Compare commits

...

30 Commits

Author SHA1 Message Date
Dante
3ac08fd1da test(assets-sidebar): add comprehensive E2E tests for Assets browser panel (#10616)
## Summary
- Extend `AssetsSidebarTab` page object with selectors for search, view
mode, asset cards, selection footer, context menu, and folder view
navigation
- Add mock data factories (`createMockJob`, `createMockJobs`,
`createMockImportedFiles`) to `AssetsHelper` for generating realistic
test fixtures
- Write 30 E2E test cases across 10 categories covering the Assets
browser sidebar panel

## Test coverage added

| Category | Tests | Details |
|----------|-------|---------|
| Empty states | 3 | Generated/Imported empty copy, zero cards |
| Tab navigation | 3 | Default tab, switching, search reset on tab
change |
| Grid view display | 2 | Generated card rendering, Imported tab assets
|
| View mode toggle | 2 | Grid↔List switching via settings menu |
| Search | 4 | Input visibility, filtering, clearing, no-match state |
| Selection | 5 | Click select, Ctrl+click multi, footer, deselect all,
tab-switch clear |
| Context menu | 7 | Right-click menu,
Download/Inspect/Delete/CopyJobID/Workflow actions, bulk menu |
| Bulk actions | 3 | Download/Delete buttons, selection count display |
| Pagination | 1 | Large job set initial load |
| Settings menu | 1 | View mode options visibility |

## Context
Part of [FixIt
Burndown](https://www.notion.so/comfy-org/FixIt-Burndown-32e6d73d365080609a81cdc9bc884460)
— "Untested Side Panels: Assets browser" assigned to @dante01yoon.

## Test plan
- [ ] Run `npx playwright test
browser_tests/tests/sidebar/assets.spec.ts` against local ComfyUI
backend
- [ ] Verify all 30 tests pass
- [ ] CI green

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10616-test-assets-sidebar-add-comprehensive-E2E-tests-for-Assets-browser-panel-3306d73d365081eeb237e559f56689bf)
by [Unito](https://www.unito.io)
2026-03-30 21:15:14 +09:00
Terry Jia
f1d5337181 Feat/glsl live preview (#10349)
## Summary
replacement for https://github.com/Comfy-Org/ComfyUI_frontend/pull/9201

the first commit squashed
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9201 and fixed
conflict.

the second commit change needed by:
- Enable GLSL live preview on SubgraphNodes by detecting the inner
GLSLShader and rendering its preview directly on the parent SubgraphNode
- Previously, SubgraphNodes containing a GLSLShader showed no live
preview at all To achieve this:
- Read shader source, uniform values, and renderer config from the inner
GLSLShader's widgets
- Trace IMAGE inputs through the subgraph boundary so the inner shader
can use images connected to the SubgraphNode's outer inputs
- Set preview output using the inner node's locator ID so the promoted
preview system picks it up on the SubgraphNode
- Extract setNodePreviewsByLocatorId from nodeOutputStore to support
setting previews by locator ID directly
- Fix graphId to use rootGraph.id for widget store lookups (was using
graph.id, which broke lookups for nodes inside subgraphs)
- Read uniform values from connected upstream nodes, not just local
widgets
- Fix blob URL lifecycle: use the store's
createSharedObjectUrl/releaseSharedObjectUrl reference-counting system
instead of manual revoke, preventing leaks on composable re-creation
        

## Screenshot


https://github.com/user-attachments/assets/9623fa32-de39-4a3a-b8b3-28688851390b

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10349-Feat-glsl-live-preview-3296d73d3650814b83aef52ab1962a77)
by [Unito](https://www.unito.io)
2026-03-29 22:26:42 -04:00
Comfy Org PR Bot
c289640e99 1.43.10 (#10726)
Patch version increment to 1.43.10

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10726-1-43-10-3336d73d36508179a69cf7affcc0070e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-29 17:47:48 -07:00
Christian Byrne
dc7c97c5ac feat: add Wave 3 homepage sections (11 Vue components) [3/3] (#10142)
## Summary
Adds all 11 homepage section components for the comfy.org marketing
site.

## Changes (incremental from #10141)
- HeroSection.vue: C monogram left, headline right, CTAs
- SocialProofBar.vue: 12 enterprise logos + metrics
- ProductShowcase.vue: PLACEHOLDER workflow demo
- ValuePillars.vue: Build/Customize/Refine/Automate/Run
- UseCaseSection.vue: PLACEHOLDER industries
- CaseStudySpotlight.vue: PLACEHOLDER bento grid
- TestimonialsSection.vue: Filterable by industry
- GetStartedSection.vue: 3-step flow
- CTASection.vue: Desktop/Cloud/API cards
- ManifestoSection.vue: Method Not Magic
- AcademySection.vue: Learning paths CTA
- Updated index.astro + zh-CN/index.astro

## Stack (via Graphite)
- #10140 [1/3] Scaffold
- #10141 [2/3] Layout Shell
- **[3/3] Homepage Sections** ← this PR (top of stack)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10142-feat-add-Wave-3-homepage-sections-11-Vue-components-3-3-3266d73d36508194aa8ee9385733ddb9)
by [Unito](https://www.unito.io)
2026-03-29 17:30:49 -07:00
Christian Byrne
8340d7655f refactor: extract auth-routing from workspaceApi to auth domain (#10484)
## Summary

Extract auth-routing logic (`getAuthHeaderOrThrow`,
`getFirebaseAuthHeaderOrThrow`) from `workspaceApi.ts` into
`authStore.ts`, eliminating a layering violation where the workspace API
re-implemented auth header resolution.

## Changes

- **What**: Moved `getAuthHeaderOrThrow` and
`getFirebaseAuthHeaderOrThrow` from `workspaceApi.ts` to `authStore.ts`.
`workspaceApi.ts` now calls through `useAuthStore()` instead of
re-implementing token resolution. Added tests for the new methods in
`authStore.test.ts`. Updated `authStoreMock.ts` with the new methods.
- **Files**: 4 files changed

## Review Focus

- The `getAuthHeaderOrThrow` / `getFirebaseAuthHeaderOrThrow` methods
throw `AuthStoreError` (auth domain error) — callers in workspace can
catch and re-wrap if needed
- `workspaceApi.ts` is simplified by ~19 lines

## Stack

PR 2/5: #10483 → **→ This PR** → #10485#10486#10487
2026-03-29 17:18:49 -07:00
Christian Byrne
1ffd92f910 config: add vitest coverage include pattern + lcov reporter (#10575)
## What

- Add `include: ['src/**/*.{ts,vue}']` to vitest coverage config so ALL
source files appear in reports (previously only imported files showed
up)
- Add `lcov` reporter for CI integration and VS Code coverage gutter
- Add `exclude` patterns for test files, locales, litegraph, assets,
declarations, stories
- Add `test:coverage` npm script

## Why

Coverage reports currently only show files that are imported during test
runs. Adding the `include` pattern reveals the true gap — files with
zero coverage that were previously invisible. The lcov reporter enables
IDE integration and future CI coverage comments (Codecov/Coveralls).

## Testing

`npx tsc --noEmit` passes. No behavioral changes — this only affects
coverage reporting configuration.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10575-config-add-vitest-coverage-include-pattern-lcov-reporter-32f6d73d365081c8b59ad2316dd2b198)
by [Unito](https://www.unito.io)
2026-03-29 16:05:45 -07:00
Christian Byrne
81d3ef22b0 refactor: extract comfyExpect and makeMatcher from ComfyPage (#10652)
## Summary

Extract `makeMatcher` and `comfyExpect` from `ComfyPage.ts` into the
standalone `browser_tests/fixtures/utils/customMatchers.ts` module,
reducing the page-object file by ~50 lines.

## Changes

- **What**: Removed duplicate `makeMatcher`/`comfyExpect` definitions
from `ComfyPage.ts`; the canonical implementation now lives in
`customMatchers.ts`. A backward-compatible re-export keeps all existing
imports working.

## Review Focus

- The re-export ensures `import { comfyExpect } from
'../fixtures/ComfyPage'` continues to resolve for all ~25 spec files.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10652-refactor-extract-comfyExpect-and-makeMatcher-from-ComfyPage-3316d73d365081bf8e7cd7fa324bf9a6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-29 16:05:10 -07:00
Christian Byrne
2d99fb446c test: add QueueClearHistoryDialog E2E tests (DLG-02) (#10586)
## Summary
Adds Playwright E2E tests for the QueueClearHistoryDialog component.

## Tests added
- Dialog opens from queue panel history actions menu
- Dialog shows confirmation message with title, description, and assets
note
- Cancel button closes dialog without clearing history
- Close (X) button closes dialog without clearing history
- Confirm clear action triggers queue history clear API call
- Dialog state resets properly after close/reopen

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

## Conventions
- Uses Vue nodes with new menu enabled (`Comfy.UseNewMenu: 'Top'`)
- Tests read as user stories
- No full-page screenshots
- Proper waits, no sleeps

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10586-test-add-QueueClearHistoryDialog-E2E-tests-DLG-02-3306d73d36508174a07bd9782340a0f7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-29 16:02:19 -07:00
Christian Byrne
dee236cd60 test: comprehensive properties panel E2E tests (PNL-01) (#10548)
## Summary
Comprehensive Playwright E2E tests for the properties panel (right
sidebar).

Part of the **Test Coverage Q2 Overhaul** initiative (Phase 2: PNL-01).

## What's included
- **PropertiesPanelHelper** page object in `browser_tests/helpers/` —
locators + action methods for all panel elements
- **35 test cases** covering:
  - Open/close via actionbar toggle
- Workflow Overview (no selection): tabs, title, nodes list, global
settings
  - Single node selection: title, parameters, info tab, widgets display
  - Multi-node selection: item count, node listing, hidden Info tab
  - Title editing: pencil icon, edit mode, rename, visibility rules
  - Search filtering: query, clear, empty state
- Settings tab: Normal/Bypass/Mute state, color swatches, pinned toggle
  - Selection transitions: no-selection ↔ single ↔ multi
  - Nodes tab: list all, search filter
  - Tab label changes based on selection count
  - **Errors tab scaffold** (for @jaeone94 ADD-03)

## Testing
- All tests use Vue nodes with new menu enabled
- Zero flaky tests (proper waits, no sleeps)
- Screenshots scoped to panel elements

## Unblocks
- **ADD-03** (error systems by @jaeone94) — errors tab scaffold ready to
extend

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10548-test-comprehensive-properties-panel-E2E-tests-PNL-01-32f6d73d36508199a216fd8d953d8e18)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-29 15:57:42 -07:00
Christian Byrne
b12b20b5ab test: add 12 workflow persistence playwright tests (#10547)
## What

12 regression tests covering 10 workflow persistence bug gaps, including
the **critical data corruption fix in PR #9531** (pythongosssss) which
previously had ZERO test coverage.

## Why

Deep scan of 37 workflow persistence bugs found 12 E2E-testable gaps
with no regression tests. Workflow persistence is a core reliability
concern — data corruption bugs are the highest risk category.

## Tests

### 🔴 Critical
| Bug | PR | Tests | Description |
|-----|----|-------|-------------|
| Data corruption | #9531 | 2 | checkState during graph loading corrupts
workflow data |
| State desync | #9533 | 2 | Rapid tab switching desyncs workflow/graph
state |

### 🟡 Medium  
| Bug | PR/Commit | Tests | Description |
|-----|-----------|-------|-------------|
| Lost previews | #9380 | 1 | Node output previews lost on tab switch |
| Stale canvas | 44bb6f13 | 1 | Canvas not cleared before loading new
workflow |
| Widget loss | #7648 | 1 | Widget values lost on graph change |
| API format | #9694 | 1 | API format workflows fail with missing nodes
|
| Paste duplication | #8259 | 1 | Middle-click paste duplicates workflow
|
| Blob URLs | #8715 | 1 | Transient blob: URLs in serialization |

### 🟢 Low
| Bug | PR/Commit | Tests | Description |
|-----|-----------|-------|-------------|
| Locale break | #8963 | 1 | Locale change breaks workflows |
| Panel drift | — | 1 | Splitter panel size drift |

## Conventions
- All tests use Vue nodes + new menu enabled
- Each test documents which PR/commit it regresses
- Proper waits (no sleeps)
- Screenshots scoped to relevant elements
- Tests read like user stories

## 🎉 Shoutout
PR #9531 by @pythongosssss was a critical data corruption fix that now
has regression test coverage for the first time.

Part of: Test Coverage Q2 Overhaul (REG-01)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10547-test-12-workflow-persistence-regression-tests-incl-critical-PR-9531-32f6d73d3650818796c6c5950c77f6d1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-29 15:57:13 -07:00
Christian Byrne
04f90b7a05 test: add mock data fixtures for backend API responses (#10662)
## Summary

Add deterministic mock data fixtures for browser tests so they can use
`page.route()` to intercept API calls without depending on a live
backend.

## Changes

- **`browser_tests/fixtures/data/nodeDefinitions.ts`** — Mock
`ComfyNodeDef` objects for KSampler, CheckpointLoaderSimple, and
CLIPTextEncode
- **`browser_tests/fixtures/data/systemStats.ts`** — Mock `SystemStats`
with realistic RTX 4090 GPU info
- **`browser_tests/fixtures/data/README.md`** — Usage guide for
`page.route()` interception

All fixtures are typed against the Zod schemas in `src/schemas/` and
pass `pnpm typecheck:browser`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10662-test-add-mock-data-fixtures-for-backend-API-responses-3316d73d3650813ea5c8c1faa215db63)
by [Unito](https://www.unito.io)

---------

Co-authored-by: dante01yoon <bunggl@naver.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-29 15:55:50 -07:00
Christian Byrne
1e7c8d9889 test: add SignIn dialog E2E tests (DLG-04) (#10587)
## Summary
Adds Playwright E2E tests for the SignIn dialog component and its
sub-forms.

## Tests added
- Dialog opens from login button in topbar
- Sign In form is the default view with email/password fields
- Toggle between Sign In and Sign Up forms
- API Key form navigation (forward and back)
- Terms of Service and Privacy Policy links present
- Form field presence verification
- Dialog close behavior (close button and Escape key)
- Forgot password link presence
- 'Or continue with' divider and API key button

## Notes
- Tests focus on UI navigation and element presence (no real Firebase
auth in test env)
- Dialog opened via `extensionManager.dialog.showSignInDialog()` API
- All selectors use stable IDs from the component source
(`#comfy-org-sign-in-email`, etc.)

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

## Conventions
- Uses Vue nodes with new menu enabled (`Comfy.UseNewMenu: 'Top'`)
- Tests read as user stories
- No full-page screenshots
- Proper waits, no sleeps

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

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-29 15:37:37 -07:00
Christian Byrne
367f810702 feat: expose renderMarkdownToHtml on ExtensionManager (#10700)
## Summary

Expose `renderMarkdownToHtml()` on the `ExtensionManager` interface so
custom node extensions can render markdown to sanitized HTML without
bundling their own copies of `marked`/`DOMPurify`.

## Motivation

Multiple custom node packs (KJNodes, comfy_mtb, rgthree-comfy) bundle
their own markdown rendering libraries to implement help popups on
nodes. This causes:

- **Cloud breakage**: KJNodes uses a `kjweb_async` pattern (custom
aiohttp static route) to lazily load `marked.min.js` and
`purify.min.js`. This 404s on Cloud because the custom route is not
registered.
- **Redundant bundling**: Both `marked` (^15.0.11) and `dompurify`
(^3.2.5) are already direct dependencies of the frontend, used
internally by `markdownRendererUtil.ts`, `NodePreview.vue`,
`WhatsNewPopup.vue`, etc.
- **XSS risk**: Custom nodes using raw `marked` without `DOMPurify`
could introduce XSS vulnerabilities.

By exposing the existing `renderMarkdownToHtml()` through the official
`ExtensionManager` API, custom nodes can:
```js
const html = app.extensionManager.renderMarkdownToHtml(nodeData.description)
```
...instead of bundling and loading their own copies.

## Changes

- **`src/types/extensionTypes.ts`**: Add `renderMarkdownToHtml(markdown:
string, baseUrl?: string): string` to the `ExtensionManager` interface
with JSDoc.
- **`src/stores/workspaceStore.ts`**: Import and re-export
`renderMarkdownToHtml` from `@/utils/markdownRendererUtil`.

## Impact

- **Zero bundle size increase** — the function and its dependencies are
already bundled in the `vendor-markdown` chunk.
- **No breaking changes** — purely additive to the `ExtensionManager`
interface.
- **Follows existing pattern** — same approach as `toast`, `dialog`,
`command`, `setting` on `ExtensionManager`.

Related: #TBD (long-term plan for custom node extension library
dependencies)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10700-feat-expose-renderMarkdownToHtml-on-ExtensionManager-3326d73d36508149bc1dc6bb45e7c077)
by [Unito](https://www.unito.io)
2026-03-29 14:51:45 -07:00
Kelly Yang
798f6de4a9 fix: image compare node displays wrong height with mismatched resolut… (#10714)
## Summary

Revert `object-cover` to `object-contain` so images are never cropped
when the container is short, and add imagecompare to `EXPANDING_TYPES`
so the widget row grows to fill the full node body instead of collapsing
to `min-content`.


## Screenshots
before
<img width="2674" height="2390" alt="image"
src="https://github.com/user-attachments/assets/8fa5cf41-f393-4a7d-a767-75ce944d00d4"
/>

after




https://github.com/user-attachments/assets/46e1fffc-5f65-4b69-9303-fe6255d9de79

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10714-fix-image-compare-node-displays-wrong-height-with-mismatched-resolut-3326d73d3650818293d3c716cb8fafb5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-29 14:45:56 -07:00
Terry Jia
752641cc67 chore: add @jtydhr88 as code owner for image crop, image compare, painter, mask editor, and 3D (#10713)
## Summary
add myself as owner to the components I worked on

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10713-chore-add-jtydhr88-as-code-owner-for-image-crop-image-compare-painter-mask-editor--3326d73d365081a5aaedf67168a32c7e)
by [Unito](https://www.unito.io)
2026-03-29 14:45:09 -07:00
Christian Byrne
af0f7cb945 refactor: extract assetPath as standalone pure function (#10651)
## Summary

Extract `assetPath` from a `ComfyPage` method to a standalone pure
function, removing unnecessary coupling to the page object.

## Changes

- **What**: Moved `assetPath` to
`browser_tests/fixtures/utils/paths.ts`. `DragDropHelper` and
`WorkflowHelper` import it directly instead of receiving it via
`ComfyPage`. `ComfyPage.assetPath` kept as thin delegate for backward
compat.

## Review Focus

Structural-only refactor — no behavioral changes. The function was
already pure (no `this`/`page` usage).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10651-refactor-extract-assetPath-as-standalone-pure-function-3316d73d365081c0b0e0ce6dde57ef8e)
by [Unito](https://www.unito.io)
2026-03-29 00:27:28 -07:00
Christian Byrne
ac0175aa6a docs: add convention for new assertions — prefer page objects over custom matchers (#10660)
## Summary

Add guidance to `docs/guidance/playwright.md` that new node-specific
assertions should be methods on page objects/helpers rather than new
`comfyExpect` custom matchers.

## Changes

- **What**: New "Custom Assertions" section in Playwright guidance
documenting that existing `comfyExpect` matchers are fine to use, but
new assertions should go on the page object for IntelliSense
discoverability.

## Review Focus

Documentation-only change. No code refactoring — this is a convention
for new code only.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10660-docs-add-convention-for-new-assertions-prefer-page-objects-over-custom-matchers-3316d73d3650816d97a8fbbdc33f6b75)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-29 00:27:13 -07:00
Christian Byrne
1e1b3884c5 refactor: include backend-mirrored endpoints in ingest-types codegen (#10697)
## Summary

Remove the exclusion filter that prevented backend-mirrored endpoint
types from being generated in `@comfyorg/ingest-types`.

## Changes

- **What**: The `openapi-ts.config.ts` excluded all endpoints shared
with the ComfyUI Python backend (system_stats, object_info, prompt,
queue, history, settings, userdata, etc.). Since the cloud ingest API
mirrors the backend, these types should be generated from the OpenAPI
spec as the canonical source. This adds ~250 new types and Zod schemas
covering previously excluded endpoints.
- **Breaking**: None. This only adds new exported types — no existing
types or imports are changed.

## Review Focus

- The cloud ingest API is designed to mirror the ComfyUI Python backend.
The original exclusion filter was added to avoid duplication with
`src/schemas/apiSchema.ts`, but the generated types should be the
canonical source since they are auto-generated from the OpenAPI spec.
- A follow-up PR will migrate imports in `src/` from `apiSchema.ts` to
`@comfyorg/ingest-types` where applicable.
- Webhooks and internal analytics endpoints remain excluded
(server-to-server, not frontend-relevant).

Related: #10662

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10697-refactor-include-backend-mirrored-endpoints-in-ingest-types-codegen-3326d73d365081569614f743ab6f074d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-29 00:21:05 -07:00
Dante
bce7a168de fix: type API mock responses in browser tests (#10668)
## Motivation

Browser tests mock API responses with `route.fulfill()` using untyped
inline JSON. When the OpenAPI spec changes, these mocks silently drift —
mismatches aren't caught at compile time and only surface as test
failures at runtime.

We already have auto-generated types from OpenAPI and manual Zod
schemas. This PR makes those types the source of truth for test mock
data.

From Mar 27 PR review session action item: "instruct agents to use
schemas and types when writing browser tests."

## Type packages and their API coverage

The frontend has two OpenAPI-generated type packages, each targeting a
different backend API with a different code generation tool:

| Package | Target API | Generator | TS types | Zod schemas |
|---------|-----------|-----------|----------|-------------|
| `@comfyorg/registry-types` | Registry API (node packages, releases,
subscriptions, customers) | `openapi-typescript` | Yes | **No** |
| `@comfyorg/ingest-types` | Ingest API (hub workflows, asset uploads,
workspaces) | `@hey-api/openapi-ts` | Yes | Yes |

Additionally, Python backend endpoints (`/api/queue`, `/api/features`,
`/api/settings`, etc.) are typed via manual Zod schemas in
`src/schemas/apiSchema.ts`.

This PR applies **compile-time type checking** using these existing
types. Runtime validation via Zod `.parse()` is not yet possible for all
endpoints because `registry-types` does not generate Zod schemas — this
requires a separate migration of `registry-types` to
`@hey-api/openapi-ts` (#10674).

## Summary

- Add "Typed API Mocks" guideline to `docs/guidance/playwright.md` with
a sources-of-truth table mapping endpoint categories to their type
packages
- Add rule to `AGENTS.md` Playwright section requiring typed mock data
- Refactor `releaseNotifications.spec.ts` to use `ReleaseNote` type
(from `registry-types`) via `createMockRelease()` factory
- Annotate template mock in `templates.spec.ts` with
`WorkflowTemplates[]` type

Refs #10656

## Example workflow: writing a new typed E2E test mock

When adding a new `route.fulfill()` mock, follow these steps:

### 1. Identify the type source

Check which API the endpoint belongs to:

| Endpoint category | Type source | Zod available |
|---|---|---|
| Ingest API (hub, billing, workflows) | `@comfyorg/ingest-types` | Yes
— use `.parse()` |
| Registry API (releases, nodes, publishers) |
`@comfyorg/registry-types` | Not yet (#10674) — TS type only |
| Python backend (queue, history, settings) | `src/schemas/apiSchema.ts`
| Yes — use `z.infer` |
| Templates | `src/platform/workflow/templates/types/template.ts` | No —
TS type only |

### 2. Create a typed factory (with Zod when available)

**Ingest API endpoints** — Zod schemas exist, use `.parse()` for runtime
validation:

```typescript
import { zBillingStatusResponse } from '@comfyorg/ingest-types/zod'
import type { BillingStatusResponse } from '@comfyorg/ingest-types'

function createMockBillingStatus(
  overrides?: Partial<BillingStatusResponse>
): BillingStatusResponse {
  return zBillingStatusResponse.parse({
    plan: 'free',
    credits_remaining: 100,
    renewal_date: '2026-04-28T00:00:00Z',
    ...overrides
  })
}
```

**Registry API endpoints** — TS type only (Zod not yet generated):

```typescript
import type { ReleaseNote } from '../../src/platform/updates/common/releaseService'

function createMockRelease(
  overrides?: Partial<ReleaseNote>
): ReleaseNote {
  return {
    id: 1,
    project: 'comfyui',
    version: 'v0.3.44',
    attention: 'medium',
    content: '## New Features',
    published_at: new Date().toISOString(),
    ...overrides
  }
}
```

### 3. Use in test

```typescript
test('should show upgrade banner for free plan', async ({ comfyPage }) => {
  await comfyPage.page.route('**/billing/status', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify(createMockBillingStatus({ plan: 'free' }))
    })
  })

  await comfyPage.setup()
  await expect(comfyPage.page.getByText('Upgrade')).toBeVisible()
})
```

The factory pattern keeps test bodies focused on **what varies** (the
override) rather than the full response shape.

## Scope decisions

| File | Decision | Reason |
|------|----------|--------|
| `releaseNotifications.spec.ts` | Typed | `ReleaseNote` type available
from `registry-types` |
| `templates.spec.ts` | Typed | `WorkflowTemplates` type available in
`src/platform/workflow/templates/types/` |
| `QueueHelper.ts` | Skipped | Dead code — instantiated but never called
in any test |
| `FeatureFlagHelper.ts` | Skipped | Response type is inherently
`Record<string, unknown>`, no stronger type exists |
| Fixture factories | Deferred | Coordinate with Ben's fixture
restructuring work to avoid duplication |

## Follow-up work

Sub-issues of #10656:

- #10670 — Clean up dead `QueueHelper` or rewrite against `/api/jobs`
endpoint
- #10671 — Expand typed factory pattern to more endpoints
- #10672 — Evaluate OpenAPI generation for excluded Python backend
endpoints
- #10674 — Migrate `registry-types` from `openapi-typescript` to
`@hey-api/openapi-ts` to enable Zod schema generation

## Test plan

- [x] `pnpm typecheck:browser` passes
- [x] `pnpm lint` passes
- [ ] Existing `releaseNotifications` and `templates` tests pass in CI
2026-03-29 15:45:06 +09:00
Christian Byrne
e7c2cd04f4 perf: add FPS, p95 frame time, and target thresholds to CI perf report (#10516)
## Summary

Enhances the CI performance report with explicit FPS metrics, percentile
frame times, and milestone target thresholds.

### Changes

**PerformanceHelper** (data collection):
- `measureFrameDurations()` now returns individual frame durations
instead of just the average, enabling percentile computation
- Computes `p95FrameDurationMs` from sorted frame durations
- Strips `allFrameDurationsMs` from serialized JSON to avoid bloating
artifacts

**perf-report.ts** (report rendering):
- **Headline summary** at top of report with key metrics per test
scenario
- **FPS display**: derives avg FPS and P5 FPS from frame duration
metrics
- **Target thresholds**: shows P5 FPS ≥ 52 target with / pass/fail
indicator
- **p95 frame time**: added as a tracked metric in the comparison table
- Metrics reordered to show frame time/FPS first (what people look for)

### Target

From the Nodes 2.0 Perf milestone: **P5 ≥ 52 FPS** on 245-node workflow
(equivalent to P95 frame time ≤ 19.2ms).

### Example headline output

```
> **vue-large-graph-pan**: 60 avg FPS · 58 P5 FPS  (target: ≥52) · 12ms TBT · 45.2 MB heap
> **canvas-zoom-sweep**: 45 avg FPS · 38 P5 FPS  (target: ≥52) · 85ms TBT · 52.1 MB heap
```

Follow-up to #10477 (merged).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10516-perf-add-FPS-p95-frame-time-and-target-thresholds-to-CI-perf-report-32e6d73d365081a2a2a6ceae7d6e9be5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-28 23:29:19 -07:00
Terry Jia
391a6db056 test: add minimap e2e tests for close button, viewport, and pan (#10596)
## Summary
add more basic tests for minimap

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10596-test-add-minimap-e2e-tests-for-close-button-viewport-and-pan-3306d73d365081b9bf64dc7a3951d65f)
by [Unito](https://www.unito.io)
2026-03-28 23:19:04 -07:00
Christian Byrne
4d4dca2a46 docs: document fixture/page-object separation in browser tests (#10645)
## Summary

Document the agreed-upon architectural separation for browser test
fixtures:

- `fixtures/data/` — Static test data (mock API responses, workflow
JSONs, node definitions)
- `fixtures/components/` — Page object components (locators, user
interactions)
- `fixtures/helpers/` — Focused helper classes (domain-specific actions)
- `fixtures/utils/` — Pure utility functions (no page dependency)

## Changes

- **`browser_tests/AGENTS.md`** — Added architectural separation section
with clear rules for each directory
- **`browser_tests/fixtures/data/README.md`** (new) — Explains the data
directory purpose and what belongs here vs `assets/`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10645-docs-document-fixture-page-object-separation-in-browser-tests-3316d73d365081febf52d165282c68f6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-28 23:11:34 -07:00
Christian Byrne
ba9f3481fb test(infra): cloud Playwright project with @cloud/@oss tagging (#10546)
## What

Adds a `cloud` Playwright project so E2E tests can run against
`DISTRIBUTION=cloud` builds, with `@cloud` / `@oss` test tagging.

## Why

100+ usages of `isCloud` / `DISTRIBUTION` across 9 categories (API
routing, UI visibility, settings, auth). Zero cloud test infrastructure
existed — cloud-specific UI components (LoginButton, SubscribeButton,
etc.) had no E2E coverage path.

## Investigation: Runtime Toggle

Investigated whether `isCloud` could be made runtime-toggleable in
dev/test mode (via `window.__FORCE_CLOUD__`). **Not feasible** —
`__DISTRIBUTION__` is a Vite `define` compile-time constant used for
dead-code elimination. Runtime override would break tree-shaking in
production.

Full investigation:
`research/architecture/cloud-runtime-toggle-investigation.md`

## What's included

### Playwright Config
- New `cloud` project alongside existing `chromium`
- Cloud project: `grep: /@cloud/` — only runs `@cloud` tagged tests
- Chromium project: `grepInvert: /@cloud/` — excludes cloud tests

### Build Script
- `npm run build:cloud` → `DISTRIBUTION=cloud vite build`

### Test Tagging Convention
```typescript
test('works in both', async () => { ... });
test('subscription button visible @cloud', async () => { ... });
test('install manager prompt @oss', async () => { ... });
```

### Example Tests
- 2 cloud-only tests validating cloud UI visibility

## NOT included (future work)
- CI workflow job for cloud tests (separate PR)
- Cloud project is opt-in — not run by default locally

## Unblocks
- Cloud-specific E2E tests for entire team
- TB-03 LoginButton, TB-04 SubscribeButton (@Kaili Yang)
- DLG-04 SignIn, DLG-06 CancelSubscription

Part of: Test Coverage Q2 Overhaul

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10546-test-infra-cloud-Playwright-project-with-cloud-oss-tagging-32f6d73d3650810ebb59dea8ce4891e9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-28 22:34:37 -07:00
Comfy Org PR Bot
7cbd61aaea 1.43.9 (#10693)
Patch version increment to 1.43.9

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10693-1-43-9-3326d73d3650815d8e77e1db06a91b53)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-28 22:18:45 -07:00
Christian Byrne
b09562a1bf docs: document Playwright fixture injection pattern for new helpers (#10653)
## Summary

Document the recommended pattern for adding new domain-specific test
helpers as Playwright fixtures via `base.extend()` instead of attaching
them to `ComfyPage`.

## Changes

- **What**: Added "Creating New Test Helpers" section to
`docs/guidance/playwright.md` with fixture extension example and rules

## Review Focus

Documentation-only change. Verify the example code matches the existing
pattern in `browser_tests/fixtures/ComfyPage.ts`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10653-docs-document-Playwright-fixture-injection-pattern-for-new-helpers-3316d73d36508145b402cf02a5c2c696)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-28 21:37:02 -07:00
Christian Byrne
cc8ef09d28 docs: add arrange/act/assert pattern guidance for browser tests (#10657)
## Summary

Document the arrange/act/assert pattern for Playwright browser tests to
keep mock setup out of test bodies.

## Changes

- **What**: Added "Test Structure: Arrange/Act/Assert" section to
`docs/guidance/playwright.md` documenting that mock setup belongs in
`beforeEach`/fixtures, test bodies should only act and assert, and
`clearAllMocks` should never be called mid-test. Includes good/bad
examples.

## Review Focus

Docs-only change — no code impact.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10657-docs-add-arrange-act-assert-pattern-guidance-for-browser-tests-3316d73d365081aa92c0fb6442084484)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-28 21:23:06 -07:00
Christian Byrne
64917e5b6c feat: add Playwright E2E agent check for reviewing browser tests (#10684)
Adds `.agents/checks/playwright-e2e.md` — a reviewer-focused agent check
for Playwright E2E tests.

**19 checks across 4 severity tiers:**

- **Major (1-7)** — Flakiness risks: `waitForTimeout`, missing
`nextFrame()`, unfocused keyboard, coordinate fragility, shared state,
server cleanup, double-click timing
- **Medium (8-11)** — Fixture/API misuse: reimplementing helpers, wrong
imports, programmatic graph building, missing `TestIds`
- **Minor (12-16)** — Convention violations: missing tags, `as any`,
unmasked screenshots, missing cleanup, debug helpers
- **Nitpick (17-19)** — Test design: screenshot-over-functional, large
workflows, Vue/LiteGraph mismatch

Hyperlinks to existing docs (`browser_tests/README.md`, `AGENTS.md`,
`docs/guidance/playwright.md`, writing skill) rather than duplicating
content. Scoped to reviewer concerns (not writer guidance).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10684-feat-add-Playwright-E2E-agent-check-for-reviewing-browser-tests-3316d73d365081d88f9dea4467ee04cf)
by [Unito](https://www.unito.io)
2026-03-28 21:14:16 -07:00
Alexander Brown
0e7cab96b7 test: reorganize subgraph E2E tests into domain-organized directory (#10695)
## Summary

From the primordial entropy of 17 scattered spec files — a formless
sprawl of mixed concerns and inconsistent naming — emerges a clean,
domain-organized hierarchy. Order triumphs over chaos.

## Changes

- **What**: Reorganize all subgraph E2E tests from 17 flat files in
`browser_tests/tests/` into 10 domain-grouped files under
`browser_tests/tests/subgraph/`.

| File | Tests | Domain |
|------|-------|--------|
| `subgraphSlots` | 16 | I/O slot CRUD, rename, alignment, promoted slot
position |
| `subgraphPromotion` | 22 | Auto-promote, visibility, reactivity,
context menu, cleanup |
| `subgraphSerialization` | 16 | Hydration, round-trip, legacy formats,
ID remapping |
| `subgraphNavigation` | 10 | Breadcrumb, viewport, hotkeys, progress
state |
| `subgraphNested` | 9 | Configure order, duplicate names, pack values,
stale proxies |
| `subgraphLifecycle` | 7 | Source removal cleanup, pseudo-preview
lifecycle |
| `subgraphPromotionDom` | 6 | DOM widget persistence, cleanup,
positioning |
| `subgraphCrud` | 5 | Create, delete, copy, unpack |
| `subgraphSearch` | 3 | Search aliases, description, persistence |
| `subgraphOperations` | 2 | Copy/paste inside, undo/redo inside |

Where once the monolith `subgraph.spec.ts` (856 lines) mixed slot CRUD
with hotkeys, DOM widgets with navigation, and copy/paste with undo/redo
— now each behavioral domain has its sovereign territory.

Where once `subgraph-rename-dialog.spec.ts`,
`subgraphInputSlotRename.spec.ts`, and
`subgraph-promoted-slot-position.spec.ts` scattered rename concerns
across three kingdoms — now they answer to one crown:
`subgraphSlots.spec.ts`.

Where once `kebab-case` and `camelCase` warred for dominion — now a
single convention reigns.

All 96 test cases preserved. Zero test logic changes. Purely structural.

## Review Focus

- Verify no tests were lost in the consolidation
- Confirm import paths all resolve correctly at the new depth
(`../../fixtures/`)
- The `import.meta.dirname` asset path in `subgraphSlots.spec.ts` (slot
alignment test) updated for new directory depth

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10695-test-reorganize-subgraph-E2E-tests-into-domain-organized-directory-3326d73d36508197939be8825b69ea88)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-28 21:06:00 -07:00
Christian Byrne
e0d16b7ee9 docs: add Fixture Data & Schemas section to Playwright test guidance (#10642)
## Summary

Add a "Fixture Data & Schemas" section to `docs/guidance/playwright.md`
so agents reference existing Zod schemas and TypeScript types when
creating test fixture data.

## Changes

- **What**: New section listing key schema/type locations (`apiSchema`,
`nodeDefSchema`, `jobTypes`, `workflowSchema`, etc.) to keep test
fixtures in sync with production types.

## Review Focus

Documentation-only change; no runtime impact.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10642-docs-add-Fixture-Data-Schemas-section-to-Playwright-test-guidance-3316d73d365081f5a234e4672b3dc4b9)
by [Unito](https://www.unito.io)
2026-03-28 19:03:06 -07:00
Christian Byrne
8eb1525171 feat: add assertHasItems and openFor to ContextMenu page object (#10659)
## Summary

Add composite assertion and scoped opening methods to the `ContextMenu`
Playwright page object.

## Changes

- **What**: Added `assertHasItems(items: string[])` using
`expect.soft()` per item, and `openFor(locator: Locator)` which
right-clicks and waits for menu visibility. Fully backward-compatible.

## Review Focus

Both methods reuse existing locators (`primeVueMenu`, `litegraphMenu`,
`getByRole("menuitem")`). `openFor` uses `.or()` to handle both menu
types.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10659-feat-add-assertHasItems-and-openFor-to-ContextMenu-page-object-3316d73d36508193af45da7d3af4f50c)
by [Unito](https://www.unito.io)
2026-03-28 18:58:16 -07:00
139 changed files with 10908 additions and 2830 deletions

View File

@@ -0,0 +1,74 @@
---
name: playwright-e2e
description: Reviews Playwright E2E test code for ComfyUI-specific patterns, flakiness risks, and fixture misuse
severity-default: medium
tools: [Read, Grep]
---
You are reviewing Playwright E2E test code in `browser_tests/`. Focus on issues a **reviewer** would catch that an author might miss — flakiness risks, fixture misuse, test isolation problems, and convention violations.
Reference docs (read if you need full context):
- `browser_tests/README.md` — setup, patterns, screenshot workflow
- `browser_tests/AGENTS.md` — directory structure, fixture overview
- `docs/guidance/playwright.md` — type assertion rules, test tags, forbidden patterns
- `.claude/skills/writing-playwright-tests/SKILL.md` — anti-patterns, retry patterns, Vue Nodes vs LiteGraph decision guide
## Checks
### Flakiness Risks (Major)
1. **`waitForTimeout` usage** — Always wrong. Must use retrying assertions (`toBeVisible`, `toHaveText`), `expect.poll()`, or `expect().toPass()`. See retry patterns in `.claude/skills/writing-playwright-tests/SKILL.md`.
2. **Missing `nextFrame()` after canvas ops** — Any `drag`, `click` on canvas, `resizeNode`, `pan`, `zoom`, or programmatic graph mutation via `page.evaluate` that changes visual state needs `await comfyPage.nextFrame()` before assertions. `loadWorkflow()` does NOT need it. Prefer encapsulating `nextFrame()` calls inside Page Object methods so tests don't manage frame timing directly.
3. **Keyboard actions without prior focus**`page.keyboard.press()` without a preceding `comfyPage.canvas.click()` or element `.focus()` will silently send keys to nothing.
4. **Coordinate-based interactions where node refs exist** — Raw `{ x, y }` clicks on canvas are fragile. If the test targets a node, use `comfyPage.nodeOps.getNodeRefById()` / `getNodeRefsByTitle()` / `getNodeRefsByType()` instead.
5. **Shared mutable state between tests** — Variables declared outside `test()` blocks, `let` state mutated across tests, or tests depending on execution order. Each test must be independently runnable.
6. **Missing cleanup of server-persisted state** — Settings changed via `comfyPage.settings.setSetting()` persist across tests. Must be reset in `afterEach` or at test start. Same for uploaded files or saved workflows. Prefer moving cleanup into [fixture options](https://playwright.dev/docs/test-fixtures#fixtures-options) so individual tests don't manage reset logic.
7. **Double-click without `{ delay }` option**`dblclick()` without `{ delay: 5 }` or similar can be too fast for the canvas event handler.
### Fixture & API Misuse (Medium)
8. **Reimplementing existing fixture helpers** — Before flagging, grep `browser_tests/fixtures/` for the functionality. Common missed helpers:
- `comfyPage.command.executeCommand()` for menu/command actions
- `comfyPage.workflow.loadWorkflow()` for loading test workflows
- `comfyPage.canvasOps.resetView()` for view reset
- `comfyPage.settings.setSetting()` for settings
- Component page objects in `browser_tests/fixtures/components/`
9. **Building workflows programmatically when a JSON asset would work** — Complex `page.evaluate` chains to construct a graph should use a premade JSON workflow in `browser_tests/assets/` loaded via `comfyPage.workflow.loadWorkflow()`.
10. **Selectors not using `TestIds`** — Hard-coded `data-testid` strings should reference `browser_tests/fixtures/selectors.ts` when a matching entry exists. Check `selectors.ts` before flagging.
### Convention Violations (Minor)
11. **Missing test tags** — Every `test.describe` should have `tag` with at least one of: `@smoke`, `@slow`, `@screenshot`, `@canvas`, `@node`, `@widget`, `@mobile`, `@2x`. See `.claude/skills/writing-playwright-tests/SKILL.md` for when to use each.
12. **`as any` type assertions** — Forbidden in E2E tests. Use specific type assertions or test-local type helpers. See `docs/guidance/playwright.md` for acceptable patterns.
13. **Screenshot tests without masking dynamic content** — Timestamps, version numbers, or other non-deterministic content in screenshots will cause flakes. Use `mask` option.
14. **`test.describe` without `afterEach` cleanup when canvas state changes** — Tests that manipulate canvas view (drag, zoom, pan) should include `afterEach` with `comfyPage.canvasOps.resetView()`. Prefer moving canvas reset into the fixture so individual tests don't manage cleanup.
15. **Debug helpers left in committed code**`debugAddMarker`, `debugAttachScreenshot`, `debugShowCanvasOverlay`, `debugGetCanvasDataURL` are for local debugging only.
### Test Design (Nitpick)
16. **Screenshot-only assertions where functional assertions are possible** — Prefer `expect(await node.isPinned()).toBe(true)` over screenshot comparison when testing non-visual behavior.
17. **Overly large test workflows** — Test should load the minimal workflow needed. If a test only needs one node, don't load the full default graph.
18. **Vue Nodes / LiteGraph mismatch** — If testing Vue-rendered node UI (DOM widgets, CSS states), should use `comfyPage.vueNodes.*`. If testing canvas interactions/connections, should use `comfyPage.nodeOps.*`. Mixing both in one test is a smell.
## Rules
- Only review `.spec.ts` files and supporting code in `browser_tests/`
- Do NOT flag patterns in fixture/helper code (`browser_tests/fixtures/`) — those are shared infrastructure with different rules
- "Major" for flakiness risks (items 1-7), "medium" for fixture misuse (8-10), "minor" for convention violations (11-15), "nitpick" for test design (16-18)
- When flagging missing fixture usage (item 8), confirm the helper exists by checking the fixture code — don't assume
- Existing tests that predate conventions are acceptable to modify but not required to fix

View File

@@ -95,7 +95,7 @@ jobs:
if npx license-checker-rseidelsohn@4 \
--production \
--summary \
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/ingest-types;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
--clarificationsFile .github/license-clarifications.json \
--onlyAllow 'MIT;MIT*;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;BlueOak-1.0.0;Python-2.0;CC0-1.0;Unlicense;(MIT OR Apache-2.0);(MIT OR GPL-3.0);(Apache-2.0 OR MIT);(MPL-2.0 OR Apache-2.0);CC-BY-4.0;CC-BY-3.0;GPL-3.0-only'; then
echo ''

View File

@@ -33,6 +33,20 @@ jobs:
path: dist/
retention-days: 1
# Build cloud distribution for @cloud tagged tests
# NX_SKIP_NX_CACHE=true is required because `nx build` was already run
# for the OSS distribution above. Without skipping cache, Nx returns
# the cached OSS build since env vars aren't part of the cache key.
- name: Build cloud frontend
run: NX_SKIP_NX_CACHE=true pnpm build:cloud
- name: Upload cloud frontend
uses: actions/upload-artifact@v6
with:
name: frontend-dist-cloud
path: dist/
retention-days: 1
# Sharded chromium tests
playwright-tests-chromium-sharded:
needs: setup
@@ -97,14 +111,14 @@ jobs:
strategy:
fail-fast: false
matrix:
browser: [chromium-2x, chromium-0.5x, mobile-chrome]
browser: [chromium-2x, chromium-0.5x, mobile-chrome, cloud]
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Download built frontend
uses: actions/download-artifact@v7
with:
name: frontend-dist
name: ${{ matrix.browser == 'cloud' && 'frontend-dist-cloud' || 'frontend-dist' }}
path: dist/
- name: Start ComfyUI server

View File

@@ -1,4 +1,4 @@
# Description: Unit and component testing with Vitest
# Description: Unit and component testing with Vitest + coverage reporting
name: 'CI: Tests Unit'
on:
@@ -23,5 +23,12 @@ jobs:
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run Vitest tests
run: pnpm test:unit
- name: Run Vitest tests with coverage
run: pnpm test:coverage
- name: Upload coverage to Codecov
if: always()
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/lcov.info
fail_ci_if_error: false

View File

@@ -216,6 +216,7 @@ See @docs/testing/\*.md for detailed patterns.
1. Follow the Best Practices described [in the Playwright documentation](https://playwright.dev/docs/best-practices)
2. Do not use waitForTimeout, use Locator actions and [retrying assertions](https://playwright.dev/docs/test-assertions#auto-retrying-assertions)
3. Tags like `@mobile`, `@2x` are respected by config and should be used for relevant tests
4. Type all API mock responses in `route.fulfill()` using generated types or schemas from `packages/ingest-types`, `packages/registry-types`, `src/workbench/extensions/manager/types/generatedManagerTypes.ts`, or `src/schemas/` — see `docs/guidance/playwright.md` for the full source-of-truth table
## External Resources

View File

@@ -41,12 +41,46 @@
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
# Mask Editor
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88
# Image Crop
/src/extensions/core/imageCrop.ts @jtydhr88
/src/components/imagecrop/ @jtydhr88
/src/composables/useImageCrop.ts @jtydhr88
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88
# Image Compare
/src/extensions/core/imageCompare.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88
# Painter
/src/extensions/core/painter.ts @jtydhr88
/src/components/painter/ @jtydhr88
/src/composables/painter/ @jtydhr88
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
# 3D
/src/extensions/core/load3d.ts @jtydhr88
/src/extensions/core/load3dLazy.ts @jtydhr88
/src/extensions/core/load3d/ @jtydhr88
/src/components/load3d/ @jtydhr88
/src/composables/useLoad3d.ts @jtydhr88
/src/composables/useLoad3d.test.ts @jtydhr88
/src/composables/useLoad3dDrag.ts @jtydhr88
/src/composables/useLoad3dDrag.test.ts @jtydhr88
/src/composables/useLoad3dViewer.ts @jtydhr88
/src/composables/useLoad3dViewer.test.ts @jtydhr88
/src/services/load3dService.ts @jtydhr88
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
const features = [
{ icon: '📚', label: 'Guided Tutorials' },
{ icon: '🎥', label: 'Video Courses' },
{ icon: '🛠️', label: 'Hands-on Projects' }
]
</script>
<template>
<section class="bg-charcoal-800 py-24">
<div class="mx-auto max-w-3xl px-6 text-center">
<!-- Badge -->
<span
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs uppercase tracking-widest text-brand-yellow"
>
COMFY ACADEMY
</span>
<h2 class="mt-6 text-3xl font-bold text-white">Master AI Workflows</h2>
<p class="mt-4 text-smoke-700">
Learn to build professional AI workflows with guided tutorials, video
courses, and hands-on projects.
</p>
<!-- Feature bullets -->
<div class="mt-8 flex flex-wrap items-center justify-center gap-8">
<div
v-for="feature in features"
:key="feature.label"
class="flex items-center gap-2 text-sm text-white"
>
<span aria-hidden="true">{{ feature.icon }}</span>
<span>{{ feature.label }}</span>
</div>
</div>
<!-- CTA -->
<a
href="/academy"
class="mt-8 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
>
EXPLORE ACADEMY
</a>
</div>
</section>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
const cards = [
{
icon: '🖥️',
title: 'Comfy Desktop',
description: 'Full power on your local machine. Free and open source.',
cta: 'DOWNLOAD',
href: '/download',
outlined: false
},
{
icon: '☁️',
title: 'Comfy Cloud',
description: 'Run workflows in the cloud. No GPU required.',
cta: 'TRY CLOUD',
href: 'https://app.comfy.org',
outlined: false
},
{
icon: '⚡',
title: 'Comfy API',
description: 'Integrate AI generation into your applications.',
cta: 'VIEW DOCS',
href: 'https://docs.comfy.org',
outlined: true
}
]
</script>
<template>
<section class="bg-charcoal-800 py-24">
<div class="mx-auto max-w-5xl px-6">
<h2 class="text-center text-3xl font-bold text-white">
Choose Your Way to Comfy
</h2>
<!-- CTA cards -->
<div class="mt-12 grid grid-cols-1 gap-6 md:grid-cols-3">
<a
v-for="card in cards"
:key="card.title"
:href="card.href"
class="flex flex-1 flex-col items-center rounded-xl border border-white/10 bg-charcoal-600 p-8 text-center transition-colors hover:border-brand-yellow"
>
<span class="text-4xl" aria-hidden="true">{{ card.icon }}</span>
<h3 class="mt-4 text-xl font-semibold text-white">
{{ card.title }}
</h3>
<p class="mt-2 text-sm text-smoke-700">
{{ card.description }}
</p>
<span
class="mt-6 inline-block rounded-full px-6 py-2 text-sm font-semibold transition-opacity hover:opacity-90"
:class="
card.outlined
? 'border border-brand-yellow text-brand-yellow'
: 'bg-brand-yellow text-black'
"
>
{{ card.cta }}
</span>
</a>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,77 @@
<!-- TODO: Replace placeholder content with real quotes and case studies -->
<script setup lang="ts">
const studies = [
{
title: 'New Pipelines with Chord Mode',
body: 'For AI-assisted texture and environment generation across studio pipelines.',
highlight: false,
gridClass: 'md:row-span-2'
},
{
title: 'AI-Assisted Texture and Environment',
body: 'For AI-assisted texture and environment generation across studio pipelines.',
highlight: false,
gridClass: 'min-h-[300px] lg:col-span-2'
},
{
title: 'Open-sourced the Chord Mode',
body: 'For AI-assisted texture and environment generation across studio pipelines.',
highlight: false,
gridClass: 'min-h-[200px]'
},
{
title: 'Environment Generation',
body: 'For AI-assisted texture and environment generation across studio pipelines.',
highlight: true,
gridClass: 'min-h-[200px]'
}
]
</script>
<template>
<section class="bg-black px-6 py-24">
<div class="mx-auto max-w-7xl">
<header class="mb-12">
<h2 class="text-3xl font-bold text-white">Customer Stories</h2>
<p class="mt-2 text-smoke-700">
See how leading studios use Comfy in production
</p>
</header>
<!-- Bento grid -->
<div
class="relative grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
>
<article
v-for="study in studies"
:key="study.title"
class="flex flex-col justify-end rounded-2xl border border-brand-yellow/30 p-6"
:class="[
study.gridClass,
study.highlight ? 'bg-brand-yellow' : 'bg-charcoal-600'
]"
>
<h3
class="font-semibold"
:class="study.highlight ? 'text-black' : 'text-white'"
>
{{ study.title }}
</h3>
<p
class="mt-2 text-sm"
:class="study.highlight ? 'text-black/70' : 'text-smoke-700'"
>
{{ study.body }}
</p>
<a
href="/case-studies"
class="mt-4 text-sm underline"
:class="study.highlight ? 'text-black' : 'text-brand-yellow'"
>
READ CASE STUDY
</a>
</article>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
const steps = [
{
number: '1',
title: 'Download & Sign Up',
description: 'Get Comfy Desktop for free or create a Cloud account'
},
{
number: '2',
title: 'Load a Workflow',
description:
'Choose from thousands of community workflows or build your own'
},
{
number: '3',
title: 'Generate',
description: 'Hit run and watch your AI workflow come to life'
}
]
</script>
<template>
<section class="border-t border-white/10 bg-black py-24">
<div class="mx-auto max-w-7xl px-6 text-center">
<h2 class="text-3xl font-bold text-white">Get Started in Minutes</h2>
<p class="mt-4 text-smoke-700">
From download to your first AI-generated output in three simple steps
</p>
<!-- Steps -->
<div class="mt-16 grid grid-cols-1 gap-8 md:grid-cols-3">
<div v-for="(step, index) in steps" :key="step.number" class="relative">
<!-- Connecting line between steps (desktop only) -->
<div
v-if="index < steps.length - 1"
class="absolute right-0 top-8 hidden w-full translate-x-1/2 border-t border-brand-yellow/20 md:block"
/>
<div class="relative">
<span class="text-6xl font-bold text-brand-yellow/20">
{{ step.number }}
</span>
<h3 class="mt-2 text-xl font-semibold text-white">
{{ step.title }}
</h3>
<p class="mt-2 text-sm text-smoke-700">
{{ step.description }}
</p>
</div>
</div>
</div>
<!-- CTA -->
<a
href="/download"
class="mt-12 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
>
DOWNLOAD COMFY
</a>
</div>
</section>
</template>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
const ctaButtons = [
{
label: 'GET STARTED',
href: 'https://app.comfy.org',
variant: 'solid' as const
},
{
label: 'LEARN MORE',
href: '/about',
variant: 'outline' as const
}
]
</script>
<template>
<section
class="relative flex min-h-screen items-center overflow-hidden bg-black pt-16"
>
<div
class="mx-auto flex w-full max-w-7xl flex-col items-center gap-12 px-6 md:flex-row md:gap-0"
>
<!-- Left: C Monogram -->
<div class="flex w-full items-center justify-center md:w-[55%]">
<div class="relative -ml-12 -rotate-15 md:-ml-24" aria-hidden="true">
<div
class="h-64 w-64 rounded-full border-[40px] border-brand-yellow md:h-[28rem] md:w-[28rem] md:border-[64px] lg:h-[36rem] lg:w-[36rem] lg:border-[80px]"
>
<!-- Gap on the right side to form "C" shape -->
<div
class="absolute right-0 top-1/2 h-32 w-24 -translate-y-1/2 translate-x-1/2 bg-black md:h-48 md:w-36 lg:h-64 lg:w-48"
/>
</div>
</div>
</div>
<!-- Right: Text content -->
<div class="flex w-full flex-col items-start md:w-[45%]">
<h1
class="text-5xl font-bold leading-tight tracking-tight text-white md:text-6xl lg:text-7xl"
>
Professional Control of Visual AI
</h1>
<p class="mt-6 max-w-lg text-lg text-smoke-700">
Comfy is the AI creation engine for visual professionals who demand
control over every model, every parameter, and every output.
</p>
<div class="mt-8 flex flex-wrap gap-4">
<a
v-for="btn in ctaButtons"
:key="btn.label"
:href="btn.href"
class="rounded-full px-8 py-3 text-sm font-semibold transition-opacity hover:opacity-90"
:class="
btn.variant === 'solid'
? 'bg-brand-yellow text-black'
: 'border border-brand-yellow text-brand-yellow'
"
>
{{ btn.label }}
</a>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,26 @@
<template>
<section class="bg-black py-24">
<div class="mx-auto max-w-4xl px-6 text-center">
<!-- Decorative quote mark -->
<span class="text-6xl text-brand-yellow opacity-30" aria-hidden="true">
&laquo;
</span>
<h2 class="text-4xl font-bold text-white md:text-5xl">
Method, Not Magic
</h2>
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700">
We believe in giving creators real control over AI. Not black boxes. Not
magic buttons. But transparent, reproducible, node-by-node control over
every step of the creative process.
</p>
<!-- Separator line -->
<div
class="mx-auto mt-8 h-0.5 w-24 bg-brand-yellow opacity-30"
aria-hidden="true"
/>
</div>
</section>
</template>

View File

@@ -0,0 +1,51 @@
<!-- TODO: Replace with actual workflow demo content -->
<script setup lang="ts">
const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
</script>
<template>
<section class="bg-charcoal-800 py-24">
<div class="mx-auto max-w-7xl px-6">
<!-- Section header -->
<div class="text-center">
<h2 class="text-3xl font-bold text-white">See Comfy in Action</h2>
<p class="mx-auto mt-4 max-w-2xl text-smoke-700">
Watch how professionals build AI workflows with unprecedented control
</p>
</div>
<!-- Placeholder video area -->
<div
class="mt-12 flex aspect-video items-center justify-center rounded-2xl border border-white/10 bg-charcoal-600"
>
<div class="flex flex-col items-center gap-4">
<!-- Play button triangle -->
<div
class="flex h-16 w-16 items-center justify-center rounded-full border-2 border-white/20"
aria-hidden="true"
>
<div
class="ml-1 h-0 w-0 border-y-8 border-l-[14px] border-y-transparent border-l-white"
/>
</div>
<p class="text-sm text-smoke-700">Workflow Demo Coming Soon</p>
</div>
</div>
<!-- Feature labels -->
<div class="mt-8 flex flex-wrap items-center justify-center gap-6">
<div
v-for="feature in features"
:key="feature"
class="flex items-center gap-2"
>
<span
class="h-2 w-2 rounded-full bg-brand-yellow"
aria-hidden="true"
/>
<span class="text-sm text-smoke-700">{{ feature }}</span>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
const logos = [
'Harman',
'Tencent',
'Nike',
'HP',
'Autodesk',
'Apple',
'Ubisoft',
'Lucid',
'Amazon',
'Netflix',
'Pixomondo',
'EA'
]
const metrics = [
{ value: '60K+', label: 'Custom Nodes' },
{ value: '106K+', label: 'GitHub Stars' },
{ value: '500K+', label: 'Community Members' }
]
</script>
<template>
<section class="border-y border-white/10 bg-black py-16">
<div class="mx-auto max-w-7xl px-6">
<!-- Heading -->
<p
class="text-center text-xs font-medium uppercase tracking-widest text-smoke-700"
>
Trusted by Industry Leaders
</p>
<!-- Logo row -->
<div
class="mt-10 flex flex-wrap items-center justify-center gap-4 md:gap-6"
>
<span
v-for="company in logos"
:key="company"
class="rounded-full border border-white/10 px-6 py-2 text-sm text-smoke-700"
>
{{ company }}
</span>
</div>
<!-- Metrics row -->
<div
class="mt-14 flex flex-col items-center justify-center gap-10 sm:flex-row sm:gap-12"
>
<div v-for="metric in metrics" :key="metric.label" class="text-center">
<p class="text-3xl font-bold text-white">{{ metric.value }}</p>
<p class="mt-1 text-sm text-smoke-700">{{ metric.label }}</p>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const activeFilter = ref('All')
const industries = ['All', 'VFX', 'Gaming', 'Advertising', 'Photography']
const testimonials = [
{
quote:
'Comfy has transformed our VFX pipeline. The node-based approach gives us unprecedented control over every step of the generation process.',
name: 'Sarah Chen',
title: 'Lead Technical Artist',
company: 'Studio Alpha',
industry: 'VFX'
},
{
quote:
'The level of control over AI generation is unmatched. We can iterate on game assets faster than ever before.',
name: 'Marcus Rivera',
title: 'Creative Director',
company: 'PixelForge',
industry: 'Gaming'
},
{
quote:
'We\u2019ve cut our iteration time by 70%. Comfy workflows let our team produce high-quality creative assets at scale.',
name: 'Yuki Tanaka',
title: 'Head of AI',
company: 'CreativeX',
industry: 'Advertising'
}
]
const filteredTestimonials = computed(() => {
if (activeFilter.value === 'All') return testimonials
return testimonials.filter((t) => t.industry === activeFilter.value)
})
</script>
<template>
<section class="bg-black py-24">
<div class="mx-auto max-w-7xl px-6">
<h2 class="text-center text-3xl font-bold text-white">
What Professionals Say
</h2>
<!-- Industry filter pills -->
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<button
v-for="industry in industries"
:key="industry"
class="cursor-pointer rounded-full px-4 py-1.5 text-sm transition-colors"
:class="
activeFilter === industry
? 'bg-brand-yellow text-black'
: 'border border-white/10 text-smoke-700 hover:border-brand-yellow'
"
@click="activeFilter = industry"
>
{{ industry }}
</button>
</div>
<!-- Testimonial cards -->
<div class="mt-12 grid grid-cols-1 gap-6 md:grid-cols-3">
<article
v-for="testimonial in filteredTestimonials"
:key="testimonial.name"
class="rounded-xl border border-white/10 bg-charcoal-600 p-6"
>
<blockquote class="text-base italic text-white">
&ldquo;{{ testimonial.quote }}&rdquo;
</blockquote>
<p class="mt-4 text-sm font-semibold text-white">
{{ testimonial.name }}
</p>
<p class="text-sm text-smoke-700">
{{ testimonial.title }}, {{ testimonial.company }}
</p>
<span
class="mt-3 inline-block rounded-full bg-white/5 px-2 py-0.5 text-xs text-smoke-700"
>
{{ testimonial.industry }}
</span>
</article>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,74 @@
<!-- TODO: Wire category content swap when final assets arrive -->
<script setup lang="ts">
import { ref } from 'vue'
const categories = [
'VFX & Animation',
'Creative Agencies',
'Gaming',
'eCommerce & Fashion',
'Community & Hobbyists'
]
const activeCategory = ref(0)
</script>
<template>
<section class="bg-charcoal-800 px-6 py-24">
<div class="mx-auto max-w-7xl">
<div class="flex flex-col items-center gap-12 lg:flex-row lg:gap-8">
<!-- Left placeholder image (desktop only) -->
<div class="hidden flex-1 lg:block">
<div
class="aspect-[2/3] rounded-full border border-white/10 bg-charcoal-600"
/>
</div>
<!-- Center content -->
<div class="flex flex-col items-center text-center lg:flex-[2]">
<h2 class="text-3xl font-bold text-white">
Built for Every Creative Industry
</h2>
<nav
class="mt-10 flex flex-col items-center gap-4"
aria-label="Industry categories"
>
<button
v-for="(category, index) in categories"
:key="category"
class="transition-colors"
:class="
index === activeCategory
? 'text-2xl text-white'
: 'text-xl text-ash-500 hover:text-white/70'
"
@click="activeCategory = index"
>
{{ category }}
</button>
</nav>
<p class="mt-10 max-w-lg text-smoke-700">
Powered by 60,000+ nodes, thousands of workflows, and a community
that builds faster than any one company could.
</p>
<a
href="/workflows"
class="mt-8 rounded-full border border-brand-yellow px-8 py-3 text-sm font-semibold text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black"
>
EXPLORE WORKFLOWS
</a>
</div>
<!-- Right placeholder image (desktop only) -->
<div class="hidden flex-1 lg:block">
<div
class="aspect-[2/3] rounded-3xl border border-white/10 bg-charcoal-600"
/>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
const pillars = [
{
icon: '⚡',
title: 'Build',
description:
'Design complex AI workflows visually with our node-based editor'
},
{
icon: '🎨',
title: 'Customize',
description: 'Fine-tune every parameter across any model architecture'
},
{
icon: '🔧',
title: 'Refine',
description:
'Iterate on outputs with precision controls and real-time preview'
},
{
icon: '⚙️',
title: 'Automate',
description:
'Scale your workflows with batch processing and API integration'
},
{
icon: '🚀',
title: 'Run',
description: 'Deploy locally or in the cloud with identical results'
}
]
</script>
<template>
<section class="bg-black px-6 py-24">
<div class="mx-auto max-w-7xl">
<header class="mb-16 text-center">
<h2 class="text-3xl font-bold text-white md:text-4xl">
The Building Blocks of AI Production
</h2>
<p class="mt-4 text-smoke-700">
Five powerful capabilities that give you complete control
</p>
</header>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-5">
<article
v-for="pillar in pillars"
:key="pillar.title"
class="rounded-xl border border-white/10 bg-charcoal-600 p-6 transition-colors hover:border-brand-yellow"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-full bg-brand-yellow text-xl"
>
{{ pillar.icon }}
</div>
<h3 class="mt-4 text-lg font-semibold text-white">
{{ pillar.title }}
</h3>
<p class="mt-2 text-sm text-smoke-700">
{{ pillar.description }}
</p>
</article>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,34 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import SiteNav from '../components/SiteNav.vue'
import HeroSection from '../components/HeroSection.vue'
import SocialProofBar from '../components/SocialProofBar.vue'
import ProductShowcase from '../components/ProductShowcase.vue'
import ValuePillars from '../components/ValuePillars.vue'
import UseCaseSection from '../components/UseCaseSection.vue'
import CaseStudySpotlight from '../components/CaseStudySpotlight.vue'
import TestimonialsSection from '../components/TestimonialsSection.vue'
import GetStartedSection from '../components/GetStartedSection.vue'
import CTASection from '../components/CTASection.vue'
import ManifestoSection from '../components/ManifestoSection.vue'
import AcademySection from '../components/AcademySection.vue'
import SiteFooter from '../components/SiteFooter.vue'
---
<BaseLayout title="Comfy — Professional Control of Visual AI">
<SiteNav client:load />
<main>
<HeroSection />
<SocialProofBar />
<ProductShowcase />
<ValuePillars />
<UseCaseSection client:visible />
<CaseStudySpotlight />
<TestimonialsSection client:visible />
<GetStartedSection />
<CTASection />
<ManifestoSection />
<AcademySection />
</main>
<SiteFooter />
</BaseLayout>

View File

@@ -0,0 +1,34 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import SiteNav from '../../components/SiteNav.vue'
import HeroSection from '../../components/HeroSection.vue'
import SocialProofBar from '../../components/SocialProofBar.vue'
import ProductShowcase from '../../components/ProductShowcase.vue'
import ValuePillars from '../../components/ValuePillars.vue'
import UseCaseSection from '../../components/UseCaseSection.vue'
import CaseStudySpotlight from '../../components/CaseStudySpotlight.vue'
import TestimonialsSection from '../../components/TestimonialsSection.vue'
import GetStartedSection from '../../components/GetStartedSection.vue'
import CTASection from '../../components/CTASection.vue'
import ManifestoSection from '../../components/ManifestoSection.vue'
import AcademySection from '../../components/AcademySection.vue'
import SiteFooter from '../../components/SiteFooter.vue'
---
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
<SiteNav client:load />
<main>
<HeroSection />
<SocialProofBar />
<ProductShowcase />
<ValuePillars />
<UseCaseSection client:visible />
<CaseStudySpotlight />
<TestimonialsSection client:visible />
<GetStartedSection />
<CTASection />
<ManifestoSection />
<AcademySection />
</main>
<SiteFooter />
</BaseLayout>

View File

@@ -12,12 +12,13 @@ browser_tests/
│ ├── ComfyMouse.ts - Mouse interaction helper
│ ├── VueNodeHelpers.ts - Vue Nodes 2.0 helpers
│ ├── selectors.ts - Centralized TestIds
│ ├── components/ - Page object components
│ ├── data/ - Static test data (mock API responses, workflow JSONs, node definitions)
│ ├── components/ - Page object components (locators, user interactions)
│ │ ├── ContextMenu.ts
│ │ ├── SettingDialog.ts
│ │ ├── SidebarTab.ts
│ │ └── Topbar.ts
│ ├── helpers/ - Focused helper classes
│ ├── helpers/ - Focused helper classes (domain-specific actions)
│ │ ├── CanvasHelper.ts
│ │ ├── CommandHelper.ts
│ │ ├── KeyboardHelper.ts
@@ -25,11 +26,18 @@ browser_tests/
│ │ ├── SettingsHelper.ts
│ │ ├── WorkflowHelper.ts
│ │ └── ...
│ └── utils/ - Utility functions
│ └── utils/ - Pure utility functions (no page dependency)
├── helpers/ - Test-specific utilities
└── tests/ - Test files (*.spec.ts)
```
### Architectural Separation
- **`fixtures/data/`** — Static test data only. Mock API responses, workflow JSONs, node definitions. No code, no imports from Playwright.
- **`fixtures/components/`** — Page object components. Encapsulate locators and user interactions for a specific UI area.
- **`fixtures/helpers/`** — Focused helper classes. Domain-specific actions that coordinate multiple page objects (e.g. canvas operations, workflow loading).
- **`fixtures/utils/`** — Pure utility functions. No `Page` dependency; stateless helpers that can be used anywhere.
## Polling Assertions
Prefer `expect.poll()` over `expect(async () => { ... }).toPass()` when the block contains a single async call with a single assertion. `expect.poll()` is more readable and gives better error messages (shows actual vs expected on failure).

View File

@@ -0,0 +1,30 @@
{
"1": {
"class_type": "KSampler",
"inputs": {
"seed": 42,
"steps": 20,
"cfg": 8.0,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0
},
"_meta": { "title": "KSampler" }
},
"2": {
"class_type": "NonExistentCustomNode_XYZ_12345",
"inputs": {
"input1": "test"
},
"_meta": { "title": "Missing Node" }
},
"3": {
"class_type": "EmptyLatentImage",
"inputs": {
"width": 512,
"height": 512,
"batch_size": 1
},
"_meta": { "title": "Empty Latent Image" }
}
}

View File

@@ -1,13 +1,10 @@
import type {
APIRequestContext,
ExpectMatcherState,
Locator,
Page
} from '@playwright/test'
import { test as base, expect } from '@playwright/test'
import type { APIRequestContext, Locator, Page } from '@playwright/test'
import { test as base } from '@playwright/test'
import { config as dotenvConfig } from 'dotenv'
import { TestIds } from './selectors'
import { sleep } from './utils/timing'
import { comfyExpect } from './utils/customMatchers'
import { NodeBadgeMode } from '../../src/types/nodeSource'
import { ComfyActionbar } from '../helpers/actionbar'
import { ComfyTemplates } from '../helpers/templates'
@@ -18,6 +15,7 @@ import { ComfyNodeSearchBoxV2 } from './components/ComfyNodeSearchBoxV2'
import { ContextMenu } from './components/ContextMenu'
import { SettingDialog } from './components/SettingDialog'
import { BottomPanel } from './components/BottomPanel'
import { QueuePanel } from './components/QueuePanel'
import {
AssetsSidebarTab,
NodeLibrarySidebarTab,
@@ -39,7 +37,7 @@ import { AppModeHelper } from './helpers/AppModeHelper'
import { SubgraphHelper } from './helpers/SubgraphHelper'
import { ToastHelper } from './helpers/ToastHelper'
import { WorkflowHelper } from './helpers/WorkflowHelper'
import type { NodeReference } from './utils/litegraphUtils'
import { assetPath } from './utils/paths'
import type { WorkspaceStore } from '../types/globals'
dotenvConfig()
@@ -124,7 +122,7 @@ type KeysOfType<T, Match> = {
}[keyof T]
class ConfirmDialog {
private readonly root: Locator
public readonly root: Locator
public readonly delete: Locator
public readonly overwrite: Locator
public readonly reject: Locator
@@ -199,6 +197,7 @@ export class ComfyPage {
public readonly featureFlags: FeatureFlagHelper
public readonly command: CommandHelper
public readonly bottomPanel: BottomPanel
public readonly queuePanel: QueuePanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly queue: QueueHelper
@@ -242,10 +241,11 @@ export class ComfyPage {
this.workflow = new WorkflowHelper(this)
this.contextMenu = new ContextMenu(page)
this.toast = new ToastHelper(page)
this.dragDrop = new DragDropHelper(page, this.assetPath.bind(this))
this.dragDrop = new DragDropHelper(page)
this.featureFlags = new FeatureFlagHelper(page)
this.command = new CommandHelper(page)
this.bottomPanel = new BottomPanel(page)
this.queuePanel = new QueuePanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.queue = new QueueHelper(page)
@@ -343,8 +343,9 @@ export class ComfyPage {
await this.nextFrame()
}
/** @deprecated Use standalone `assetPath` from `browser_tests/fixtures/utils/assetPath` directly. */
public assetPath(fileName: string) {
return `./browser_tests/assets/${fileName}`
return assetPath(fileName)
}
async goto() {
@@ -358,7 +359,7 @@ export class ComfyPage {
}
async delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
return sleep(ms)
}
/**
@@ -476,49 +477,4 @@ export const comfyPageFixture = base.extend<{
}
})
const makeMatcher = function <T>(
getValue: (node: NodeReference) => Promise<T> | T,
type: string
) {
return async function (
this: ExpectMatcherState,
node: NodeReference,
options?: { timeout?: number; intervals?: number[] }
) {
const value = await getValue(node)
let assertion = expect(
value,
'Node is ' + (this.isNot ? '' : 'not ') + type
)
if (this.isNot) {
assertion = assertion.not
}
await expect(async () => {
assertion.toBeTruthy()
}).toPass({ timeout: 250, ...options })
return {
pass: !this.isNot,
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
}
}
}
export const comfyExpect = expect.extend({
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'),
async toHaveFocus(locator: Locator, options = { timeout: 256 }) {
const isFocused = await locator.evaluate(
(el) => el === document.activeElement
)
await expect(async () => {
expect(isFocused).toBe(!this.isNot)
}).toPass(options)
return {
pass: isFocused,
message: () => `Expected element to ${isFocused ? 'not ' : ''}be focused.`
}
}
})
export { comfyExpect }

View File

@@ -1,3 +1,4 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
export class ContextMenu {
@@ -33,6 +34,20 @@ export class ContextMenu {
return primeVueVisible || litegraphVisible
}
async assertHasItems(items: string[]): Promise<void> {
for (const item of items) {
await expect
.soft(this.page.getByRole('menuitem', { name: item }))
.toBeVisible()
}
}
async openFor(locator: Locator): Promise<this> {
await locator.click({ button: 'right' })
await expect.poll(() => this.isVisible()).toBe(true)
return this
}
async waitForHidden(): Promise<void> {
const waitIfExists = async (locator: Locator, menuName: string) => {
const count = await locator.count()

View File

@@ -0,0 +1,24 @@
import type { Locator, Page } from '@playwright/test'
import { comfyExpect as expect } from '../ComfyPage'
import { TestIds } from '../selectors'
export class QueuePanel {
readonly overlayToggle: Locator
readonly moreOptionsButton: Locator
constructor(readonly page: Page) {
this.overlayToggle = page.getByTestId(TestIds.queue.overlayToggle)
this.moreOptionsButton = page.getByLabel(/More options/i).first()
}
async openClearHistoryDialog() {
await this.moreOptionsButton.click()
const clearHistoryAction = this.page.getByTestId(
TestIds.queue.clearHistoryAction
)
await expect(clearHistoryAction).toBeVisible()
await clearHistoryAction.click()
}
}

View File

@@ -1,4 +1,5 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { WorkspaceStore } from '../../types/globals'
import { TestIds } from '../selectors'
@@ -174,6 +175,8 @@ export class AssetsSidebarTab extends SidebarTab {
super(page, 'assets')
}
// --- Tab navigation ---
get generatedTab() {
return this.page.getByRole('tab', { name: 'Generated' })
}
@@ -182,6 +185,8 @@ export class AssetsSidebarTab extends SidebarTab {
return this.page.getByRole('tab', { name: 'Imported' })
}
// --- Empty state ---
get emptyStateMessage() {
return this.page.getByText(
'Upload files or generate content to see them here'
@@ -192,8 +197,169 @@ export class AssetsSidebarTab extends SidebarTab {
return this.page.getByText(title)
}
// --- Search & filter ---
get searchInput() {
return this.page.getByPlaceholder('Search Assets...')
}
get settingsButton() {
return this.page.getByRole('button', { name: 'View settings' })
}
// --- View mode ---
get listViewOption() {
return this.page.getByText('List view')
}
get gridViewOption() {
return this.page.getByText('Grid view')
}
// --- Sort options (cloud-only, shown inside settings popover) ---
get sortNewestFirst() {
return this.page.getByText('Newest first')
}
get sortOldestFirst() {
return this.page.getByText('Oldest first')
}
// --- Asset cards ---
get assetCards() {
return this.page.locator('[role="button"][data-selected]')
}
getAssetCardByName(name: string) {
return this.page.locator('[role="button"][data-selected]', {
hasText: name
})
}
get selectedCards() {
return this.page.locator('[data-selected="true"]')
}
// --- List view items ---
get listViewItems() {
return this.page.locator(
'.sidebar-content-container [role="button"][tabindex="0"]'
)
}
// --- Selection footer ---
get selectionFooter() {
return this.page
.locator('.sidebar-content-container')
.locator('..')
.locator('[class*="h-18"]')
}
get selectionCountButton() {
return this.page.getByText(/Assets Selected: \d+/)
}
get deselectAllButton() {
return this.page.getByText('Deselect all')
}
get deleteSelectedButton() {
return this.page
.getByTestId('assets-delete-selected')
.or(this.page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
.first()
}
get downloadSelectedButton() {
return this.page
.getByTestId('assets-download-selected')
.or(this.page.locator('button:has(.icon-\\[lucide--download\\])').last())
.first()
}
// --- Context menu ---
contextMenuItem(label: string) {
return this.page.locator('.p-contextmenu').getByText(label)
}
// --- Folder view ---
get backToAssetsButton() {
return this.page.getByText('Back to all assets')
}
// --- Loading ---
get skeletonLoaders() {
return this.page.locator('.sidebar-content-container .animate-pulse')
}
// --- Helpers ---
override async open() {
// Remove any toast notifications that may overlay the sidebar button
await this.dismissToasts()
await super.open()
await this.generatedTab.waitFor({ state: 'visible' })
}
/** Dismiss all visible toast notifications by clicking their close buttons. */
async dismissToasts() {
const closeButtons = this.page.locator('.p-toast-close-button')
for (const btn of await closeButtons.all()) {
await btn.click({ force: true }).catch(() => {})
}
// Wait for all toast elements to fully animate out and detach from DOM
await expect(this.page.locator('.p-toast-message'))
.toHaveCount(0, { timeout: 5000 })
.catch(() => {})
}
async switchToImported() {
await this.dismissToasts()
await this.importedTab.click()
await expect(this.importedTab).toHaveAttribute('aria-selected', 'true', {
timeout: 3000
})
}
async switchToGenerated() {
await this.dismissToasts()
await this.generatedTab.click()
await expect(this.generatedTab).toHaveAttribute('aria-selected', 'true', {
timeout: 3000
})
}
async openSettingsMenu() {
await this.dismissToasts()
await this.settingsButton.click()
// Wait for popover content to render
await this.listViewOption
.or(this.gridViewOption)
.first()
.waitFor({ state: 'visible', timeout: 3000 })
}
async rightClickAsset(name: string) {
const card = this.getAssetCardByName(name)
await card.click({ button: 'right' })
await this.page
.locator('.p-contextmenu')
.waitFor({ state: 'visible', timeout: 3000 })
}
async waitForAssets(count?: number) {
if (count !== undefined) {
await expect(this.assetCards).toHaveCount(count, { timeout: 5000 })
} else {
await this.assetCards.first().waitFor({ state: 'visible', timeout: 5000 })
}
}
}

View File

@@ -0,0 +1,77 @@
import type { Locator, Page } from '@playwright/test'
import { BaseDialog } from './BaseDialog'
export class SignInDialog extends BaseDialog {
readonly emailInput: Locator
readonly passwordInput: Locator
readonly signInButton: Locator
readonly forgotPasswordLink: Locator
readonly apiKeyButton: Locator
readonly termsLink: Locator
readonly privacyLink: Locator
constructor(page: Page) {
super(page)
this.emailInput = this.root.locator('#comfy-org-sign-in-email')
this.passwordInput = this.root.locator('#comfy-org-sign-in-password')
this.signInButton = this.root.getByRole('button', { name: 'Sign in' })
this.forgotPasswordLink = this.root.getByText('Forgot password?')
this.apiKeyButton = this.root.getByRole('button', {
name: 'Comfy API Key'
})
this.termsLink = this.root.getByRole('link', { name: 'Terms of Use' })
this.privacyLink = this.root.getByRole('link', { name: 'Privacy Policy' })
}
async open() {
await this.page.evaluate(() => {
void window.app!.extensionManager.dialog.showSignInDialog()
})
await this.waitForVisible()
}
get heading() {
return this.root.getByRole('heading').first()
}
get signUpLink() {
return this.root.getByText('Sign up', { exact: true })
}
get signInLink() {
return this.root.getByText('Sign in', { exact: true })
}
get signUpEmailInput() {
return this.root.locator('#comfy-org-sign-up-email')
}
get signUpPasswordInput() {
return this.root.locator('#comfy-org-sign-up-password')
}
get signUpConfirmPasswordInput() {
return this.root.locator('#comfy-org-sign-up-confirm-password')
}
get signUpButton() {
return this.root.getByRole('button', { name: 'Sign up', exact: true })
}
get apiKeyHeading() {
return this.root.getByRole('heading', { name: 'API Key' })
}
get apiKeyInput() {
return this.root.locator('#comfy-org-api-key')
}
get backButton() {
return this.root.getByRole('button', { name: 'Back' })
}
get dividerText() {
return this.root.getByText('Or continue with')
}
}

View File

@@ -0,0 +1,41 @@
# Mock Data Fixtures
Deterministic mock data for browser (Playwright) tests. Each fixture
exports typed objects that conform to generated types from
`packages/ingest-types` or Zod schemas in `src/schemas/`.
## Usage with `page.route()`
> **Note:** `comfyPageFixture` navigates to the app during `setup()`,
> before the test body runs. Routes must be registered before navigation
> to intercept initial page-load requests. Set up routes in a custom
> fixture or `test.beforeEach` that runs before `comfyPage.setup()`.
```ts
import { createMockNodeDefinitions } from '../fixtures/data/nodeDefinitions'
import { mockSystemStats } from '../fixtures/data/systemStats'
// Extend the base set with test-specific nodes
const nodeDefs = createMockNodeDefinitions({
MyCustomNode: {
/* ... */
}
})
await page.route('**/api/object_info', (route) =>
route.fulfill({ json: nodeDefs })
)
await page.route('**/api/system_stats', (route) =>
route.fulfill({ json: mockSystemStats })
)
```
## Adding new fixtures
1. Locate the generated type in `packages/ingest-types` or Zod schema
in `src/schemas/` for the endpoint you need.
2. Create a new `.ts` file here that imports and satisfies the
corresponding TypeScript type.
3. Keep values realistic but stable — avoid dates, random IDs, or
values that would cause test flakiness.

View File

@@ -0,0 +1,155 @@
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
/**
* Base node definitions covering the default workflow.
* Use {@link createMockNodeDefinitions} to extend with per-test overrides.
*/
const baseNodeDefinitions: Record<string, ComfyNodeDef> = {
KSampler: {
input: {
required: {
model: ['MODEL', {}],
seed: [
'INT',
{
default: 0,
min: 0,
max: 0xfffffffffffff,
control_after_generate: true
}
],
steps: ['INT', { default: 20, min: 1, max: 10000 }],
cfg: ['FLOAT', { default: 8.0, min: 0.0, max: 100.0, step: 0.1 }],
sampler_name: [['euler', 'euler_ancestral', 'heun', 'dpm_2'], {}],
scheduler: [['normal', 'karras', 'exponential', 'simple'], {}],
positive: ['CONDITIONING', {}],
negative: ['CONDITIONING', {}],
latent_image: ['LATENT', {}]
},
optional: {
denoise: ['FLOAT', { default: 1.0, min: 0.0, max: 1.0, step: 0.01 }]
}
},
output: ['LATENT'],
output_is_list: [false],
output_name: ['LATENT'],
name: 'KSampler',
display_name: 'KSampler',
description: 'Samples latents using the provided model and conditioning.',
category: 'sampling',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
CheckpointLoaderSimple: {
input: {
required: {
ckpt_name: [
['v1-5-pruned.safetensors', 'sd_xl_base_1.0.safetensors'],
{}
]
}
},
output: ['MODEL', 'CLIP', 'VAE'],
output_is_list: [false, false, false],
output_name: ['MODEL', 'CLIP', 'VAE'],
name: 'CheckpointLoaderSimple',
display_name: 'Load Checkpoint',
description: 'Loads a diffusion model checkpoint.',
category: 'loaders',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
CLIPTextEncode: {
input: {
required: {
text: ['STRING', { multiline: true, dynamicPrompts: true }],
clip: ['CLIP', {}]
}
},
output: ['CONDITIONING'],
output_is_list: [false],
output_name: ['CONDITIONING'],
name: 'CLIPTextEncode',
display_name: 'CLIP Text Encode (Prompt)',
description: 'Encodes a text prompt using a CLIP model.',
category: 'conditioning',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
EmptyLatentImage: {
input: {
required: {
width: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
height: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
batch_size: ['INT', { default: 1, min: 1, max: 4096 }]
}
},
output: ['LATENT'],
output_is_list: [false],
output_name: ['LATENT'],
name: 'EmptyLatentImage',
display_name: 'Empty Latent Image',
description: 'Creates an empty latent image of the specified dimensions.',
category: 'latent',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
VAEDecode: {
input: {
required: {
samples: ['LATENT', {}],
vae: ['VAE', {}]
}
},
output: ['IMAGE'],
output_is_list: [false],
output_name: ['IMAGE'],
name: 'VAEDecode',
display_name: 'VAE Decode',
description: 'Decodes latent images back into pixel space.',
category: 'latent',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
SaveImage: {
input: {
required: {
images: ['IMAGE', {}],
filename_prefix: ['STRING', { default: 'ComfyUI' }]
}
},
output: [],
output_is_list: [],
output_name: [],
name: 'SaveImage',
display_name: 'Save Image',
description: 'Saves images to the output directory.',
category: 'image',
output_node: true,
python_module: 'nodes',
deprecated: false,
experimental: false
}
}
export function createMockNodeDefinitions(
overrides?: Record<string, ComfyNodeDef>
): Record<string, ComfyNodeDef> {
const base = structuredClone(baseNodeDefinitions)
return overrides ? { ...base, ...overrides } : base
}

View File

@@ -0,0 +1,22 @@
import type { SystemStatsResponse } from '@comfyorg/ingest-types'
export const mockSystemStats: SystemStatsResponse = {
system: {
os: 'posix',
python_version: '3.11.9 (main, Apr 2 2024, 08:25:04) [GCC 13.2.0]',
embedded_python: false,
comfyui_version: '0.3.10',
pytorch_version: '2.4.0+cu124',
argv: ['main.py', '--listen', '0.0.0.0'],
ram_total: 67108864000,
ram_free: 52428800000
},
devices: [
{
name: 'NVIDIA GeForce RTX 4090',
type: 'cuda',
vram_total: 25769803776,
vram_free: 23622320128
}
]
}

View File

@@ -5,6 +5,63 @@ import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/j
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
/** Factory to create a mock completed job with preview output. */
export function createMockJob(
overrides: Partial<RawJobListItem> & { id: string }
): RawJobListItem {
const now = Date.now() / 1000
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5,
preview_output: {
filename: `output_${overrides.id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
priority: 0,
...overrides
}
}
/** Create multiple mock jobs with sequential IDs and staggered timestamps. */
export function createMockJobs(
count: number,
baseOverrides?: Partial<RawJobListItem>
): RawJobListItem[] {
const now = Date.now() / 1000
return Array.from({ length: count }, (_, i) =>
createMockJob({
id: `job-${String(i + 1).padStart(3, '0')}`,
create_time: now - i * 60,
execution_start_time: now - i * 60,
execution_end_time: now - i * 60 + 5 + i,
preview_output: {
filename: `image_${String(i + 1).padStart(3, '0')}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
...baseOverrides
})
)
}
/** Create mock imported file names with various media types. */
export function createMockImportedFiles(count: number): string[] {
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
return Array.from(
{ length: count },
(_, i) =>
`imported_${String(i + 1).padStart(3, '0')}.${extensions[i % extensions.length]}`
)
}
function parseLimit(url: URL, total: number): number {
const value = Number(url.searchParams.get('limit'))
if (!Number.isInteger(value) || value <= 0) {

View File

@@ -4,12 +4,10 @@ import type { Page } from '@playwright/test'
import type { Position } from '../types'
import { getMimeType } from './mimeTypeUtil'
import { assetPath } from '../utils/paths'
export class DragDropHelper {
constructor(
private readonly page: Page,
private readonly assetPath: (fileName: string) => string
) {}
constructor(private readonly page: Page) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => {
@@ -49,7 +47,7 @@ export class DragDropHelper {
} = { dropPosition, preserveNativePropagation }
if (fileName) {
const filePath = this.assetPath(fileName)
const filePath = assetPath(fileName)
const buffer = readFileSync(filePath)
evaluateParams.fileName = fileName

View File

@@ -30,6 +30,8 @@ export interface PerfMeasurement {
eventListeners: number
totalBlockingTimeMs: number
frameDurationMs: number
p95FrameDurationMs: number
allFrameDurationsMs: number[]
}
export class PerformanceHelper {
@@ -101,13 +103,13 @@ export class PerformanceHelper {
}
/**
* Measure average frame duration via rAF timing over a sample window.
* Returns average ms per frame (lower = better, 16.67 = 60fps).
* Measure individual frame durations via rAF timing over a sample window.
* Returns all per-frame durations so callers can compute avg, p95, etc.
*/
private async measureFrameDuration(sampleFrames = 10): Promise<number> {
private async measureFrameDurations(sampleFrames = 30): Promise<number[]> {
return this.page.evaluate((frames) => {
return new Promise<number>((resolve) => {
const timeout = setTimeout(() => resolve(0), 5000)
return new Promise<number[]>((resolve) => {
const timeout = setTimeout(() => resolve([]), 5000)
const timestamps: number[] = []
let count = 0
function tick(ts: number) {
@@ -118,11 +120,14 @@ export class PerformanceHelper {
} else {
clearTimeout(timeout)
if (timestamps.length < 2) {
resolve(0)
resolve([])
return
}
const total = timestamps[timestamps.length - 1] - timestamps[0]
resolve(total / (timestamps.length - 1))
const durations: number[] = []
for (let i = 1; i < timestamps.length; i++) {
durations.push(timestamps[i] - timestamps[i - 1])
}
resolve(durations)
}
}
requestAnimationFrame(tick)
@@ -177,11 +182,21 @@ export class PerformanceHelper {
return after[key] - before[key]
}
const [totalBlockingTimeMs, frameDurationMs] = await Promise.all([
const [totalBlockingTimeMs, allFrameDurationsMs] = await Promise.all([
this.collectTBT(),
this.measureFrameDuration()
this.measureFrameDurations()
])
const frameDurationMs =
allFrameDurationsMs.length > 0
? allFrameDurationsMs.reduce((a, b) => a + b, 0) /
allFrameDurationsMs.length
: 0
const sorted = [...allFrameDurationsMs].sort((a, b) => a - b)
const p95FrameDurationMs =
sorted.length > 0 ? sorted[Math.ceil(sorted.length * 0.95) - 1] : 0
return {
name,
durationMs: delta('Timestamp') * 1000,
@@ -197,7 +212,9 @@ export class PerformanceHelper {
scriptDurationMs: delta('ScriptDuration') * 1000,
eventListeners: delta('JSEventListeners'),
totalBlockingTimeMs,
frameDurationMs
frameDurationMs,
p95FrameDurationMs,
allFrameDurationsMs
}
}
}

View File

@@ -7,6 +7,7 @@ import type {
} from '../../../src/platform/workflow/validation/schemas/workflowSchema'
import type { WorkspaceStore } from '../../types/globals'
import type { ComfyPage } from '../ComfyPage'
import { assetPath } from '../utils/paths'
type FolderStructure = {
[key: string]: FolderStructure | string
@@ -20,7 +21,7 @@ export class WorkflowHelper {
for (const [key, value] of Object.entries(structure)) {
if (typeof value === 'string') {
const filePath = this.comfyPage.assetPath(value)
const filePath = assetPath(value)
result[key] = readFileSync(filePath, 'utf-8')
} else {
result[key] = this.convertLeafToContent(value)
@@ -59,7 +60,7 @@ export class WorkflowHelper {
async loadWorkflow(workflowName: string) {
await this.comfyPage.workflowUploadInput.setInputFiles(
this.comfyPage.assetPath(`${workflowName}.json`)
assetPath(`${workflowName}.json`)
)
await this.comfyPage.nextFrame()
}
@@ -146,6 +147,16 @@ export class WorkflowHelper {
})
}
async waitForWorkflowIdle(timeout = 5000): Promise<void> {
await this.comfyPage.page.waitForFunction(
() =>
!(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
?.isBusy,
undefined,
{ timeout }
)
}
async getExportedWorkflow(options: { api: true }): Promise<ComfyApiWorkflow>
async getExportedWorkflow(options?: {
api?: false

View File

@@ -20,6 +20,7 @@ export const TestIds = {
main: 'graph-canvas',
contextMenu: 'canvas-context-menu',
toggleMinimapButton: 'toggle-minimap-button',
closeMinimapButton: 'close-minimap-button',
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
zoomControlsButton: 'zoom-controls-button',
zoomInAction: 'zoom-in-action',
@@ -51,7 +52,8 @@ export const TestIds = {
topbar: {
queueButton: 'queue-button',
queueModeMenuTrigger: 'queue-mode-menu-trigger',
saveButton: 'save-workflow-button'
saveButton: 'save-workflow-button',
subscribeButton: 'topbar-subscribe-button'
},
nodeLibrary: {
bookmarksSection: 'node-library-bookmarks-section'
@@ -96,6 +98,10 @@ export const TestIds = {
user: {
currentUserIndicator: 'current-user-indicator'
},
queue: {
overlayToggle: 'queue-overlay-toggle',
clearHistoryAction: 'clear-history-action'
},
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
@@ -124,4 +130,5 @@ export type TestIdValue =
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]

View File

@@ -0,0 +1,49 @@
import type { ExpectMatcherState, Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import type { NodeReference } from './litegraphUtils'
function makeMatcher<T>(
getValue: (node: NodeReference) => Promise<T> | T,
type: string
) {
return async function (
this: ExpectMatcherState,
node: NodeReference,
options?: { timeout?: number; intervals?: number[] }
) {
await expect(async () => {
const value = await getValue(node)
const assertion = this.isNot
? expect(value, 'Node is ' + type).not
: expect(value, 'Node is not ' + type)
assertion.toBeTruthy()
}).toPass({ timeout: 250, ...options })
return {
pass: !this.isNot,
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
}
}
}
export const comfyExpect = expect.extend({
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'),
async toHaveFocus(locator: Locator, options = { timeout: 256 }) {
await expect
.poll(
() => locator.evaluate((el) => el === document.activeElement),
options
)
.toBe(!this.isNot)
const isFocused = await locator.evaluate(
(el) => el === document.activeElement
)
return {
pass: isFocused,
message: () => `Expected element to ${isFocused ? 'not ' : ''}be focused.`
}
}
})

View File

@@ -0,0 +1,3 @@
export function assetPath(fileName: string): string {
return `./browser_tests/assets/${fileName}`
}

View File

@@ -0,0 +1,3 @@
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@@ -47,7 +47,8 @@ export function logMeasurement(
export function recordMeasurement(m: PerfMeasurement) {
mkdirSync(TEMP_DIR, { recursive: true })
const filename = `${m.name}-${Date.now()}.json`
writeFileSync(join(TEMP_DIR, filename), JSON.stringify(m))
const { allFrameDurationsMs: _, ...serializable } = m
writeFileSync(join(TEMP_DIR, filename), JSON.stringify(serializable))
}
export function writePerfReport(

View File

@@ -0,0 +1,29 @@
import { expect, test } from '@playwright/test'
/**
* Cloud distribution E2E tests.
*
* These tests run against the cloud build (DISTRIBUTION=cloud) and verify
* that cloud-specific behavior is present. In CI, no Firebase auth is
* configured, so the auth guard redirects to /cloud/login. The tests
* verify the cloud build loaded correctly by checking for cloud-only
* routes and elements.
*/
test.describe('Cloud distribution UI', { tag: '@cloud' }, () => {
test('cloud build redirects unauthenticated users to login', async ({
page
}) => {
await page.goto('http://localhost:8188')
// Cloud build has an auth guard that redirects to /cloud/login.
// This route only exists in the cloud distribution — it's tree-shaken
// in the OSS build. Its presence confirms the cloud build is active.
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
})
test('cloud login page renders sign-in options', async ({ page }) => {
await page.goto('http://localhost:8188')
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
// Verify cloud-specific login UI is rendered
await expect(page.getByRole('button', { name: /google/i })).toBeVisible()
})
})

View File

@@ -0,0 +1,126 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../../fixtures/ComfyPage'
test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.queuePanel.overlayToggle.click()
})
test('Dialog opens from queue panel history actions menu', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
})
test('Dialog shows confirmation message with title, description, and assets note', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
await expect(
dialog.getByText('Clear your job queue history?')
).toBeVisible()
await expect(
dialog.getByText(
'All the finished or failed jobs below will be removed from this Job queue panel.'
)
).toBeVisible()
await expect(
dialog.getByText(
'Assets generated by these jobs won\u2019t be deleted and can always be viewed from the assets panel.'
)
).toBeVisible()
})
test('Cancel button closes dialog without clearing history', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
let clearCalled = false
await comfyPage.page.route('**/api/history', (route) => {
if (route.request().method() === 'POST') {
clearCalled = true
}
return route.continue()
})
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).not.toBeVisible()
expect(clearCalled).toBe(false)
await comfyPage.page.unroute('**/api/history')
})
test('Close (X) button closes dialog without clearing history', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
let clearCalled = false
await comfyPage.page.route('**/api/history', (route) => {
if (route.request().method() === 'POST') {
clearCalled = true
}
return route.continue()
})
await dialog.getByLabel('Close').click()
await expect(dialog).not.toBeVisible()
expect(clearCalled).toBe(false)
await comfyPage.page.unroute('**/api/history')
})
test('Confirm clears queue history and closes dialog', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
const clearPromise = comfyPage.page.waitForRequest(
(req) => req.url().includes('/api/history') && req.method() === 'POST'
)
await dialog.getByRole('button', { name: 'Clear' }).click()
const request = await clearPromise
expect(request.postDataJSON()).toEqual({ clear: true })
await expect(dialog).not.toBeVisible()
})
test('Dialog state resets after close and reopen', async ({ comfyPage }) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).not.toBeVisible()
await comfyPage.queuePanel.openClearHistoryDialog()
await expect(dialog).toBeVisible()
const clearButton = dialog.getByRole('button', { name: 'Clear' })
await expect(clearButton).toBeVisible()
await expect(clearButton).toBeEnabled()
})
})

View File

@@ -0,0 +1,94 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { SignInDialog } from '../../fixtures/components/SignInDialog'
test.describe('Sign In dialog', { tag: '@ui' }, () => {
let dialog: SignInDialog
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
dialog = new SignInDialog(comfyPage.page)
await dialog.open()
})
test('Should open and show the sign-in form by default', async () => {
await expect(
dialog.root.getByRole('heading', { name: 'Log in to your account' })
).toBeVisible()
await expect(dialog.emailInput).toBeVisible()
await expect(dialog.passwordInput).toBeVisible()
await expect(dialog.signInButton).toBeVisible()
})
test('Should toggle from sign-in to sign-up form', async () => {
await dialog.signUpLink.click()
await expect(
dialog.root.getByRole('heading', { name: 'Create an account' })
).toBeVisible()
await expect(dialog.signUpEmailInput).toBeVisible()
await expect(dialog.signUpPasswordInput).toBeVisible()
await expect(dialog.signUpConfirmPasswordInput).toBeVisible()
await expect(dialog.signUpButton).toBeVisible()
})
test('Should toggle back from sign-up to sign-in form', async () => {
await dialog.signUpLink.click()
await expect(
dialog.root.getByRole('heading', { name: 'Create an account' })
).toBeVisible()
await dialog.signInLink.click()
await expect(
dialog.root.getByRole('heading', { name: 'Log in to your account' })
).toBeVisible()
await expect(dialog.emailInput).toBeVisible()
await expect(dialog.passwordInput).toBeVisible()
})
test('Should navigate to the API Key form and back', async () => {
await dialog.apiKeyButton.click()
await expect(dialog.apiKeyHeading).toBeVisible()
await expect(dialog.apiKeyInput).toBeVisible()
await dialog.backButton.click()
await expect(
dialog.root.getByRole('heading', { name: 'Log in to your account' })
).toBeVisible()
})
test('Should display Terms of Service and Privacy Policy links', async () => {
await expect(dialog.termsLink).toBeVisible()
await expect(dialog.termsLink).toHaveAttribute(
'href',
'https://www.comfy.org/terms-of-service'
)
await expect(dialog.privacyLink).toBeVisible()
await expect(dialog.privacyLink).toHaveAttribute(
'href',
'https://www.comfy.org/privacy'
)
})
test('Should display the "Or continue with" divider and API key button', async () => {
await expect(dialog.dividerText).toBeVisible()
await expect(dialog.apiKeyButton).toBeVisible()
})
test('Should show forgot password link on sign-in form', async () => {
await expect(dialog.forgotPasswordLink).toBeVisible()
})
test('Should close dialog via close button', async () => {
await dialog.close()
await expect(dialog.root).toBeHidden()
})
test('Should close dialog via Escape key', async ({ comfyPage }) => {
await comfyPage.page.keyboard.press('Escape')
await expect(dialog.root).toBeHidden()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -78,4 +78,66 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
await expect(minimapContainer).toBeVisible()
})
test('Close button hides minimap', async ({ comfyPage }) => {
const minimap = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap).toBeVisible()
await comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton).click()
await expect(minimap).not.toBeVisible()
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
await expect(toggleButton).toBeVisible()
})
test(
'Panning canvas moves minimap viewport',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const minimap = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap).toBeVisible()
await expect(minimap).toHaveScreenshot('minimap-before-pan.png')
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.ds.scale = 3
canvas.ds.offset[0] = -800
canvas.ds.offset[1] = -600
canvas.setDirty(true, true)
})
await expect(minimap).toHaveScreenshot('minimap-after-pan.png')
}
)
test(
'Viewport rectangle is visible and positioned within minimap',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const minimap = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap).toBeVisible()
const viewport = minimap.locator('.minimap-viewport')
await expect(viewport).toBeVisible()
const minimapBox = await minimap.boundingBox()
const viewportBox = await viewport.boundingBox()
expect(minimapBox).toBeTruthy()
expect(viewportBox).toBeTruthy()
expect(viewportBox!.width).toBeGreaterThan(0)
expect(viewportBox!.height).toBeGreaterThan(0)
expect(viewportBox!.x + viewportBox!.width).toBeGreaterThan(minimapBox!.x)
expect(viewportBox!.y + viewportBox!.height).toBeGreaterThan(
minimapBox!.y
)
expect(viewportBox!.x).toBeLessThan(minimapBox!.x + minimapBox!.width)
expect(viewportBox!.y).toBeLessThan(minimapBox!.y + minimapBox!.height)
await expect(minimap).toHaveScreenshot('minimap-with-viewport.png')
}
)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -0,0 +1,34 @@
# Properties Panel E2E Tests
Tests for the right-side properties panel (`RightSidePanel.vue`).
## Structure
| File | Coverage |
| --------------------------------- | ----------------------------------------------------------- |
| `openClose.spec.ts` | Panel open/close via actionbar and close button |
| `workflowOverview.spec.ts` | No-selection state: tabs, nodes list, global settings |
| `nodeSelection.spec.ts` | Single/multi-node selection, selection changes, tab labels |
| `titleEditing.spec.ts` | Node title editing via pencil icon |
| `searchFiltering.spec.ts` | Widget search and clear |
| `nodeSettings.spec.ts` | Settings tab: node state, color, pinned (requires VueNodes) |
| `infoTab.spec.ts` | Node help content |
| `errorsTab.spec.ts` | Errors tab visibility |
| `propertiesPanelPosition.spec.ts` | Panel position relative to sidebar |
## Shared Helper
`PropertiesPanelHelper.ts` — Encapsulates panel locators and actions. Instantiated in `beforeEach`:
```typescript
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
})
```
## Conventions
- Tests requiring VueNodes rendering enable it in `beforeEach` via `comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)` and call `comfyPage.vueNodes.waitForNodes()`.
- Verify node state changes via user-facing indicators (text labels like "Bypassed"/"Muted", pin indicator test IDs) rather than internal properties.
- Color changes are verified via `page.evaluate` accessing node properties, per the guidance in `docs/guidance/playwright.md`.

View File

@@ -0,0 +1,100 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { TestIds } from '../../fixtures/selectors'
export class PropertiesPanelHelper {
readonly root: Locator
readonly panelTitle: Locator
readonly searchBox: Locator
readonly closeButton: Locator
constructor(readonly page: Page) {
this.root = page.getByTestId(TestIds.propertiesPanel.root)
this.panelTitle = this.root.locator('h3')
this.searchBox = this.root.getByPlaceholder(/^Search/)
this.closeButton = this.root.locator('button[aria-pressed]')
}
get tabs(): Locator {
return this.root.locator('nav button')
}
getTab(label: string): Locator {
return this.root.locator('nav button', { hasText: label })
}
get titleEditIcon(): Locator {
return this.panelTitle.locator('i[class*="lucide--pencil"]')
}
get titleInput(): Locator {
return this.root.getByTestId(TestIds.node.titleInput)
}
getNodeStateButton(state: 'Normal' | 'Bypass' | 'Mute'): Locator {
return this.root.locator('button', { hasText: state })
}
getColorSwatch(colorName: string): Locator {
return this.root.locator(`[data-testid="${colorName}"]`)
}
get pinnedSwitch(): Locator {
return this.root.locator('[data-p-checked]').first()
}
get subgraphEditButton(): Locator {
return this.root.locator('button:has(i[class*="lucide--settings-2"])')
}
get contentArea(): Locator {
return this.root.locator('.scrollbar-thin')
}
get errorsTabIcon(): Locator {
return this.root.locator('nav i[class*="lucide--octagon-alert"]')
}
get viewAllSettingsButton(): Locator {
return this.root.getByRole('button', { name: /view all settings/i })
}
get collapseToggleButton(): Locator {
return this.root.locator(
'button:has(i[class*="lucide--chevrons-down-up"]), button:has(i[class*="lucide--chevrons-up-down"])'
)
}
async open(actionbar: Locator): Promise<void> {
if (!(await this.root.isVisible())) {
await actionbar.click()
await expect(this.root).toBeVisible()
}
}
async close(): Promise<void> {
if (await this.root.isVisible()) {
await this.closeButton.click()
await expect(this.root).not.toBeVisible()
}
}
async switchToTab(label: string): Promise<void> {
await this.getTab(label).click()
}
async editTitle(newTitle: string): Promise<void> {
await this.titleEditIcon.click()
await this.titleInput.fill(newTitle)
await this.titleInput.press('Enter')
}
async searchWidgets(query: string): Promise<void> {
await this.searchBox.fill(query)
}
async clearSearch(): Promise<void> {
await this.searchBox.fill('')
}
}

View File

@@ -0,0 +1,31 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Errors tab', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
})
test('should show Errors tab when errors exist', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nextFrame()
await expect(panel.errorsTabIcon).toBeVisible()
})
test('should not show Errors tab when errors are disabled', async ({
comfyPage
}) => {
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.errorsTabIcon).not.toBeVisible()
})
})

View File

@@ -0,0 +1,22 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Info tab', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nodeOps.selectNodes(['KSampler'])
await panel.switchToTab('Info')
})
test('should show node help content', async () => {
await expect(panel.contentArea).toBeVisible()
await expect(
panel.contentArea.getByRole('heading', { name: 'Inputs' })
).toBeVisible()
})
})

View File

@@ -0,0 +1,126 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Node selection', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.actionbar.propertiesButton.click()
})
test.describe('Single node', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
})
test('should show node title in panel header', async () => {
await expect(panel.panelTitle).toContainText('KSampler')
})
test('should show Parameters, Info, and Settings tabs', async () => {
await expect(panel.getTab('Parameters')).toBeVisible()
await expect(panel.getTab('Info')).toBeVisible()
await expect(panel.getTab('Settings')).toBeVisible()
})
test('should not show Nodes tab for single node', async () => {
await expect(panel.getTab('Nodes')).not.toBeVisible()
})
test('should display node widgets in Parameters tab', async () => {
await expect(panel.contentArea.getByText('seed')).toBeVisible()
await expect(panel.contentArea.getByText('steps')).toBeVisible()
})
})
test.describe('Multi-node', () => {
test('should show item count in title', async ({ comfyPage }) => {
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(panel.panelTitle).toContainText('3 items selected')
})
test('should list all selected nodes in Parameters tab', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
await expect(
panel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(2)
})
test('should not show Info tab for multi-selection', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(panel.getTab('Info')).not.toBeVisible()
})
})
test.describe('Selection changes', () => {
test('should update from no selection to node selection', async ({
comfyPage
}) => {
await expect(panel.panelTitle).toContainText('Workflow Overview')
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(panel.panelTitle).toContainText('KSampler')
})
test('should update from node selection back to no selection', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(panel.panelTitle).toContainText('KSampler')
await comfyPage.page.evaluate(() => {
window.app!.canvas.deselectAll()
})
await comfyPage.nextFrame()
await expect(panel.panelTitle).toContainText('Workflow Overview')
})
test('should update between different single node selections', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(panel.panelTitle).toContainText('KSampler')
await comfyPage.page.evaluate(() => {
window.app!.canvas.deselectAll()
})
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['Empty Latent Image'])
await expect(panel.panelTitle).toContainText('Empty Latent Image')
})
})
test.describe('Tab labels', () => {
test('should show "Parameters" tab for single node', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(panel.getTab('Parameters')).toBeVisible()
})
test('should show "Nodes" tab label for multi-selection', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(panel.getTab('Nodes')).toBeVisible()
})
})
})

View File

@@ -0,0 +1,122 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Node settings', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nodeOps.selectNodes(['KSampler'])
await panel.switchToTab('Settings')
})
test.describe('Node state', () => {
test('should show Normal, Bypass, and Mute state buttons', async () => {
await expect(panel.getNodeStateButton('Normal')).toBeVisible()
await expect(panel.getNodeStateButton('Bypass')).toBeVisible()
await expect(panel.getNodeStateButton('Mute')).toBeVisible()
})
test('should set node to Bypass mode', async ({ comfyPage }) => {
await panel.getNodeStateButton('Bypass').click()
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
await expect(nodeLocator.getByText('Bypassed')).toBeVisible()
})
test('should set node to Mute mode', async ({ comfyPage }) => {
await panel.getNodeStateButton('Mute').click()
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
await expect(nodeLocator.getByText('Muted')).toBeVisible()
})
test('should restore node to Normal mode', async ({ comfyPage }) => {
await panel.getNodeStateButton('Bypass').click()
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
await expect(nodeLocator.getByText('Bypassed')).toBeVisible()
await panel.getNodeStateButton('Normal').click()
await expect(nodeLocator.getByText('Bypassed')).not.toBeVisible()
await expect(nodeLocator.getByText('Muted')).not.toBeVisible()
})
})
test.describe('Node color', () => {
test('should display color swatches', async () => {
await expect(panel.getColorSwatch('noColor')).toBeVisible()
await expect(panel.getColorSwatch('red')).toBeVisible()
await expect(panel.getColorSwatch('blue')).toBeVisible()
})
test('should apply color to node', async ({ comfyPage }) => {
await panel.getColorSwatch('red').click()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const selected = window.app!.canvas.selected_nodes
const node = Object.values(selected)[0]
return node?.color != null
})
)
.toBe(true)
})
test('should remove color with noColor swatch', async ({ comfyPage }) => {
await panel.getColorSwatch('red').click()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const selected = window.app!.canvas.selected_nodes
const node = Object.values(selected)[0]
return node?.color != null
})
)
.toBe(true)
await panel.getColorSwatch('noColor').click()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const selected = window.app!.canvas.selected_nodes
const node = Object.values(selected)[0]
return node?.color
})
)
.toBeFalsy()
})
})
test.describe('Pinned state', () => {
test('should display pinned toggle', async () => {
await expect(panel.pinnedSwitch).toBeVisible()
})
test('should toggle pinned state', async ({ comfyPage }) => {
await panel.pinnedSwitch.click()
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
await expect(nodeLocator.getByTestId('node-pin-indicator')).toBeVisible()
})
test('should unpin previously pinned node', async ({ comfyPage }) => {
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
await panel.pinnedSwitch.click()
await expect(nodeLocator.getByTestId('node-pin-indicator')).toBeVisible()
await panel.pinnedSwitch.click()
await expect(
nodeLocator.getByTestId('node-pin-indicator')
).not.toBeVisible()
})
})
})

View File

@@ -0,0 +1,32 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Open and close', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
})
test('should open via actionbar toggle button', async ({ comfyPage }) => {
await expect(panel.root).not.toBeVisible()
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.root).toBeVisible()
})
test('should close via panel close button', async ({ comfyPage }) => {
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.root).toBeVisible()
await panel.closeButton.click()
await expect(panel.root).not.toBeVisible()
})
test('should close via close button after opening', async ({ comfyPage }) => {
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.root).toBeVisible()
await panel.close()
await expect(panel.root).not.toBeVisible()
})
})

View File

@@ -1,36 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Properties panel', () => {
test('opens and updates title based on selection', async ({ comfyPage }) => {
await comfyPage.actionbar.propertiesButton.click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.panelTitle).toContainText('Workflow Overview')
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(propertiesPanel.panelTitle).toContainText('3 items selected')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(2)
await propertiesPanel.searchBox.fill('seed')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(0)
await propertiesPanel.searchBox.fill('')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(2)
})
})

View File

@@ -0,0 +1,41 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Search filtering', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
})
test('should filter nodes by search query', async () => {
await panel.searchWidgets('seed')
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
await expect(panel.root.getByText('CLIP Text Encode (Prompt)')).toHaveCount(
0
)
})
test('should restore all nodes when search is cleared', async () => {
await panel.searchWidgets('seed')
await panel.clearSearch()
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
await expect(panel.root.getByText('CLIP Text Encode (Prompt)')).toHaveCount(
2
)
})
test('should show empty state for no matches', async () => {
await panel.searchWidgets('nonexistent_widget_xyz')
await expect(
panel.contentArea.getByText(/no .* match|no results|no items/i)
).toBeVisible()
})
})

View File

@@ -0,0 +1,50 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Title editing', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nodeOps.selectNodes(['KSampler'])
})
test('should show pencil icon for editable title', async () => {
await expect(panel.titleEditIcon).toBeVisible()
})
test('should enter edit mode on pencil click', async () => {
await panel.titleEditIcon.click()
await expect(panel.titleInput).toBeVisible()
})
test('should update node title on edit', async () => {
const newTitle = 'My Custom Sampler'
await panel.editTitle(newTitle)
await expect(panel.panelTitle).toContainText(newTitle)
})
test('should not show pencil icon for multi-selection', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(panel.titleEditIcon).not.toBeVisible()
})
test('should not show pencil icon when nothing is selected', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
window.app!.canvas.deselectAll()
})
await comfyPage.nextFrame()
await expect(panel.panelTitle).toContainText('Workflow Overview')
await expect(panel.titleEditIcon).not.toBeVisible()
})
})

View File

@@ -0,0 +1,70 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Workflow Overview', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.root).toBeVisible()
})
test('should show "Workflow Overview" title when nothing is selected', async () => {
await expect(panel.panelTitle).toContainText('Workflow Overview')
})
test('should show Parameters, Nodes, and Settings tabs', async () => {
await expect(panel.getTab('Parameters')).toBeVisible()
await expect(panel.getTab('Nodes')).toBeVisible()
await expect(panel.getTab('Settings')).toBeVisible()
})
test('should not show Info tab when nothing is selected', async () => {
await expect(panel.getTab('Info')).not.toBeVisible()
})
test('should switch to Nodes tab and list all workflow nodes', async ({
comfyPage
}) => {
await panel.switchToTab('Nodes')
const nodeCount = await comfyPage.nodeOps.getNodeCount()
expect(nodeCount).toBeGreaterThan(0)
await expect(panel.contentArea.locator('text=KSampler')).toBeVisible()
})
test('should filter nodes by search in Nodes tab', async () => {
await panel.switchToTab('Nodes')
await panel.searchWidgets('KSampler')
await expect(panel.contentArea.getByText('KSampler').first()).toBeVisible()
})
test('should switch to Settings tab and show global settings', async () => {
await panel.switchToTab('Settings')
await expect(panel.viewAllSettingsButton).toBeVisible()
})
test('should show "View all settings" button', async () => {
await panel.switchToTab('Settings')
await expect(panel.viewAllSettingsButton).toBeVisible()
})
test('should show Nodes section with toggles', async () => {
await panel.switchToTab('Settings')
await expect(
panel.contentArea.getByRole('button', { name: 'NODES' })
).toBeVisible()
})
test('should show Canvas section with grid settings', async () => {
await panel.switchToTab('Settings')
await expect(panel.contentArea.getByText('Canvas')).toBeVisible()
})
test('should show Connection Links section', async () => {
await panel.switchToTab('Settings')
await expect(panel.contentArea.getByText('Connection Links')).toBeVisible()
})
})

View File

@@ -1,8 +1,23 @@
import { expect } from '@playwright/test'
import type { components } from '@comfyorg/registry-types'
type ReleaseNote = components['schemas']['ReleaseNote']
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
function createMockRelease(overrides?: Partial<ReleaseNote>): ReleaseNote {
return {
id: 1,
project: 'comfyui',
version: 'v0.3.44',
attention: 'medium',
content: '## New Features\n\n- Added awesome feature',
published_at: new Date().toISOString(),
...overrides
}
}
test.describe('Release Notifications', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -22,15 +37,10 @@ test.describe('Release Notifications', () => {
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
id: 1,
project: 'comfyui',
version: 'v0.3.44',
attention: 'medium',
createMockRelease({
content:
'## New Features\n\n- Added awesome feature\n- Fixed important bug',
published_at: new Date().toISOString()
}
'## New Features\n\n- Added awesome feature\n- Fixed important bug'
})
])
})
} else {
@@ -157,16 +167,7 @@ test.describe('Release Notifications', () => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
id: 1,
project: 'comfyui',
version: 'v0.3.44',
attention: 'high',
content: '## New Features\n\n- Added awesome feature',
published_at: new Date().toISOString()
}
])
body: JSON.stringify([createMockRelease({ attention: 'high' })])
})
} else {
await route.continue()
@@ -250,16 +251,7 @@ test.describe('Release Notifications', () => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
id: 1,
project: 'comfyui',
version: 'v0.3.44',
attention: 'medium',
content: '## New Features\n\n- Added awesome feature',
published_at: new Date().toISOString()
}
])
body: JSON.stringify([createMockRelease()])
})
} else {
await route.continue()
@@ -303,14 +295,10 @@ test.describe('Release Notifications', () => {
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
id: 1,
project: 'comfyui',
version: 'v0.3.44',
createMockRelease({
attention: 'low',
content: '## Bug Fixes\n\n- Fixed minor issue',
published_at: new Date().toISOString()
}
content: '## Bug Fixes\n\n- Fixed minor issue'
})
])
})
} else {

View File

@@ -1,8 +1,72 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import {
createMockJob,
createMockJobs
} from '../../fixtures/helpers/AssetsHelper'
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
test.describe('Assets sidebar', () => {
// ---------------------------------------------------------------------------
// Shared fixtures
// ---------------------------------------------------------------------------
const SAMPLE_JOBS: RawJobListItem[] = [
createMockJob({
id: 'job-alpha',
create_time: 1000,
execution_start_time: 1000,
execution_end_time: 1010,
preview_output: {
filename: 'landscape.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1
}),
createMockJob({
id: 'job-beta',
create_time: 2000,
execution_start_time: 2000,
execution_end_time: 2003,
preview_output: {
filename: 'portrait.png',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'images'
},
outputs_count: 1
}),
createMockJob({
id: 'job-gamma',
create_time: 3000,
execution_start_time: 3000,
execution_end_time: 3020,
preview_output: {
filename: 'abstract_art.png',
subfolder: '',
type: 'output',
nodeId: '3',
mediaType: 'images'
},
outputs_count: 2
})
]
const SAMPLE_IMPORTED_FILES = [
'reference_photo.png',
'background.jpg',
'audio_clip.wav'
]
// ==========================================================================
// 1. Empty states
// ==========================================================================
test.describe('Assets sidebar - empty states', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockEmptyState()
await comfyPage.setup()
@@ -12,19 +76,587 @@ test.describe('Assets sidebar', () => {
await comfyPage.assets.clearMocks()
})
test('Shows empty-state copy for generated and imported tabs', async ({
comfyPage
}) => {
test('Shows empty-state copy for generated tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
})
await tab.importedTab.click()
test('Shows empty-state copy for imported tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.switchToImported()
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
})
test('No asset cards are rendered when empty', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.assetCards).toHaveCount(0)
})
})
// ==========================================================================
// 2. Tab navigation
// ==========================================================================
test.describe('Assets sidebar - tab navigation', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Generated tab is active by default', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'false')
})
test('Can switch between Generated and Imported tabs', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Switch to Imported
await tab.switchToImported()
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
// Switch back to Generated
await tab.switchToGenerated()
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
})
test('Search is cleared when switching tabs', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Type search in Generated tab
await tab.searchInput.fill('landscape')
await expect(tab.searchInput).toHaveValue('landscape')
// Switch to Imported tab
await tab.switchToImported()
await expect(tab.searchInput).toHaveValue('')
})
})
// ==========================================================================
// 3. Asset display - grid view
// ==========================================================================
test.describe('Assets sidebar - grid view display', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Displays generated assets as cards in grid view', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const count = await tab.assetCards.count()
expect(count).toBeGreaterThanOrEqual(1)
})
test('Displays imported files when switching to Imported tab', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.switchToImported()
// Wait for imported assets to render
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
// Imported tab should show the mocked files
const count = await tab.assetCards.count()
expect(count).toBeGreaterThanOrEqual(1)
})
})
// ==========================================================================
// 4. View mode toggle (grid <-> list)
// ==========================================================================
test.describe('Assets sidebar - view mode toggle', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Can switch to list view via settings menu', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Open settings menu and select list view
await tab.openSettingsMenu()
await tab.listViewOption.click()
// List view items should now be visible
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
})
test('Can switch back to grid view', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Switch to list view
await tab.openSettingsMenu()
await tab.listViewOption.click()
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
// Switch back to grid view (settings popover is still open)
await tab.gridViewOption.click()
await tab.waitForAssets()
// Grid cards (with data-selected attribute) should be visible again
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
})
})
// ==========================================================================
// 5. Search functionality
// ==========================================================================
test.describe('Assets sidebar - search', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Search input is visible', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.searchInput).toBeVisible()
})
test('Filtering assets by search query reduces displayed count', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
// Search for a specific filename that matches only one asset
await tab.searchInput.fill('landscape')
// Wait for filter to reduce the count
await expect(async () => {
const filteredCount = await tab.assetCards.count()
expect(filteredCount).toBeLessThan(initialCount)
}).toPass({ timeout: 5000 })
})
test('Clearing search restores all assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
// Filter then clear
await tab.searchInput.fill('landscape')
await expect(async () => {
expect(await tab.assetCards.count()).toBeLessThan(initialCount)
}).toPass({ timeout: 5000 })
await tab.searchInput.fill('')
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
})
test('Search with no matches shows empty state', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.searchInput.fill('nonexistent_file_xyz')
await expect(tab.assetCards).toHaveCount(0, { timeout: 5000 })
})
})
// ==========================================================================
// 6. Asset selection
// ==========================================================================
test.describe('Assets sidebar - selection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Clicking an asset card selects it', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Click first asset card
await tab.assetCards.first().click()
// Should have data-selected="true"
await expect(tab.selectedCards).toHaveCount(1)
})
test('Ctrl+click adds to selection', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const cards = tab.assetCards
const cardCount = await cards.count()
expect(cardCount).toBeGreaterThanOrEqual(2)
// Click first card
await cards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Ctrl+click second card
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
await expect(tab.selectedCards).toHaveCount(2)
})
test('Selection shows footer with count and actions', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
// Footer should show selection count
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
})
test('Deselect all clears selection', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Hover over the selection count button to reveal "Deselect all"
await tab.selectionCountButton.hover()
await expect(tab.deselectAllButton).toBeVisible({ timeout: 3000 })
// Click "Deselect all"
await tab.deselectAllButton.click()
await expect(tab.selectedCards).toHaveCount(0)
})
test('Selection is cleared when switching tabs', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Switch to Imported tab
await tab.switchToImported()
// Switch back - selection should be cleared
await tab.switchToGenerated()
await tab.waitForAssets()
await expect(tab.selectedCards).toHaveCount(0)
})
})
// ==========================================================================
// 7. Context menu
// ==========================================================================
test.describe('Assets sidebar - context menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Right-clicking an asset shows context menu', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Right-click first asset
await tab.assetCards.first().click({ button: 'right' })
// Context menu should appear with standard items
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible({ timeout: 3000 })
})
test('Context menu contains Download action for output asset', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
.locator('.p-contextmenu')
.waitFor({ state: 'visible', timeout: 3000 })
await expect(tab.contextMenuItem('Download')).toBeVisible()
})
test('Context menu contains Inspect action for image assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
.locator('.p-contextmenu')
.waitFor({ state: 'visible', timeout: 3000 })
await expect(tab.contextMenuItem('Inspect asset')).toBeVisible()
})
test('Context menu contains Delete action for output assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
.locator('.p-contextmenu')
.waitFor({ state: 'visible', timeout: 3000 })
await expect(tab.contextMenuItem('Delete')).toBeVisible()
})
test('Context menu contains Copy job ID for output assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
.locator('.p-contextmenu')
.waitFor({ state: 'visible', timeout: 3000 })
await expect(tab.contextMenuItem('Copy job ID')).toBeVisible()
})
test('Context menu contains workflow actions for output assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible({ timeout: 3000 })
await expect(
tab.contextMenuItem('Open as workflow in new tab')
).toBeVisible()
await expect(tab.contextMenuItem('Export workflow')).toBeVisible()
})
test('Bulk context menu shows when multiple assets selected', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const cards = tab.assetCards
const cardCount = await cards.count()
expect(cardCount).toBeGreaterThanOrEqual(2)
// Dismiss any toasts that appeared after asset loading
await tab.dismissToasts()
// Multi-select: click first, then Ctrl/Cmd+click second
await cards.first().click()
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
// Verify multi-selection took effect and footer is stable before right-clicking
await expect(tab.selectedCards).toHaveCount(2, { timeout: 3000 })
await expect(tab.selectionFooter).toBeVisible({ timeout: 3000 })
// Right-click on a selected card (retry to let grid layout settle)
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(async () => {
await cards.first().click({ button: 'right' })
await expect(contextMenu).toBeVisible()
}).toPass({ intervals: [300], timeout: 5000 })
// Bulk menu should show bulk download action
await expect(tab.contextMenuItem('Download all')).toBeVisible()
})
})
// ==========================================================================
// 8. Bulk actions (footer)
// ==========================================================================
test.describe('Assets sidebar - bulk actions', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Footer shows download button when assets selected', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click()
// Download button in footer should be visible
await expect(tab.downloadSelectedButton).toBeVisible({ timeout: 3000 })
})
test('Footer shows delete button when output assets selected', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click()
// Delete button in footer should be visible
await expect(tab.deleteSelectedButton).toBeVisible({ timeout: 3000 })
})
test('Selection count displays correct number', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select two assets
const cards = tab.assetCards
const cardCount = await cards.count()
expect(cardCount).toBeGreaterThanOrEqual(2)
await cards.first().click()
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
// Selection count should show the count
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
const text = await tab.selectionCountButton.textContent()
expect(text).toMatch(/Assets Selected: \d+/)
})
})
// ==========================================================================
// 9. Pagination
// ==========================================================================
test.describe('Assets sidebar - pagination', () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Initially loads a batch of assets with has_more pagination', async ({
comfyPage
}) => {
// Create a large set of jobs to trigger pagination
const manyJobs = createMockJobs(30)
await comfyPage.assets.mockOutputHistory(manyJobs)
await comfyPage.setup()
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Should load at least the first batch
const count = await tab.assetCards.count()
expect(count).toBeGreaterThanOrEqual(1)
})
})
// ==========================================================================
// 10. Settings menu visibility
// ==========================================================================
test.describe('Assets sidebar - settings menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Settings menu shows view mode options', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.openSettingsMenu()
await expect(tab.listViewOption).toBeVisible()
await expect(tab.gridViewOption).toBeVisible()
})
})

View File

@@ -1,120 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Subgraph 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()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
})
})

View File

@@ -1,95 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
test.describe(
'Subgraph promoted widget-input slot position',
{ tag: '@subgraph' },
() => {
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 = '.graphdialog input'
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

@@ -1,45 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
test.describe(
'Subgraph promoted widget DOM position',
{ tag: '@subgraph' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
// Enable Vue nodes now that the subgraph has been created
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
// Wait for Vue nodes to render
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
// The seed widget should be visible inside the node body
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
// Verify widget is inside the node body, not the header
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
})
}
)

View File

@@ -1,165 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
// Constants
const RENAMED_NAME = 'renamed_slot_name'
const SECOND_RENAMED_NAME = 'second_renamed_name'
// Common selectors
const SELECTORS = {
promptDialog: '.graphdialog input'
} as const
test.describe('Subgraph Slot Rename Dialog', { tag: '@subgraph' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Shows current slot label (not stale) in rename dialog', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-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 comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
await comfyPage.page.keyboard.press('Enter')
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
// 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))
return { label: null, name: null, displayName: null }
const slot = graph.inputs?.[0]
return {
label: slot?.label || null,
name: slot?.name || null,
displayName: slot?.displayName || slot?.label || slot?.name || null
}
})
expect(afterFirstRename.label).toBe(RENAMED_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'
})
// 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).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'
})
// 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)
})
test('Shows current output slot label in rename dialog', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-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 comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
await comfyPage.page.keyboard.press('Enter')
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
// 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'
})
// 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)
})
})

View File

@@ -1,856 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
// Constants
const RENAMED_INPUT_NAME = 'renamed_input'
const NEW_SUBGRAPH_TITLE = 'New Subgraph'
const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title'
const TEST_WIDGET_CONTENT = 'Test content that should persist'
// Common selectors
const SELECTORS = {
breadcrumb: '.subgraph-breadcrumb',
promptDialog: '.graphdialog input',
nodeSearchContainer: '.node-search-container',
domWidget: '.comfy-multiline-input'
} as const
test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
})
test.describe('I/O Slot Management', () => {
test('Can add input slots to subgraph', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await comfyPage.subgraph.getSlotCount('input')
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
'VAEEncode',
true
)
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('input')
expect(finalCount).toBe(initialCount + 1)
})
test('Can add output slots to subgraph', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await comfyPage.subgraph.getSlotCount('output')
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
'VAEEncode',
true
)
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('output')
expect(finalCount).toBe(initialCount + 1)
})
test('Can remove input slots from subgraph', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await comfyPage.subgraph.getSlotCount('input')
expect(initialCount).toBeGreaterThan(0)
await comfyPage.subgraph.removeSlot('input')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('input')
expect(finalCount).toBe(initialCount - 1)
})
test('Can remove output slots from subgraph', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await comfyPage.subgraph.getSlotCount('output')
expect(initialCount).toBeGreaterThan(0)
await comfyPage.subgraph.removeSlot('output')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('output')
expect(finalCount).toBe(initialCount - 1)
})
test('Can rename I/O slots', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel!)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
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()
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
})
test('Can rename input slots via double-click', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
await comfyPage.subgraph.doubleClickInputSlot(initialInputLabel!)
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
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()
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
})
test('Can rename output slots via double-click', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialOutputLabel = await comfyPage.subgraph.getSlotLabel('output')
await comfyPage.subgraph.doubleClickOutputSlot(initialOutputLabel!)
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
const renamedOutputName = 'renamed_output'
await comfyPage.page.fill(SELECTORS.promptDialog, renamedOutputName)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newOutputName = await comfyPage.subgraph.getSlotLabel('output')
expect(newOutputName).toBe(renamedOutputName)
expect(newOutputName).not.toBe(initialOutputLabel)
})
test('Right-click context menu still works alongside double-click', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
// Test that right-click still works for renaming
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel!)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
const rightClickRenamedName = 'right_click_renamed'
await comfyPage.page.fill(SELECTORS.promptDialog, rightClickRenamedName)
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(rightClickRenamedName)
expect(newInputName).not.toBe(initialInputLabel)
})
test('Can double-click on slot label text to rename', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
// 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)) {
throw new Error('Expected to be in subgraph')
}
const input = graph.inputs?.[0]
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,
button: 0,
preventDefault: () => {},
stopPropagation: () => {}
} as Parameters<typeof graph.inputNode.onPointerDown>[0]
const inputNode = graph.inputNode
if (inputNode?.onPointerDown) {
inputNode.onPointerDown(
leftClickEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
// Trigger double-click if pointer has the handler
if (app.canvas.pointer.onDoubleClick) {
app.canvas.pointer.onDoubleClick(leftClickEvent)
}
}
})
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
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('Can create widget from link with compressed target_slot', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
const step = await comfyPage.page.evaluate(() => {
return window.app!.graph!.nodes[0].widgets![0].options.step
})
expect(step).toBe(10)
})
})
test.describe('Subgraph Unpacking', () => {
test('Unpacking subgraph with duplicate links does not create extra links', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-duplicate-links'
)
const result = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
if (!subgraphNode || !subgraphNode.isSubgraphNode()) {
return { error: 'No subgraph node found' }
}
graph.unpackSubgraph(subgraphNode)
const linkCount = graph.links.size
const nodes = graph.nodes
const ksampler = nodes.find((n) => n.type === 'KSampler')
if (!ksampler) return { error: 'No KSampler found after unpack' }
const linkedInputCount = ksampler.inputs.filter(
(i) => i.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)
})
})
test.describe('Subgraph Creation and Deletion', () => {
test('Can create subgraph from selected nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.keyboard.selectAll()
await comfyPage.nextFrame()
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)
})
test('Can delete subgraph node', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
expect(await subgraphNode.exists()).toBe(true)
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
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)
})
test.describe('Subgraph copy and paste', () => {
test('Can copy subgraph node by dragging + alt', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
// 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)
})
test('Copying subgraph node by dragging + alt creates a new subgraph node with unique type', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
// 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 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)
})
})
})
test.describe('Operations Inside Subgraphs', () => {
test('Can copy and paste nodes in subgraph', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
const nodesInSubgraph = await comfyPage.page.evaluate(() => {
const nodes = window.app!.canvas.graph!.nodes
return nodes?.[0]?.id || null
})
expect(nodesInSubgraph).not.toBeNull()
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(
String(nodesInSubgraph)
)
await nodeToClone.click('title')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+c')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+v')
await comfyPage.nextFrame()
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
expect(finalNodeCount).toBe(initialNodeCount + 1)
})
test('Can undo and redo operations in 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()
const afterUndoCount = await comfyPage.subgraph.getNodeCount()
expect(afterUndoCount).toBe(initialCount - 1)
// Redo
await comfyPage.keyboard.redo()
await comfyPage.nextFrame()
const afterRedoCount = await comfyPage.subgraph.getNodeCount()
expect(afterRedoCount).toBe(initialCount)
})
})
test.describe('Subgraph Navigation and UI', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Breadcrumb updates when subgraph node title is changed', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('10')
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 initialBreadcrumbText = await breadcrumb.textContent()
// Go back and edit title
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y - 10
},
delay: 5
})
await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.page.keyboard.type(UPDATED_SUBGRAPH_TITLE)
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
// Navigate back into subgraph
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
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 ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
expect(await 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()
})
})
test.describe('DOM Widget Promotion', () => {
test('DOM widget visibility persists through subgraph navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
// Verify promoted widget is visible in parent graph
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(parentTextarea).toBeVisible()
await expect(parentTextarea).toHaveCount(1)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
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('DOM elements are cleaned up when subgraph node is removed', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const initialCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(initialCount).toBe(1)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.delete()
const finalCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(finalCount).toBe(0)
})
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')
const workflowName = 'subgraphs/subgraph-with-promoted-text-widget'
await comfyPage.workflow.loadWorkflow(workflowName)
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)
})
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)
})
})
test.describe('Navigation Hotkeys', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Navigation hotkey can be customized', async ({ comfyPage }) => {
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',
combo: {
key: 'q',
ctrl: false,
alt: true,
shift: false
}
}
])
await comfyPage.settings.setSetting('Comfy.Keybinding.UnsetBindings', [
{
commandId: 'Comfy.Graph.ExitSubgraph',
combo: {
key: 'Escape',
ctrl: false,
alt: false,
shift: false
}
}
])
// 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)
// Verify we're in a subgraph
expect(await 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')
}
// Test that Alt+Q now exits subgraph
await comfyPage.page.keyboard.press('Alt+q')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
})
test('Escape prioritizes closing dialogs over exiting subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
// Verify we're in a subgraph
if (!(await comfyPage.subgraph.isInSubgraph())) {
throw new Error('Not in subgraph')
}
// Open settings dialog using hotkey
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
state: 'visible'
})
// 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()
// Should still be in subgraph
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
// Press Escape again - now should exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
})
})
})

View File

@@ -0,0 +1,154 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../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)'
)
})
test.describe('Subgraph Unpacking', () => {
test('Unpacking subgraph with duplicate links does not create extra links', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-duplicate-links'
)
const result = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
if (!subgraphNode || !subgraphNode.isSubgraphNode()) {
return { error: 'No subgraph node found' }
}
graph.unpackSubgraph(subgraphNode)
const linkCount = graph.links.size
const nodes = graph.nodes
const ksampler = nodes.find((n) => n.type === 'KSampler')
if (!ksampler) return { error: 'No KSampler found after unpack' }
const linkedInputCount = ksampler.inputs.filter(
(i) => i.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)
})
})
test.describe('Subgraph Creation and Deletion', () => {
test('Can create subgraph from selected nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.keyboard.selectAll()
await comfyPage.nextFrame()
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)
})
test('Can delete subgraph node', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
expect(await subgraphNode.exists()).toBe(true)
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
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)
})
test.describe('Subgraph copy and paste', () => {
test('Can copy subgraph node by dragging + alt', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
// 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)
})
test('Copying subgraph node by dragging + alt creates a new subgraph node with unique type', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
// 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 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

@@ -1,132 +1,19 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { PromotedWidgetEntry } from '../helpers/promotedWidgets'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
import {
getPromotedWidgets,
getPseudoPreviewWidgets,
getNonPreviewPromotedWidgets
} from '../helpers/promotedWidgets'
} from '../../helpers/promotedWidgets'
const domPreviewSelector = '.image-preview'
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)
}
}
test.describe(
'Subgraph Lifecycle Edge Behaviors',
{ tag: ['@subgraph'] },
() => {
test.describe('Deterministic Hydrate from Serialized proxyWidgets', () => {
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()
const widgets = await getPromotedWidgets(comfyPage, '11')
expect(widgets.length).toBeGreaterThan(0)
for (const [interiorNodeId] of widgets) {
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
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
)
})
})
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')

View File

@@ -0,0 +1,438 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { TestIds } from '../../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
const ds = canvas.ds
const cw = canvas.canvas.width / window.devicePixelRatio
const ch = canvas.canvas.height / window.devicePixelRatio
const visLeft = -ds.offset[0]
const visTop = -ds.offset[1]
const visRight = visLeft + cw / ds.scale
const visBottom = visTop + ch / ds.scale
for (const node of canvas.graph._nodes) {
const [nx, ny] = node.pos
const [nw, nh] = node.size
if (
nx + nw > visLeft &&
nx < visRight &&
ny + nh > visTop &&
ny < visBottom
)
return true
}
return false
}
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.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Breadcrumb updates when subgraph node title is changed', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('10')
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 initialBreadcrumbText = await breadcrumb.textContent()
// Go back and edit title
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y - 10
},
delay: 5
})
await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.page.keyboard.type(UPDATED_SUBGRAPH_TITLE)
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
// Navigate back into subgraph
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
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 ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
expect(await 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()
})
})
test.describe('Navigation Hotkeys', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Navigation hotkey can be customized', async ({ comfyPage }) => {
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',
combo: {
key: 'q',
ctrl: false,
alt: true,
shift: false
}
}
])
await comfyPage.settings.setSetting('Comfy.Keybinding.UnsetBindings', [
{
commandId: 'Comfy.Graph.ExitSubgraph',
combo: {
key: 'Escape',
ctrl: false,
alt: false,
shift: false
}
}
])
// 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)
// Verify we're in a subgraph
expect(await 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')
}
// Test that Alt+Q now exits subgraph
await comfyPage.page.keyboard.press('Alt+q')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
})
test('Escape prioritizes closing dialogs over exiting subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
// Verify we're in a subgraph
if (!(await comfyPage.subgraph.isInSubgraph())) {
throw new Error('Not in subgraph')
}
// Open settings dialog using hotkey
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
state: 'visible'
})
// 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()
// Should still be in subgraph
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
// Press Escape again - now should exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
})
})
test.describe('Viewport', () => {
test('first visit fits viewport to subgraph nodes (LG)', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
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)
})
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 2000
})
.toBe(true)
})
test('first visit fits viewport to subgraph nodes (Vue)', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('11')
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 2000
})
.toBe(true)
})
test('viewport is restored when returning to root (Vue)', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
const rootViewport = await comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
})
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
}),
{ timeout: 2000 }
)
.toEqual({
scale: expect.closeTo(rootViewport.scale, 2),
offset: [
expect.closeTo(rootViewport.offset[0], 0),
expect.closeTo(rootViewport.offset[1], 0)
]
})
})
})
test.describe('Progress State', () => {
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
expect(await 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 })
})
test('Stale progress is cleared when switching workflows while inside subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
node.progress = 0.7
}, subgraphNodeId)
const subgraphNode =
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
await subgraphNode.navigateIntoSubgraph()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
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 }
}
return { exists: true, progress: subgraphNode.progress }
})
expect(subgraphProgressState.exists).toBe(true)
expect(subgraphProgressState.progress).toBeUndefined()
}).toPass({ timeout: 5_000 })
})
})
})

View File

@@ -0,0 +1,461 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test, comfyExpect } from '../../fixtures/ComfyPage'
import { SubgraphHelper } from '../../fixtures/helpers/SubgraphHelper'
import { TestIds } from '../../fixtures/selectors'
test.describe('Subgraph Nested Scenarios', { 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 ({
comfyPage
}) => {
const { warnings } = SubgraphHelper.collectConsoleWarnings(
comfyPage.page,
['No link found', 'Failed to resolve legacy -1']
)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
expect(warnings).toEqual([])
})
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)
}
}
})
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
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const widgetValues = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const outerNode = graph.getNodeById('4')
if (
!outerNode ||
typeof outerNode.isSubgraphNode !== 'function' ||
!outerNode.isSubgraphNode()
) {
return []
}
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
return (innerSubgraphNode.widgets ?? []).map((w) => ({
name: w.name,
value: w.value
}))
})
const textWidgets = widgetValues.filter((w) =>
w.name.startsWith('text')
)
comfyExpect(textWidgets).toHaveLength(2)
const values = textWidgets.map((w) => w.value)
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'] },
() => {
const WORKFLOW = 'subgraphs/nested-pack-promoted-values'
const HOST_NODE_ID = '57'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Promoted widget values persist after packing interior nodes into nested subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
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()
const heightWidget = nodeLocator
.getByLabel('height', { exact: true })
.first()
const stepsWidget = nodeLocator
.getByLabel('steps', { exact: true })
.first()
const textWidget = nodeLocator.getByRole('textbox', { name: 'prompt' })
const widthControls =
comfyPage.vueNodes.getInputNumberControls(widthWidget)
const heightControls =
comfyPage.vueNodes.getInputNumberControls(heightWidget)
const stepsControls =
comfyPage.vueNodes.getInputNumberControls(stepsWidget)
await comfyExpect(async () => {
await comfyExpect(widthControls.input).toHaveValue('1024')
await comfyExpect(heightControls.input).toHaveValue('1024')
await comfyExpect(stepsControls.input).toHaveValue('8')
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()
const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await comfyExpect(nodeAfter).toBeVisible()
const widthAfter = nodeAfter
.getByLabel('width', { exact: true })
.first()
const heightAfter = nodeAfter
.getByLabel('height', { exact: true })
.first()
const stepsAfter = nodeAfter
.getByLabel('steps', { exact: true })
.first()
const textAfter = nodeAfter.getByRole('textbox', { name: 'prompt' })
const widthControlsAfter =
comfyPage.vueNodes.getInputNumberControls(widthAfter)
const heightControlsAfter =
comfyPage.vueNodes.getInputNumberControls(heightAfter)
const stepsControlsAfter =
comfyPage.vueNodes.getInputNumberControls(stepsAfter)
await comfyExpect(async () => {
await comfyExpect(widthControlsAfter.input).toHaveValue('1024')
await comfyExpect(heightControlsAfter.input).toHaveValue('1024')
await comfyExpect(stepsControlsAfter.input).toHaveValue('8')
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'] },
() => {
const WORKFLOW = 'subgraphs/nested-subgraph-stale-proxy-widgets'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Outer subgraph node has no stale proxyWidgets after nested packing', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
await comfyExpect(outerNode).toBeVisible()
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

@@ -0,0 +1,77 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../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('Can copy and paste nodes in subgraph', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
const nodesInSubgraph = await comfyPage.page.evaluate(() => {
const nodes = window.app!.canvas.graph!.nodes
return nodes?.[0]?.id || null
})
expect(nodesInSubgraph).not.toBeNull()
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(
String(nodesInSubgraph)
)
await nodeToClone.click('title')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+c')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+v')
await comfyPage.nextFrame()
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
expect(finalNodeCount).toBe(initialNodeCount + 1)
})
test('Can undo and redo operations in 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()
const afterUndoCount = await comfyPage.subgraph.getNodeCount()
expect(afterUndoCount).toBe(initialCount - 1)
// Redo
await comfyPage.keyboard.redo()
await comfyPage.nextFrame()
const afterRedoCount = await comfyPage.subgraph.getNodeCount()
expect(afterRedoCount).toBe(initialCount)
})
}
)

View File

@@ -1,13 +1,12 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
import { fitToViewInstant } from '../helpers/fitToView'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
import { fitToViewInstant } from '../../helpers/fitToView'
import {
getPromotedWidgetNames,
getPromotedWidgetCount,
getPromotedWidgets
} from '../helpers/promotedWidgets'
getPromotedWidgetCount
} from '../../helpers/promotedWidgets'
test.describe(
'Subgraph Widget Promotion',
@@ -480,106 +479,6 @@ test.describe(
})
})
test.describe('Legacy And Round-Trip Coverage', () => {
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
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)
})
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)
}
})
})
test.describe('Vue Mode - Promoted Preview Content', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)

View File

@@ -0,0 +1,216 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { SubgraphHelper } from '../../fixtures/helpers/SubgraphHelper'
import { getPromotedWidgetNames } from '../../helpers/promotedWidgets'
// Constants
const TEST_WIDGET_CONTENT = 'Test content that should persist'
// Common selectors
const SELECTORS = {
breadcrumb: '.subgraph-breadcrumb',
domWidget: '.comfy-multiline-input'
} as const
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)'
)
})
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()
// Verify promoted widget is visible in parent graph
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(parentTextarea).toBeVisible()
await expect(parentTextarea).toHaveCount(1)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
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)
})
})
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'
)
const initialCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(initialCount).toBe(1)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.delete()
const finalCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(finalCount).toBe(0)
})
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')
const workflowName = 'subgraphs/subgraph-with-promoted-text-widget'
await comfyPage.workflow.loadWorkflow(workflowName)
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)
})
})
test.describe('DOM Positioning', () => {
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
// Enable Vue nodes now that the subgraph has been created
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
// Wait for Vue nodes to render
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
// The seed widget should be visible inside the node body
const seedWidget = nodeLocator
.getByLabel('seed', { exact: true })
.first()
await expect(seedWidget).toBeVisible()
// Verify widget is inside the node body, not the header
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
})
})
}
)

View File

@@ -1,7 +1,7 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
async function createSubgraphAndNavigateInto(comfyPage: ComfyPage) {
const subgraphNode =

View File

@@ -0,0 +1,433 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../../fixtures/ComfyPage'
import type { PromotedWidgetEntry } from '../../helpers/promotedWidgets'
import { comfyPageFixture as test, comfyExpect } from '../../fixtures/ComfyPage'
import { SubgraphHelper } from '../../fixtures/helpers/SubgraphHelper'
import { TestIds } from '../../fixtures/selectors'
import {
getPromotedWidgets,
getPromotedWidgetNames,
getPromotedWidgetCount
} from '../../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)
}
}
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()
const widgets = await getPromotedWidgets(comfyPage, '11')
expect(widgets.length).toBeGreaterThan(0)
for (const [interiorNodeId] of widgets) {
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
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
)
})
})
test.describe('Legacy And Round-Trip Coverage', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
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)
})
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)
}
})
})
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()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await 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

@@ -0,0 +1,762 @@
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test, comfyExpect } from '../../fixtures/ComfyPage'
import { SubgraphHelper } from '../../fixtures/helpers/SubgraphHelper'
// Constants
const RENAMED_INPUT_NAME = 'renamed_input'
const RENAMED_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
interface SlotMeasurement {
key: string
offsetX: number
offsetY: number
}
interface NodeSlotData {
nodeId: string
isSubgraph: boolean
nodeW: number
nodeH: number
slots: SlotMeasurement[]
}
test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
})
test.describe('I/O Slot CRUD', () => {
test('Can add input slots to subgraph', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await comfyPage.subgraph.getSlotCount('input')
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
'VAEEncode',
true
)
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('input')
expect(finalCount).toBe(initialCount + 1)
})
test('Can add output slots to subgraph', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await comfyPage.subgraph.getSlotCount('output')
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
'VAEEncode',
true
)
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('output')
expect(finalCount).toBe(initialCount + 1)
})
test('Can remove input slots from subgraph', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await comfyPage.subgraph.getSlotCount('input')
expect(initialCount).toBeGreaterThan(0)
await comfyPage.subgraph.removeSlot('input')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('input')
expect(finalCount).toBe(initialCount - 1)
})
test('Can remove output slots from subgraph', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await comfyPage.subgraph.getSlotCount('output')
expect(initialCount).toBeGreaterThan(0)
await comfyPage.subgraph.removeSlot('output')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('output')
expect(finalCount).toBe(initialCount - 1)
})
})
test.describe('Slot Rename', () => {
test('Can rename I/O slots via right-click context menu', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel!)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
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()
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
})
test('Can rename input slots via double-click', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
await comfyPage.subgraph.doubleClickInputSlot(initialInputLabel!)
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
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()
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
})
test('Can rename output slots via double-click', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialOutputLabel = await comfyPage.subgraph.getSlotLabel('output')
await comfyPage.subgraph.doubleClickOutputSlot(initialOutputLabel!)
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
const renamedOutputName = 'renamed_output'
await comfyPage.page.fill(SELECTORS.promptDialog, renamedOutputName)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newOutputName = await comfyPage.subgraph.getSlotLabel('output')
expect(newOutputName).toBe(renamedOutputName)
expect(newOutputName).not.toBe(initialOutputLabel)
})
test('Right-click context menu still works alongside double-click', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
// Test that right-click still works for renaming
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel!)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
const rightClickRenamedName = 'right_click_renamed'
await comfyPage.page.fill(SELECTORS.promptDialog, rightClickRenamedName)
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(rightClickRenamedName)
expect(newInputName).not.toBe(initialInputLabel)
})
test('Can double-click on slot label text to rename', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
// 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)) {
throw new Error('Expected to be in subgraph')
}
const input = graph.inputs?.[0]
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,
button: 0,
preventDefault: () => {},
stopPropagation: () => {}
} as Parameters<typeof graph.inputNode.onPointerDown>[0]
const inputNode = graph.inputNode
if (inputNode?.onPointerDown) {
inputNode.onPointerDown(
leftClickEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
// Trigger double-click if pointer has the handler
if (app.canvas.pointer.onDoubleClick) {
app.canvas.pointer.onDoubleClick(leftClickEvent)
}
}
})
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
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('Shows current slot label (not stale) in rename dialog', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-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 comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
await comfyPage.page.keyboard.press('Enter')
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
// 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))
return { label: null, name: null, displayName: null }
const slot = graph.inputs?.[0]
return {
label: slot?.label || null,
name: slot?.name || null,
displayName: slot?.displayName || slot?.label || slot?.name || null
}
})
expect(afterFirstRename.label).toBe(RENAMED_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'
})
// 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).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'
})
// 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)
})
test('Shows current output slot label in rename dialog', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-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 comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
await comfyPage.page.keyboard.press('Enter')
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
// 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'
})
// 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)
})
})
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('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.vueNodes.waitForNodes()
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
await comfyExpect(sgNode).toBeVisible()
// 2. Verify the seed widget is visible on the parent node
const seedWidget = sgNode.getByLabel('seed', { exact: true })
await comfyExpect(seedWidget).toBeVisible()
// 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()
// Find the seed SubgraphInput slot
const seedSlotName = await 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
})
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' })
// 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 updatedLabel = await 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')
)
return w?.label || w?.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)
})
})
test.describe('Compressed target_slot', () => {
test('Can create widget from link with compressed target_slot', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
const step = await comfyPage.page.evaluate(() => {
return window.app!.graph!.nodes[0].widgets![0].options.step
})
expect(step).toBe(10)
})
})
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('slot positions stay within node bounds after loading LG workflow', async ({
comfyPage
}) => {
const SLOT_BOUNDS_MARGIN = 20
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const workflowPath = resolve(
import.meta.dirname,
'../../assets/subgraphs/basic-subgraph.json'
)
const workflow = JSON.parse(
readFileSync(workflowPath, 'utf-8')
) as ComfyWorkflowJSON
workflow.extra = {
...workflow.extra,
workflowRendererVersion: 'LG'
}
await comfyPage.page.evaluate(
(wf) =>
window.app!.loadGraphData(wf as ComfyWorkflowJSON, true, true, null, {
openSource: 'template'
}),
workflow
)
await comfyPage.nextFrame()
// Wait for slot elements to appear in DOM
await comfyPage.page.locator('[data-slot-key]').first().waitFor()
const result: NodeSlotData[] = await comfyPage.page.evaluate(() => {
const nodes = window.app!.graph._nodes
const slotData: NodeSlotData[] = []
for (const node of nodes) {
const nodeId = String(node.id)
const nodeEl = document.querySelector(
`[data-node-id="${nodeId}"]`
) as HTMLElement | null
if (!nodeEl) continue
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
if (slotEls.length === 0) continue
const slots: SlotMeasurement[] = []
const nodeRect = nodeEl.getBoundingClientRect()
for (const slotEl of slotEls) {
const slotRect = slotEl.getBoundingClientRect()
const slotKey = (slotEl as HTMLElement).dataset.slotKey ?? 'unknown'
slots.push({
key: slotKey,
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
})
}
slotData.push({
nodeId,
isSubgraph: !!node.isSubgraphNode?.(),
nodeW: nodeRect.width,
nodeH: nodeRect.height,
slots
})
}
return slotData
})
const subgraphNodes = result.filter((n) => n.isSubgraph)
expect(subgraphNodes.length).toBeGreaterThan(0)
for (const node of subgraphNodes) {
for (const slot of node.slots) {
expect(
slot.offsetX,
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
expect(
slot.offsetX,
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
).toBeLessThanOrEqual(node.nodeW + SLOT_BOUNDS_MARGIN)
expect(
slot.offsetY,
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
expect(
slot.offsetY,
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
).toBeLessThanOrEqual(node.nodeH + SLOT_BOUNDS_MARGIN)
}
}
})
})
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

@@ -1,104 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
const RENAMED_LABEL = 'my_seed'
/**
* 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',
{ tag: ['@subgraph', '@widget'] },
() => {
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
// 1. Load workflow with subgraph containing a promoted seed widget input
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
await expect(sgNode).toBeVisible()
// 2. Verify the seed widget is visible on the parent node
const seedWidget = sgNode.getByLabel('seed', { exact: true })
await expect(seedWidget).toBeVisible()
// 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()
// Find the seed SubgraphInput slot
const seedSlotName = await 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
})
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 = '.graphdialog input'
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' })
// 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 expect(sgNodeAfter).toBeVisible()
const updatedLabel = await 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')
)
return w?.label || w?.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 expect(seedWidgetAfter).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(sgNodeAfter, seedWidgetAfter)
})
}
)

View File

@@ -1,90 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
import { TestIds } from '../fixtures/selectors'
/**
* 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)
expect(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

@@ -1,104 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
test.describe('Nested subgraph configure order', { tag: ['@subgraph'] }, () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
test('Loads without "No link found" or "Failed to resolve legacy -1" console warnings', async ({
comfyPage
}) => {
const { warnings } = SubgraphHelper.collectConsoleWarnings(comfyPage.page, [
'No link found',
'Failed to resolve legacy -1'
])
await comfyPage.workflow.loadWorkflow(WORKFLOW)
expect(warnings).toEqual([])
})
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)
}
}
})
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)
})
})

View File

@@ -1,141 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
/**
* 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: ['@subgraph', '@widget'] },
() => {
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]
)
})
expect(nonPreview).toEqual([
['1', 'text'],
['2', 'text']
])
})
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const widgetValues = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const outerNode = graph.getNodeById('4')
if (
!outerNode ||
typeof outerNode.isSubgraphNode !== 'function' ||
!outerNode.isSubgraphNode()
) {
return []
}
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
return (innerSubgraphNode.widgets ?? []).map((w) => ({
name: w.name,
value: w.value
}))
})
const textWidgets = widgetValues.filter((w) => w.name.startsWith('text'))
expect(textWidgets).toHaveLength(2)
const values = textWidgets.map((w) => w.value)
expect(values).toContain('11111111111')
expect(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 expect(outerNode).toBeVisible()
const outerPromotedRings = outerNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await expect(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 expect(intermediateNode).toBeVisible()
const intermediatePromotedRings = intermediateNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await expect(intermediatePromotedRings).toHaveCount(1)
})
})
}
)

View File

@@ -1,158 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
/**
* 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: ['@subgraph', '@widget'] },
() => {
const WORKFLOW = 'subgraphs/nested-pack-promoted-values'
const HOST_NODE_ID = '57'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Promoted widget values persist after packing interior nodes into nested subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await expect(nodeLocator).toBeVisible()
// 1. Verify initial promoted widget values via Vue node DOM
const widthWidget = nodeLocator
.getByLabel('width', { exact: true })
.first()
const heightWidget = nodeLocator
.getByLabel('height', { exact: true })
.first()
const stepsWidget = nodeLocator
.getByLabel('steps', { exact: true })
.first()
const textWidget = nodeLocator.getByRole('textbox', { name: 'prompt' })
const widthControls =
comfyPage.vueNodes.getInputNumberControls(widthWidget)
const heightControls =
comfyPage.vueNodes.getInputNumberControls(heightWidget)
const stepsControls =
comfyPage.vueNodes.getInputNumberControls(stepsWidget)
await expect(async () => {
await expect(widthControls.input).toHaveValue('1024')
await expect(heightControls.input).toHaveValue('1024')
await expect(stepsControls.input).toHaveValue('8')
await expect(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()
const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await expect(nodeAfter).toBeVisible()
const widthAfter = nodeAfter.getByLabel('width', { exact: true }).first()
const heightAfter = nodeAfter
.getByLabel('height', { exact: true })
.first()
const stepsAfter = nodeAfter.getByLabel('steps', { exact: true }).first()
const textAfter = nodeAfter.getByRole('textbox', { name: 'prompt' })
const widthControlsAfter =
comfyPage.vueNodes.getInputNumberControls(widthAfter)
const heightControlsAfter =
comfyPage.vueNodes.getInputNumberControls(heightAfter)
const stepsControlsAfter =
comfyPage.vueNodes.getInputNumberControls(stepsAfter)
await expect(async () => {
await expect(widthControlsAfter.input).toHaveValue('1024')
await expect(heightControlsAfter.input).toHaveValue('1024')
await expect(stepsControlsAfter.input).toHaveValue('8')
await expect(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 expect(nodeLocator).toBeVisible()
// Pack all interior nodes into a nested subgraph
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
// Verify all proxyWidgets entries resolve
await expect(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 })
})
}
)

View File

@@ -1,51 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
const WORKFLOW = 'subgraphs/nested-subgraph-stale-proxy-widgets'
/**
* 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: ['@subgraph', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Outer subgraph node has no stale proxyWidgets after nested packing', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
await expect(outerNode).toBeVisible()
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 expect(widgets).toHaveCount(1)
await expect(widgets.first()).toBeVisible()
// Verify the seed widget is present via its label
const seedWidget = outerNode.getByLabel('seed', { exact: true })
await expect(seedWidget).toBeVisible()
})
}
)

View File

@@ -1,107 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe(
'Subgraph progress clear on navigation',
{ tag: ['@subgraph'] },
() => {
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
expect(await 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 })
})
test('Stale progress is cleared when switching workflows while inside subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
node.progress = 0.7
}, subgraphNodeId)
const subgraphNode =
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
await subgraphNode.navigateIntoSubgraph()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
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 }
}
return { exists: true, progress: subgraphNode.progress }
})
expect(subgraphProgressState.exists).toBe(true)
expect(subgraphProgressState.progress).toBeUndefined()
}).toPass({ timeout: 5_000 })
})
}
)

View File

@@ -1,132 +0,0 @@
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
interface SlotMeasurement {
key: string
offsetX: number
offsetY: number
}
interface NodeSlotData {
nodeId: string
isSubgraph: boolean
nodeW: number
nodeH: number
slots: SlotMeasurement[]
}
/**
* 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',
{ tag: ['@subgraph', '@canvas'] },
() => {
test('slot positions stay within node bounds after loading LG workflow', async ({
comfyPage
}) => {
const SLOT_BOUNDS_MARGIN = 20
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const workflowPath = resolve(
import.meta.dirname,
'../assets/subgraphs/basic-subgraph.json'
)
const workflow = JSON.parse(
readFileSync(workflowPath, 'utf-8')
) as ComfyWorkflowJSON
workflow.extra = {
...workflow.extra,
workflowRendererVersion: 'LG'
}
await comfyPage.page.evaluate(
(wf) =>
window.app!.loadGraphData(wf as ComfyWorkflowJSON, true, true, null, {
openSource: 'template'
}),
workflow
)
await comfyPage.nextFrame()
// Wait for slot elements to appear in DOM
await comfyPage.page.locator('[data-slot-key]').first().waitFor()
const result: NodeSlotData[] = await comfyPage.page.evaluate(() => {
const nodes = window.app!.graph._nodes
const slotData: NodeSlotData[] = []
for (const node of nodes) {
const nodeId = String(node.id)
const nodeEl = document.querySelector(
`[data-node-id="${nodeId}"]`
) as HTMLElement | null
if (!nodeEl) continue
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
if (slotEls.length === 0) continue
const slots: SlotMeasurement[] = []
const nodeRect = nodeEl.getBoundingClientRect()
for (const slotEl of slotEls) {
const slotRect = slotEl.getBoundingClientRect()
const slotKey = (slotEl as HTMLElement).dataset.slotKey ?? 'unknown'
slots.push({
key: slotKey,
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
})
}
slotData.push({
nodeId,
isSubgraph: !!node.isSubgraphNode?.(),
nodeW: nodeRect.width,
nodeH: nodeRect.height,
slots
})
}
return slotData
})
const subgraphNodes = result.filter((n) => n.isSubgraph)
expect(subgraphNodes.length).toBeGreaterThan(0)
for (const node of subgraphNodes) {
for (const slot of node.slots) {
expect(
slot.offsetX,
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
expect(
slot.offsetX,
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
).toBeLessThanOrEqual(node.nodeW + SLOT_BOUNDS_MARGIN)
expect(
slot.offsetY,
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
expect(
slot.offsetY,
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
).toBeLessThanOrEqual(node.nodeH + SLOT_BOUNDS_MARGIN)
}
}
})
}
)

View File

@@ -1,114 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
function hasVisibleNodeInViewport() {
const canvas = window.app!.canvas
if (!canvas?.graph?._nodes?.length) return false
const ds = canvas.ds
const cw = canvas.canvas.width / window.devicePixelRatio
const ch = canvas.canvas.height / window.devicePixelRatio
const visLeft = -ds.offset[0]
const visTop = -ds.offset[1]
const visRight = visLeft + cw / ds.scale
const visBottom = visTop + ch / ds.scale
for (const node of canvas.graph._nodes) {
const [nx, ny] = node.pos
const [nw, nh] = node.size
if (
nx + nw > visLeft &&
nx < visRight &&
ny + nh > visTop &&
ny < visBottom
)
return true
}
return false
}
test.describe('Subgraph viewport restoration', { tag: '@subgraph' }, () => {
test('first visit fits viewport to subgraph nodes (LG)', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
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)
})
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 2000
})
.toBe(true)
})
test('first visit fits viewport to subgraph nodes (Vue)', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('11')
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 2000
})
.toBe(true)
})
test('viewport is restored when returning to root (Vue)', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
const rootViewport = await comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
})
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
}),
{ timeout: 2000 }
)
.toEqual({
scale: expect.closeTo(rootViewport.scale, 2),
offset: [
expect.closeTo(rootViewport.offset[0], 0),
expect.closeTo(rootViewport.offset[1], 0)
]
})
})
})

View File

@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { WorkflowTemplates } from '../../src/platform/workflow/templates/types/template'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
@@ -244,7 +245,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await comfyPage.page.route(
'**/templates/index.json',
async (route, _) => {
const response = [
const response: WorkflowTemplates[] = [
{
moduleName: 'default',
title: 'Test Templates',

View File

@@ -0,0 +1,364 @@
import { readFileSync } from 'fs'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Workflow Persistence', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.workflow.setupWorkflowsDirectory({})
})
test('Rapid tab switching does not desync workflow and graph state', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description: 'PR #9533 — desynced workflow/graph state during loading'
})
const tab = comfyPage.menu.workflowsTab
await tab.open()
await comfyPage.menu.topbar.saveWorkflow('rapid-A')
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.menu.topbar.saveWorkflow('rapid-B')
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
expect(nodeCountA).not.toBe(nodeCountB)
for (let i = 0; i < 3; i++) {
await tab.switchToWorkflow('rapid-A')
await tab.switchToWorkflow('rapid-B')
}
await comfyPage.workflow.waitForWorkflowIdle()
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
.toBe(nodeCountB)
await tab.switchToWorkflow('rapid-A')
await comfyPage.workflow.waitForWorkflowIdle()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
.toBe(nodeCountA)
})
test('Node outputs are preserved when switching workflow tabs', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description:
'PR #9380 — ChangeTracker.store() did not save nodeOutputs, losing preview images on tab switch'
})
const tab = comfyPage.menu.workflowsTab
await tab.open()
await comfyPage.menu.topbar.saveWorkflow('outputs-test')
const firstNode = await comfyPage.nodeOps.getFirstNodeRef()
expect(firstNode).toBeTruthy()
const nodeId = firstNode!.id
// Simulate node outputs as if execution completed
await comfyPage.page.evaluate((id) => {
const outputStore = window.app!.nodeOutputs
if (outputStore) {
outputStore[id] = {
images: [{ filename: 'test.png', subfolder: '', type: 'output' }]
}
}
}, String(nodeId))
// Trigger changeTracker to capture current state including outputs
await comfyPage.page.evaluate(() => {
const em = window.app!.extensionManager as unknown as Record<
string,
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
>
em.workflow?.activeWorkflow?.changeTracker?.checkState()
})
await comfyPage.nextFrame()
const outputsBefore = await comfyPage.page.evaluate((id) => {
return window.app!.nodeOutputs?.[id]
}, String(nodeId))
expect(outputsBefore).toBeTruthy()
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
await tab.switchToWorkflow('outputs-test')
await comfyPage.nextFrame()
const outputsAfter = await comfyPage.page.evaluate((id) => {
return window.app!.nodeOutputs?.[id]
}, String(nodeId))
expect(outputsAfter).toBeTruthy()
expect(outputsAfter?.images).toBeDefined()
})
test('Loading a new workflow cleanly replaces the previous graph', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description:
'Commit 44bb6f13 — canvas graph not reset before workflow load'
})
const defaultNodeCount = await comfyPage.nodeOps.getNodeCount()
expect(defaultNodeCount).toBeGreaterThan(1)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
.toBe(1)
const nodes = await comfyPage.nodeOps.getNodes()
expect(nodes[0].type).toBe('KSampler')
})
test('Widget values on nodes are preserved across workflow tab switches', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description: 'PR #7648 — component widget state lost on graph change'
})
const tab = comfyPage.menu.workflowsTab
await tab.open()
await comfyPage.menu.topbar.saveWorkflow('widget-state-test')
// Read widget values via page.evaluate — these are internal LiteGraph
// state not exposed through DOM
const widgetValuesBefore = await comfyPage.page.evaluate(() => {
const nodes = window.app!.graph.nodes
const results: Record<string, unknown[]> = {}
for (const node of nodes) {
if (node.widgets && node.widgets.length > 0) {
results[node.id] = node.widgets.map((w) => ({
name: w.name,
value: w.value
}))
}
}
return results
})
expect(Object.keys(widgetValuesBefore).length).toBeGreaterThan(0)
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
await tab.switchToWorkflow('widget-state-test')
await comfyPage.nextFrame()
const widgetValuesAfter = await comfyPage.page.evaluate(() => {
const nodes = window.app!.graph.nodes
const results: Record<string, unknown[]> = {}
for (const node of nodes) {
if (node.widgets && node.widgets.length > 0) {
results[node.id] = node.widgets.map((w) => ({
name: w.name,
value: w.value
}))
}
}
return results
})
expect(widgetValuesAfter).toEqual(widgetValuesBefore)
})
test('API format workflow with missing node types partially loads', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description: 'PR #9694 — loadApiJson early-returned on missing node types'
})
const fixturePath = comfyPage.assetPath(
'nodes/api_workflow_with_missing_nodes.json'
)
const apiWorkflow = JSON.parse(readFileSync(fixturePath, 'utf-8'))
await comfyPage.page.evaluate(async (workflow) => {
await window.app!.loadApiJson(workflow, 'test-api-workflow.json')
}, apiWorkflow)
await comfyPage.nextFrame()
// Known nodes (KSampler, EmptyLatentImage) should load; unknown node skipped
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
.toBeGreaterThanOrEqual(2)
const nodeTypes = await comfyPage.page.evaluate(() => {
return window.app!.graph.nodes.map((n: { type: string }) => n.type)
})
expect(nodeTypes).toContain('KSampler')
expect(nodeTypes).toContain('EmptyLatentImage')
expect(nodeTypes).not.toContain('NonExistentCustomNode_XYZ_12345')
})
test('Canvas has auxclick handler to prevent middle-click paste', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description:
'PR #8259 — middle-click paste duplicates entire workflow on Linux'
})
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.canvas.click({
button: 'middle',
position: { x: 100, y: 100 }
})
await comfyPage.nextFrame()
const nodeCountAfter = await comfyPage.nodeOps.getNodeCount()
expect(nodeCountAfter).toBe(initialNodeCount)
})
test('Exported workflow does not contain transient blob: URLs', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description:
'PR #8715 — transient image URLs leaked into workflow serialization'
})
const exportedWorkflow = await comfyPage.workflow.getExportedWorkflow()
for (const node of exportedWorkflow.nodes) {
if (node.widgets_values && Array.isArray(node.widgets_values)) {
for (const value of node.widgets_values) {
if (typeof value === 'string') {
expect(value).not.toMatch(/^blob:/)
expect(value).not.toMatch(/^https?:\/\/.*\/api\/view/)
}
}
}
}
})
test('Changing locale does not break workflow operations', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description: 'PR #8963 — template workflows not reloaded on locale change'
})
const tab = comfyPage.menu.workflowsTab
await tab.open()
await comfyPage.menu.topbar.saveWorkflow('locale-test')
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.settings.setSetting('Comfy.Locale', 'zh')
await comfyPage.nextFrame()
await comfyPage.settings.setSetting('Comfy.Locale', 'en')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
await expect.poll(() => tab.getActiveWorkflowName()).toBe('locale-test')
})
test('Node links survive save/load/switch cycles', async ({ comfyPage }) => {
test.info().annotations.push({
type: 'regression',
description: 'PR #9533 — node links must survive serialization roundtrips'
})
const tab = comfyPage.menu.workflowsTab
await tab.open()
// Link count requires internal graph state — not exposed via DOM
const linkCountBefore = await comfyPage.page.evaluate(() => {
return window.app!.graph.links
? Object.keys(window.app!.graph.links).length
: 0
})
expect(linkCountBefore).toBeGreaterThan(0)
await comfyPage.menu.topbar.saveWorkflow('links-test')
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
await tab.switchToWorkflow('links-test')
await comfyPage.workflow.waitForWorkflowIdle()
const linkCountAfter = await comfyPage.page.evaluate(() => {
return window.app!.graph.links
? Object.keys(window.app!.graph.links).length
: 0
})
expect(linkCountAfter).toBe(linkCountBefore)
})
test('Splitter panel sizes persist correctly in localStorage', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description:
'Commits 91f197d9d + a1b7e57bc — splitter panel size drift on reload'
})
await comfyPage.page.evaluate(() => {
localStorage.setItem(
'Comfy.Splitter.MainSplitter',
JSON.stringify([30, 70])
)
})
await comfyPage.setup({ clearStorage: false })
await comfyPage.nextFrame()
const storedSizes = await comfyPage.page.evaluate(() => {
const raw = localStorage.getItem('Comfy.Splitter.MainSplitter')
return raw ? JSON.parse(raw) : null
})
expect(storedSizes).toBeTruthy()
expect(Array.isArray(storedSizes)).toBe(true)
for (const size of storedSizes as number[]) {
expect(typeof size).toBe('number')
expect(size).toBeGreaterThanOrEqual(0)
expect(size).not.toBeNaN()
}
const total = (storedSizes as number[]).reduce(
(a: number, b: number) => a + b,
0
)
expect(total).toBeGreaterThan(90)
expect(total).toBeLessThanOrEqual(101)
})
})

View File

@@ -10,107 +10,191 @@ See `docs/testing/*.md` for detailed patterns.
## Best Practices
- Follow [Playwright Best Practices](https://playwright.dev/docs/best-practices)
- Do NOT use `waitForTimeout` - use Locator actions and retrying assertions
- Do NOT use `waitForTimeout` use Locator actions and retrying assertions
- Prefer specific selectors (role, label, test-id)
- Test across viewports
## Window Globals
Browser tests access `window.app`, `window.graph`, and `window.LiteGraph` which are
optional in the main app types. In E2E tests, use non-null assertions (`!`):
optional in the main app types. Use non-null assertions (`!`) in E2E tests only:
```typescript
window.app!.graph!.nodes
window.LiteGraph!.registered_node_types
```
This is the **only context** where non-null assertions are acceptable.
TODO: Consolidate into a central utility (e.g., `getApp()`) with runtime type checking.
**TODO:** Consolidate these references into a central utility (e.g., `getApp()`) that
performs proper runtime type checking, removing the need for scattered `!` assertions.
## Type Assertions
## Type Assertions in E2E Tests
Use specific type assertions when needed, never `as any`.
E2E tests may use **specific** type assertions when needed, but **never** `as any`.
### Acceptable Patterns
Acceptable:
```typescript
// ✅ Non-null assertions for window globals
window.app!.extensionManager
// ✅ Specific type assertions with documentation
// Extensions can register arbitrary setting IDs
id: 'TestSetting' as TestSettingId
// ✅ Test-local type helpers
type TestSettingId = keyof Settings
```
### Forbidden Patterns
Forbidden:
```typescript
// ❌ Never use `as any`
settings: testData as any
// ❌ Never modify production types to satisfy test errors
// Don't add test settings to src/schemas/apiSchema.ts
// ❌ Don't chain through unknown to bypass types
data as unknown as SomeType // Avoid; prefer `as Partial<SomeType> as SomeType` or explicit typings
data as unknown as SomeType
```
### Accessing Internal State
When tests need internal store properties (e.g., `.workflow`, `.focusMode`):
```typescript
// ✅ Access stores directly in page.evaluate
await page.evaluate(() => {
const store = useWorkflowStore()
return store.activeWorkflow
})
// ❌ Don't change public API types to expose internals
// Keep app.extensionManager typed as ExtensionManager, not WorkspaceStore
```
Access internal state via `page.evaluate` and stores directly — don't change public API types to expose internals.
## Assertion Best Practices
When a test depends on an invariant unrelated to what it's actually testing (e.g. asserting a node has 4 widgets before testing node movement), always assert that invariant explicitly — don't leave it unchecked. Use a custom message or `expect.soft()` rather than a bare `expect`, so failures point to the broken assumption instead of producing a confusing error downstream.
Assert preconditions explicitly with a custom message so failures point to the broken assumption:
```typescript
// ✅ Custom message on an unrelated precondition — clear signal when the invariant breaks
expect(node.widgets, 'Widget count changed — update test fixture').toHaveLength(
4
)
await node.move(100, 200)
// ✅ Soft assertion — verifies multiple invariants without stopping the test early
expect.soft(menuItem1).toBeVisible()
expect.soft(menuItem2).toBeVisible()
expect.soft(menuItem3).toBeVisible()
// ❌ Bare expect on a precondition no context when it fails
// Bad — bare expect on a precondition gives no context when it fails
expect(node.widgets).toHaveLength(4)
```
- Use custom messages (`expect(x, 'reason')`) for precondition checks unrelated to the test's purpose
- Use `expect.soft()` when you want to verify multiple invariants without aborting on the first failure
- Prefer Playwright's built-in message parameter over custom error classes
- `expect(x, 'reason')` for precondition checks unrelated to the test's purpose
- `expect.soft()` to verify multiple invariants without aborting on the first failure
## Test Structure: Arrange/Act/Assert
1. All mock setup, state resets, and fixture arrangement belongs in `test.beforeEach()` or Playwright fixtures
2. Inside `test()`, only act (user actions) and assert
3. Never call `clearAllMocks` or reset mock state mid-test
```typescript
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('test.json')
})
test('should do something', async ({ comfyPage }) => {
await comfyPage.menu.topbar.click()
await expect(comfyPage.menu.nodeLibraryTab.root).toBeVisible()
})
```
## Creating New Test Helpers
New domain-specific test helpers (e.g., `AssetHelper`, `JobHelper`) should be
registered as Playwright fixtures via `base.extend()` rather than attached as
properties on `ComfyPage`. This enables automatic setup/teardown.
### Extend `base` from Playwright
Keep each fixture self-contained by extending `@playwright/test` directly.
Compose fixtures together with `mergeTests` when a test needs multiple helpers.
```typescript
// browser_tests/fixtures/assetFixture.ts
import { test as base } from '@playwright/test'
export const test = base.extend<{
assetHelper: AssetHelper
}>({
assetHelper: async ({ page }, use) => {
const helper = new AssetHelper(page)
await helper.setup()
await use(helper)
await helper.cleanup() // automatic teardown
}
})
```
### Rules
- **Do NOT** add new helpers as properties on `ComfyPage`
- Each fixture gets automatic cleanup via the callback after `use()`
- Keep fixtures modular — extend `@playwright/test` base, not
`comfyPageFixture`, so they can be composed via `mergeTests`
## Custom Assertions
Add assertion methods directly on the page object or helper class instead of extending `comfyExpect`. Page object methods are discoverable via IntelliSense without special imports.
```typescript
// ✅ Page object assertions
await node.expectPinned()
await node.expectBypassed()
// ❌ Do not add custom matchers to comfyExpect
```
## Test Tags
Tags are respected by config:
- `@mobile` - Mobile viewport tests
- `@2x` - High DPI tests
- `@mobile` — Mobile viewport tests
- `@2x` — High DPI tests
## Test Data
- Check `browser_tests/assets/` for test data and fixtures
- Use realistic ComfyUI workflows for E2E tests
- When multiple nodes share the same title (e.g. two "CLIP Text Encode" nodes), use `vueNodes.getNodeByTitle(name).nth(n)` to pick a specific one. Never interact with the bare locator when titles are non-unique — Playwright strict mode will fail.
- Check `browser_tests/assets/` for fixtures
- Use realistic ComfyUI workflows
- When multiple nodes share the same title, use `vueNodes.getNodeByTitle(name).nth(n)` — Playwright strict mode will fail on ambiguous locators
## Fixture Data & Schemas
When creating test fixture data, import or reference existing Zod schemas and TypeScript
types from `src/` instead of inventing ad-hoc shapes. This keeps test data in sync with
production types.
Key schema locations:
- `src/schemas/apiSchema.ts` — API response types (`PromptResponse`, `SystemStats`, `User`, `UserDataFullInfo`, WebSocket messages)
- `src/schemas/nodeDefSchema.ts` — Node definition schema (`ComfyNodeDef`, `InputSpec`, `ComboInputSpec`)
- `src/schemas/nodeDef/nodeDefSchemaV2.ts` — V2 node definition schema
- `src/platform/remote/comfyui/jobs/jobTypes.ts` — Jobs API Zod schemas (`zJobDetail`, `zJobsListResponse`, `zRawJobListItem`)
- `src/platform/workflow/validation/schemas/workflowSchema.ts` — Workflow validation (`ComfyWorkflowJSON`, `ComfyApiWorkflow`)
- `src/types/metadataTypes.ts` — Asset metadata types
## Typed API Mocks
When mocking API responses with `route.fulfill()`, **always** type the response body
using existing schemas or generated types — never use untyped inline JSON objects.
This catches shape mismatches at compile time instead of through flaky runtime failures.
All three generated-type packages (`ingest-types`, `registry-types`, `generatedManagerTypes`)
are auto-generated from their respective OpenAPI specs. Prefer these as the single
source of truth for any mock that targets their endpoints.
### Sources of truth
| Endpoint category | Type source |
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| Cloud-only (hub, billing, workflows) | `@comfyorg/ingest-types` (`packages/ingest-types`, auto-generated from OpenAPI) |
| Registry (releases, nodes, publishers) | `@comfyorg/registry-types` (`packages/registry-types`, auto-generated from OpenAPI) |
| Manager (queue tasks, packages) | `generatedManagerTypes.ts` (`src/workbench/extensions/manager/types/`, auto-generated from OpenAPI) |
| Python backend (queue, history, settings, features) | Manual Zod schemas in `src/schemas/apiSchema.ts` |
| Node definitions | `src/schemas/nodeDefSchema.ts` |
| Templates | `src/platform/workflow/templates/types/template.ts` |
### Patterns
```typescript
// ✅ Import the type and annotate mock data
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
const mockRelease: ReleaseNote = {
id: 1,
project: 'comfyui',
version: 'v0.3.44',
attention: 'medium',
content: '## New Features',
published_at: new Date().toISOString()
}
body: JSON.stringify([mockRelease])
// ❌ Untyped inline JSON — schema drift goes unnoticed
body: JSON.stringify([{ id: 1, project: 'comfyui', version: 'v0.3.44', ... }])
```
## Running Tests

View File

@@ -252,6 +252,22 @@ export default defineConfig([
]
}
},
// fixtures/data/ must contain only static data — no executable code or
// Playwright imports. This enforces the architectural separation documented
// in browser_tests/AGENTS.md.
{
files: ['browser_tests/fixtures/data/**/*.ts'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'ImportDeclaration[source.value=/^@playwright/]',
message:
'fixtures/data/ must contain only static data. No Playwright imports allowed.'
}
]
}
},
{
files: ['browser_tests/tests/**/*.test.ts'],
rules: {
@@ -392,6 +408,26 @@ export default defineConfig([
]
}
},
// Browser tests must use comfyPageFixture, not raw @playwright/test test
{
files: ['browser_tests/tests/**/*.spec.ts'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@playwright/test',
importNames: ['test'],
message:
"Use `comfyPageFixture as test` from the ComfyPage fixture module instead of raw `test` from '@playwright/test'."
}
]
}
]
}
},
// Non-composable .ts files must use the global t/d/te, not useI18n()
{
files: ['**/*.ts'],

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.43.8",
"version": "1.43.10",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -8,6 +8,7 @@
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"type": "module",
"scripts": {
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' nx build",
"build:desktop": "nx build @comfyorg/desktop-ui",
"build-storybook": "storybook build",
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
@@ -44,6 +45,7 @@
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
"test:browser": "pnpm exec nx e2e",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:coverage": "vitest run --coverage",
"test:unit": "nx run test",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
@@ -56,6 +58,7 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/ingest-types": "workspace:*",
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",

View File

@@ -9,27 +9,7 @@ export default defineConfig({
parser: {
filters: {
operations: {
// Exclude endpoints that overlap with ComfyUI Python backend.
// These are shared between local and cloud, with separate Zod
// schemas already maintained in src/schemas/apiSchema.ts.
exclude: [
'/^GET \\/api\\/prompt$/',
'/^POST \\/api\\/prompt$/',
'/^GET \\/api\\/queue$/',
'/^POST \\/api\\/queue$/',
'/^GET \\/api\\/history$/',
'/^POST \\/api\\/history$/',
'/^GET \\/api\\/history_v2/',
'/^GET \\/api\\/object_info$/',
'/^GET \\/api\\/features$/',
'/^GET \\/api\\/settings$/',
'/^POST \\/api\\/settings$/',
'/^GET \\/api\\/system_stats$/',
'/^(GET|POST) \\/api\\/interrupt$/',
'/^POST \\/api\\/upload\\//',
'/^GET \\/api\\/view$/',
'/^GET \\/api\\/jobs/',
'/\\/api\\/userdata/',
// Webhooks are server-to-server, not called by frontend
'/\\/api\\/webhooks\\//',
// Internal analytics endpoint

View File

@@ -145,6 +145,11 @@ export type {
DeleteSessionResponse,
DeleteSessionResponse2,
DeleteSessionResponses,
DeleteUserdataFileData,
DeleteUserdataFileError,
DeleteUserdataFileErrors,
DeleteUserdataFileResponse,
DeleteUserdataFileResponses,
DeleteWorkflowData,
DeleteWorkflowError,
DeleteWorkflowErrors,
@@ -170,6 +175,12 @@ export type {
ExchangeTokenResponse,
ExchangeTokenResponse2,
ExchangeTokenResponses,
ExecutePromptData,
ExecutePromptError,
ExecutePromptErrors,
ExecutePromptResponse,
ExecutePromptResponses,
ExecutionError,
ExportDownloadUrlResponse,
FeedbackRequest,
FeedbackResponse,
@@ -180,6 +191,11 @@ export type {
ForkWorkflowRequest,
ForkWorkflowResponse,
ForkWorkflowResponses,
GetAllSettingsData,
GetAllSettingsError,
GetAllSettingsErrors,
GetAllSettingsResponse,
GetAllSettingsResponses,
GetAssetByIdData,
GetAssetByIdError,
GetAssetByIdErrors,
@@ -220,6 +236,9 @@ export type {
GetDeletionRequestErrors,
GetDeletionRequestResponse,
GetDeletionRequestResponses,
GetFeaturesData,
GetFeaturesResponse,
GetFeaturesResponses,
GetFilesData,
GetFilesError,
GetFilesErrors,
@@ -235,6 +254,16 @@ export type {
GetGlobalSubgraphsErrors,
GetGlobalSubgraphsResponse,
GetGlobalSubgraphsResponses,
GetHistoryData,
GetHistoryError,
GetHistoryErrors,
GetHistoryForPromptData,
GetHistoryForPromptError,
GetHistoryForPromptErrors,
GetHistoryForPromptResponse,
GetHistoryForPromptResponses,
GetHistoryResponse,
GetHistoryResponses,
GetHubProfileByUsernameData,
GetHubProfileByUsernameError,
GetHubProfileByUsernameErrors,
@@ -250,6 +279,11 @@ export type {
GetInviteCodeStatusErrors,
GetInviteCodeStatusResponse,
GetInviteCodeStatusResponses,
GetJobDetailData,
GetJobDetailError,
GetJobDetailErrors,
GetJobDetailResponse,
GetJobDetailResponses,
GetJobStatusData,
GetJobStatusError,
GetJobStatusErrors,
@@ -288,16 +322,29 @@ export type {
GetMyHubProfileErrors,
GetMyHubProfileResponse,
GetMyHubProfileResponses,
GetNodeInfoData,
GetNodeInfoResponse,
GetNodeInfoResponses,
GetPaymentPortalData,
GetPaymentPortalError,
GetPaymentPortalErrors,
GetPaymentPortalResponse,
GetPaymentPortalResponses,
GetPromptInfoData,
GetPromptInfoError,
GetPromptInfoErrors,
GetPromptInfoResponse,
GetPromptInfoResponses,
GetPublishedWorkflowData,
GetPublishedWorkflowError,
GetPublishedWorkflowErrors,
GetPublishedWorkflowResponse,
GetPublishedWorkflowResponses,
GetQueueInfoData,
GetQueueInfoError,
GetQueueInfoErrors,
GetQueueInfoResponse,
GetQueueInfoResponses,
GetRawLogsData,
GetRawLogsError,
GetRawLogsErrors,
@@ -318,12 +365,34 @@ export type {
GetSettingByKeyErrors,
GetSettingByKeyResponse,
GetSettingByKeyResponses,
GetSystemStatsData,
GetSystemStatsError,
GetSystemStatsErrors,
GetSystemStatsResponse,
GetSystemStatsResponses,
GetTaskData,
GetTaskError,
GetTaskErrors,
GetTaskResponse,
GetTaskResponses,
GetUserData,
GetUserdataData,
GetUserdataError,
GetUserdataErrors,
GetUserdataFileData,
GetUserdataFileError,
GetUserdataFileErrors,
GetUserdataFilePublishData,
GetUserdataFilePublishError,
GetUserdataFilePublishErrors,
GetUserdataFilePublishResponse,
GetUserdataFilePublishResponses,
GetUserdataFileResponse,
GetUserdataFileResponses,
GetUserdataResponse,
GetUserDataResponseFull,
GetUserDataResponseFullFile,
GetUserdataResponses,
GetUserError,
GetUserErrors,
GetUserResponse,
@@ -348,6 +417,11 @@ export type {
GetWorkspaceResponses,
GlobalSubgraphData,
GlobalSubgraphInfo,
HistoryDetailEntry,
HistoryDetailResponse,
HistoryEntry,
HistoryManageRequest,
HistoryResponse,
HubAssetUploadUrlRequest,
HubAssetUploadUrlResponse,
HubLabelInfo,
@@ -367,8 +441,15 @@ export type {
ImportPublishedAssetsResponse,
ImportPublishedAssetsResponse2,
ImportPublishedAssetsResponses,
InterruptJobData,
InterruptJobError,
InterruptJobErrors,
InterruptJobResponses,
InviteCodeClaimResponse,
InviteCodeStatusResponse,
JobDetailResponse,
JobEntry,
JobsListResponse,
JobStatusResponse,
JwkKey,
JwksResponse,
@@ -401,6 +482,11 @@ export type {
ListHubWorkflowsResponse,
ListHubWorkflowsResponses,
ListInvitesResponse,
ListJobsData,
ListJobsError,
ListJobsErrors,
ListJobsResponse,
ListJobsResponses,
ListMembersResponse,
ListSecretsData,
ListSecretsError,
@@ -441,9 +527,24 @@ export type {
ListWorkspacesResponses,
LogsResponse,
LogsSubscribeRequest,
ManageHistoryData,
ManageHistoryError,
ManageHistoryErrors,
ManageHistoryResponses,
ManageQueueData,
ManageQueueError,
ManageQueueErrors,
ManageQueueResponse,
ManageQueueResponses,
Member,
ModelFile,
ModelFolder,
MoveUserdataFileData,
MoveUserdataFileError,
MoveUserdataFileErrors,
MoveUserdataFileResponse,
MoveUserdataFileResponses,
NodeInfo,
PaginationInfo,
PartnerUsageRequest,
PartnerUsageResponse,
@@ -459,6 +560,16 @@ export type {
PostAssetsFromWorkflowErrors,
PostAssetsFromWorkflowResponse,
PostAssetsFromWorkflowResponses,
PostUserdataFileData,
PostUserdataFileError,
PostUserdataFileErrors,
PostUserdataFilePublishData,
PostUserdataFilePublishError,
PostUserdataFilePublishErrors,
PostUserdataFilePublishResponse,
PostUserdataFilePublishResponses,
PostUserdataFileResponse,
PostUserdataFileResponses,
PreviewPlanInfo,
PreviewSubscribeData,
PreviewSubscribeError,
@@ -467,6 +578,10 @@ export type {
PreviewSubscribeResponse,
PreviewSubscribeResponse2,
PreviewSubscribeResponses,
PromptErrorResponse,
PromptInfo,
PromptRequest,
PromptResponse,
PublishedWorkflowDetail,
PublishHubWorkflowData,
PublishHubWorkflowError,
@@ -474,6 +589,10 @@ export type {
PublishHubWorkflowRequest,
PublishHubWorkflowResponse,
PublishHubWorkflowResponses,
PublishWorkflowAssetsRequest,
QueueInfo,
QueueManageRequest,
QueueManageResponse,
RawLogsResponse,
RemoveAssetTagsData,
RemoveAssetTagsError,
@@ -537,6 +656,7 @@ export type {
SubscribeToLogsResponses,
SubscriptionDuration,
SubscriptionTier,
SystemStatsResponse,
TagInfo,
TagsModificationResponse,
TaskEntry,
@@ -558,6 +678,11 @@ export type {
UpdateHubProfileRequest,
UpdateHubProfileResponse,
UpdateHubProfileResponses,
UpdateMultipleSettingsData,
UpdateMultipleSettingsError,
UpdateMultipleSettingsErrors,
UpdateMultipleSettingsResponse,
UpdateMultipleSettingsResponses,
UpdateSecretData,
UpdateSecretError,
UpdateSecretErrors,
@@ -586,13 +711,30 @@ export type {
UploadAssetErrors,
UploadAssetResponse,
UploadAssetResponses,
UploadImageData,
UploadImageError,
UploadImageErrors,
UploadImageResponse,
UploadImageResponses,
UploadMaskData,
UploadMaskError,
UploadMaskErrors,
UploadMaskResponse,
UploadMaskResponses,
UserDataResponseFull,
UserResponse,
ValidationError,
ValidationResult,
ViewFileData,
ViewFileError,
ViewFileErrors,
ViewFileResponse,
ViewFileResponses,
WorkflowApiAssetsRequest,
WorkflowApiAssetsResponse,
WorkflowForkedFrom,
WorkflowListResponse,
WorkflowPublishInfo,
WorkflowResponse,
WorkflowVersionContentResponse,
WorkflowVersionResponse,

File diff suppressed because it is too large Load Diff

View File

@@ -216,6 +216,18 @@ export const zWorkflowApiAssetsRequest = z.object({
workflow_api_json: z.record(z.unknown())
})
export const zPublishWorkflowAssetsRequest = z.object({
asset_ids: z.array(z.string())
})
export const zWorkflowPublishInfo = z.object({
workflow_id: z.string(),
share_id: z.string(),
publish_time: z.string().datetime().nullish(),
listed: z.boolean(),
assets: z.array(zAssetInfo)
})
export const zForkWorkflowRequest = z.object({
source_version: z.number().int(),
name: z.string().optional()
@@ -756,6 +768,106 @@ export const zDeletionRequest = z.object({
deletion_status: z.array(zDeletionStatus)
})
/**
* Detailed execution error information from ComfyUI
*/
export const zExecutionError = z.object({
node_id: z.string(),
node_type: z.string(),
exception_message: z.string(),
exception_type: z.string(),
traceback: z.array(z.string()),
current_inputs: z.record(z.unknown()),
current_outputs: z.record(z.unknown())
})
/**
* Full job details including workflow and outputs
*/
export const zJobDetailResponse = z.object({
id: z.string().uuid(),
status: z.enum([
'pending',
'in_progress',
'completed',
'failed',
'cancelled'
]),
workflow: z.record(z.unknown()).optional(),
execution_error: zExecutionError.optional(),
create_time: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
update_time: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
outputs: z.record(z.unknown()).optional(),
preview_output: z.record(z.unknown()).optional(),
outputs_count: z.number().int().optional(),
workflow_id: z.string().optional(),
execution_status: z.record(z.unknown()).optional(),
execution_meta: z.record(z.unknown()).optional()
})
/**
* Lightweight job data for list views (workflow and full outputs excluded)
*/
export const zJobEntry = z.object({
id: z.string().uuid(),
status: z.enum([
'pending',
'in_progress',
'completed',
'failed',
'cancelled'
]),
execution_error: zExecutionError.optional(),
create_time: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
preview_output: z.record(z.unknown()).optional(),
outputs_count: z.number().int().optional(),
workflow_id: z.string().optional(),
execution_start_time: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
execution_end_time: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional()
})
export const zJobsListResponse = z.object({
jobs: z.array(zJobEntry),
pagination: zPaginationInfo
})
export const zTagsModificationResponse = z.object({
added: z.array(z.string()).optional(),
removed: z.array(z.string()).optional(),
@@ -931,6 +1043,33 @@ export const zUserResponse = z.object({
status: z.string()
})
/**
* System statistics response
*/
export const zSystemStatsResponse = z.object({
system: z.object({
os: z.string(),
python_version: z.string(),
embedded_python: z.boolean(),
comfyui_version: z.string(),
comfyui_frontend_version: z.string().optional(),
workflow_templates_version: z.string().optional(),
cloud_version: z.string().optional(),
pytorch_version: z.string(),
argv: z.array(z.string()),
ram_total: z.number(),
ram_free: z.number()
}),
devices: z.array(
z.object({
name: z.string(),
type: z.string(),
vram_total: z.number().optional(),
vram_free: z.number().optional()
})
)
})
export const zLogsSubscribeRequest = z.object({
enabled: z.boolean()
})
@@ -998,6 +1137,49 @@ export const zModelFolder = z.object({
folders: z.array(z.string())
})
/**
* Error response for ComfyUI prompt execution.
*/
export const zPromptErrorResponse = z.record(z.unknown())
export const zGetUserDataResponseFullFile = z.object({
path: z.string().optional(),
size: z.number().int().optional(),
modified: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional()
})
export const zGetUserDataResponseFull = z.array(zGetUserDataResponseFullFile)
export const zUserDataResponseFull = z.object({
path: z.string().optional(),
size: z.number().int().optional(),
modified: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional()
})
/**
* Request to manage history operations
*/
export const zHistoryManageRequest = z.object({
delete: z.array(z.string()).optional(),
clear: z.boolean().optional()
})
/**
* Job status information
*/
@@ -1018,6 +1200,89 @@ export const zJobStatusResponse = z.object({
error_message: z.string().nullish()
})
export const zQueueManageResponse = z.object({
deleted: z.array(z.string()).optional(),
cleared: z.boolean().optional()
})
/**
* Request to manage queue operations
*/
export const zQueueManageRequest = z.object({
delete: z.array(z.string()).optional(),
clear: z.boolean().optional()
})
/**
* Queue information with pending and running jobs
*/
export const zQueueInfo = z.object({
queue_running: z.array(z.array(z.unknown())).optional(),
queue_pending: z.array(z.array(z.unknown())).optional()
})
/**
* History entry with full prompt data
*/
export const zHistoryDetailEntry = z.object({
prompt: z
.object({
priority: z.number().optional(),
prompt_id: z.string().optional(),
prompt: z.record(z.unknown()).optional(),
extra_data: z.record(z.unknown()).optional(),
outputs_to_execute: z.array(z.string()).optional()
})
.optional(),
outputs: z.record(z.unknown()).optional(),
status: z.record(z.unknown()).optional(),
meta: z.record(z.unknown()).optional()
})
/**
* Detailed execution history response for a specific prompt.
* Returns a dictionary with prompt_id as key and full history data as value.
*
*/
export const zHistoryDetailResponse = z.record(zHistoryDetailEntry)
/**
* History entry with prompt_id and execution data
*/
export const zHistoryEntry = z.object({
prompt_id: z.string(),
create_time: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
workflow_id: z.string().optional(),
prompt: z
.object({
priority: z.number().optional(),
prompt_id: z.string().optional(),
extra_data: z.record(z.unknown()).optional()
})
.optional(),
outputs: z.record(z.unknown()).optional(),
status: z.record(z.unknown()).optional(),
meta: z.record(z.unknown()).optional()
})
/**
* Execution history response with history array.
* Returns an object with a "history" key containing an array of history entries.
* Each entry includes prompt_id as a property along with execution data.
*
*/
export const zHistoryResponse = z.object({
history: z.array(zHistoryEntry)
})
/**
* Full data for a global subgraph blueprint
*/
@@ -1042,6 +1307,32 @@ export const zGlobalSubgraphInfo = z.object({
data: z.string().optional()
})
export const zNodeInfo = z.object({
input: z.record(z.unknown()).optional(),
input_order: z.record(z.array(z.string())).optional(),
output: z.array(z.string()).optional(),
output_is_list: z.array(z.boolean()).optional(),
output_name: z.array(z.string()).optional(),
name: z.string().optional(),
display_name: z.string().optional(),
description: z.string().optional(),
python_module: z.string().optional(),
category: z.string().optional(),
output_node: z.boolean().optional(),
output_tooltips: z.array(z.string()).optional(),
deprecated: z.boolean().optional(),
experimental: z.boolean().optional(),
api_node: z.boolean().optional()
})
export const zPromptInfo = z.object({
exec_info: z
.object({
queue_remaining: z.number().int().optional()
})
.optional()
})
export const zExportDownloadUrlResponse = z.object({
url: z.string(),
expires_at: z.string().datetime().optional()
@@ -1052,6 +1343,22 @@ export const zErrorResponse = z.object({
message: z.string()
})
export const zPromptResponse = z.object({
prompt_id: z.string().uuid().optional(),
number: z.number().optional(),
node_errors: z.record(z.unknown()).optional()
})
export const zPromptRequest = z.object({
prompt: z.record(z.unknown()),
number: z.number().optional(),
front: z.boolean().optional(),
extra_data: z.record(z.unknown()).optional(),
partial_execution_targets: z.array(z.string()).optional(),
workflow_id: z.string().optional(),
workflow_version_id: z.string().optional()
})
export const zAssetWritable = z.object({
id: z.string().uuid(),
name: z.string(),
@@ -1096,6 +1403,53 @@ export const zAssetCreatedWritable = zAssetWritable.and(
*/
export const zFeedbackResponseWritable = z.record(z.unknown())
export const zGetPromptInfoData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success
*/
export const zGetPromptInfoResponse = zPromptInfo
export const zExecutePromptData = z.object({
body: zPromptRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success - Prompt accepted
*/
export const zExecutePromptResponse = zPromptResponse
export const zGetNodeInfoData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success
*/
export const zGetNodeInfoResponse = z.record(zNodeInfo)
export const zGetFeaturesData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success
*/
export const zGetFeaturesResponse = z.object({
supports_preview_metadata: z.boolean().optional(),
max_upload_size: z.number().int().optional()
})
export const zGetWorkflowTemplatesData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -1170,6 +1524,97 @@ export const zGetModelPreviewData = z.object({
*/
export const zGetModelPreviewResponse = z.string()
export const zManageHistoryData = z.object({
body: zHistoryManageRequest,
path: z.never().optional(),
query: z.never().optional()
})
export const zGetHistoryData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
max_items: z.number().int().optional(),
offset: z.number().int().optional().default(0)
})
.optional()
})
/**
* Success - Execution history retrieved
*/
export const zGetHistoryResponse = zHistoryResponse
export const zGetHistoryForPromptData = z.object({
body: z.never().optional(),
path: z.object({
prompt_id: z.string()
}),
query: z.never().optional()
})
/**
* Success - History for prompt retrieved
*/
export const zGetHistoryForPromptResponse = zHistoryDetailResponse
export const zListJobsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
status: z.string().optional(),
workflow_id: z.string().optional(),
output_type: z.enum(['image', 'video', 'audio', '3d']).optional(),
sort_by: z.enum(['create_time', 'execution_time']).optional(),
sort_order: z.enum(['asc', 'desc']).optional(),
offset: z.number().int().gte(0).optional().default(0),
limit: z.number().int().gte(1).lte(1000).optional().default(100)
})
.optional()
})
/**
* Success - Jobs retrieved
*/
export const zListJobsResponse = zJobsListResponse
export const zGetJobDetailData = z.object({
body: z.never().optional(),
path: z.object({
job_id: z.string().uuid()
}),
query: z.never().optional()
})
/**
* Success - Job details retrieved
*/
export const zGetJobDetailResponse = zJobDetailResponse
export const zViewFileData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.object({
filename: z.string(),
subfolder: z.string().optional(),
type: z.string().optional(),
fullpath: z.string().optional(),
format: z.string().optional(),
frame_rate: z.number().int().optional(),
workflow: z.string().optional(),
timestamp: z.number().int().optional(),
channel: z.string().optional(),
res: z.number().int().gte(64).lte(1024).optional()
})
})
/**
* Success - File content returned (used when channel or res parameter is present)
*/
export const zViewFileResponse = z.string()
export const zGetMaskLayersData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -1482,6 +1927,34 @@ export const zImportPublishedAssetsData = z.object({
*/
export const zImportPublishedAssetsResponse2 = zImportPublishedAssetsResponse
export const zGetQueueInfoData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success
*/
export const zGetQueueInfoResponse = zQueueInfo
export const zManageQueueData = z.object({
body: zQueueManageRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success
*/
export const zManageQueueResponse = zQueueManageResponse
export const zInterruptJobData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
export const zListSecretsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -1543,6 +2016,28 @@ export const zUpdateSecretData = z.object({
*/
export const zUpdateSecretResponse = zSecretResponse
export const zGetAllSettingsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* User settings as key-value pairs
*/
export const zGetAllSettingsResponse = z.record(z.unknown())
export const zUpdateMultipleSettingsData = z.object({
body: z.record(z.unknown()),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Updated user settings
*/
export const zUpdateMultipleSettingsResponse = z.record(z.unknown())
export const zGetSettingByKeyData = z.object({
body: z.never().optional(),
path: z.object({
@@ -1584,6 +2079,164 @@ export const zSubmitFeedbackData = z.object({
*/
export const zSubmitFeedbackResponse = zFeedbackResponse
export const zGetUserdataData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
dir: z.string().optional(),
recurse: z.boolean().optional().default(false),
split: z.boolean().optional().default(false),
full_info: z.boolean().optional().default(false)
})
.optional()
})
/**
* A list of user data files.
*/
export const zGetUserdataResponse = zGetUserDataResponseFull
export const zGetUserdataFilePublishData = z.object({
body: z.never().optional(),
path: z.object({
file: z.string()
}),
query: z.never().optional()
})
/**
* Publish info (publish_time is null if never published)
*/
export const zGetUserdataFilePublishResponse = zWorkflowPublishInfo
export const zPostUserdataFilePublishData = z.object({
body: zPublishWorkflowAssetsRequest,
path: z.object({
file: z.string()
}),
query: z.never().optional()
})
/**
* Workflow published
*/
export const zPostUserdataFilePublishResponse = zWorkflowPublishInfo
export const zDeleteUserdataFileData = z.object({
body: z.never().optional(),
path: z.object({
file: z.string()
}),
query: z.never().optional()
})
/**
* File deleted successfully (No Content).
*/
export const zDeleteUserdataFileResponse = z.void()
export const zGetUserdataFileData = z.object({
body: z.never().optional(),
path: z.object({
file: z.string()
}),
query: z.never().optional()
})
/**
* Successfully retrieved the file.
*/
export const zGetUserdataFileResponse = z.string()
export const zPostUserdataFileData = z.object({
body: z.string(),
path: z.object({
file: z.string()
}),
query: z
.object({
overwrite: z.enum(['true', 'false']).optional(),
full_info: z.enum(['true', 'false']).optional()
})
.optional()
})
/**
* File uploaded successfully.
*/
export const zPostUserdataFileResponse = zUserDataResponseFull
export const zMoveUserdataFileData = z.object({
body: z.never().optional(),
path: z.object({
file: z.string(),
dest: z.string()
}),
query: z
.object({
overwrite: z.enum(['true', 'false']).optional()
})
.optional()
})
/**
* File moved successfully.
*/
export const zMoveUserdataFileResponse = zUserDataResponseFull
export const zUploadImageData = z.object({
body: z.object({
image: z.string(),
overwrite: z.string().optional(),
subfolder: z.string().optional(),
type: z.string().optional()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Image uploaded successfully
*/
export const zUploadImageResponse = z.object({
name: z.string().optional(),
subfolder: z.string().optional(),
type: z.string().optional()
})
export const zUploadMaskData = z.object({
body: z.object({
image: z.string(),
original_ref: z.string()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Mask uploaded successfully
*/
export const zUploadMaskResponse = z.object({
name: z.string().optional(),
subfolder: z.string().optional(),
type: z.string().optional(),
metadata: z
.object({
is_mask: z.boolean().optional(),
original_hash: z.string().optional(),
mask_type: z.string().optional(),
related_files: z
.object({
mask: z.string().optional(),
paint: z.string().optional(),
painted: z.string().optional()
})
.optional()
})
.optional()
})
export const zGetLogsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -1619,6 +2272,17 @@ export const zSubscribeToLogsResponse = z.object({
enabled: z.boolean().optional()
})
export const zGetSystemStatsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success
*/
export const zGetSystemStatsResponse = zSystemStatsResponse
export const zDeleteSessionData = z.object({
body: z.never().optional(),
path: z.never().optional(),

View File

@@ -36,7 +36,7 @@ export default defineConfig({
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
timeout: 15000,
grepInvert: /@mobile|@perf|@audit/ // Run all tests except those tagged with @mobile, @perf, or @audit
grepInvert: /@mobile|@perf|@audit|@cloud/ // Run all tests except those tagged with @mobile, @perf, @audit, or @cloud
},
{
@@ -85,6 +85,14 @@ export default defineConfig({
// use: { ...devices['Desktop Safari'] },
// },
{
name: 'cloud',
use: { ...devices['Desktop Chrome'] },
timeout: 15000,
grep: /@cloud/, // Run only tests tagged with @cloud
grepInvert: /@oss/ // Exclude tests tagged with @oss
},
/* Test against mobile viewports. */
{
name: 'mobile-chrome',

3
pnpm-lock.yaml generated
View File

@@ -410,6 +410,9 @@ importers:
'@comfyorg/design-system':
specifier: workspace:*
version: link:packages/design-system
'@comfyorg/ingest-types':
specifier: workspace:*
version: link:packages/ingest-types
'@comfyorg/registry-types':
specifier: workspace:*
version: link:packages/registry-types

View File

@@ -29,6 +29,8 @@ interface PerfMeasurement {
eventListeners: number
totalBlockingTimeMs: number
frameDurationMs: number
p95FrameDurationMs: number
allFrameDurationsMs?: number[]
}
interface PerfReport {
@@ -53,6 +55,7 @@ type MetricKey =
| 'eventListeners'
| 'totalBlockingTimeMs'
| 'frameDurationMs'
| 'p95FrameDurationMs'
| 'heapUsedBytes'
interface MetricDef {
@@ -64,6 +67,8 @@ interface MetricDef {
}
const REPORTED_METRICS: MetricDef[] = [
{ key: 'frameDurationMs', label: 'avg frame time', unit: 'ms' },
{ key: 'p95FrameDurationMs', label: 'p95 frame time', unit: 'ms' },
{ key: 'layoutDurationMs', label: 'layout duration', unit: 'ms' },
{
key: 'styleRecalcDurationMs',
@@ -80,12 +85,15 @@ const REPORTED_METRICS: MetricDef[] = [
{ key: 'taskDurationMs', label: 'task duration', unit: 'ms' },
{ key: 'scriptDurationMs', label: 'script duration', unit: 'ms' },
{ key: 'totalBlockingTimeMs', label: 'TBT', unit: 'ms' },
{ key: 'frameDurationMs', label: 'frame duration', unit: 'ms' },
{ key: 'heapUsedBytes', label: 'heap used', unit: 'bytes' },
{ key: 'domNodes', label: 'DOM nodes', unit: '', minAbsDelta: 5 },
{ key: 'eventListeners', label: 'event listeners', unit: '', minAbsDelta: 5 }
]
/** Target: P5 FPS ≥ 52 → P95 frame time ≤ 19.2ms */
const TARGET_P95_FRAME_MS = 19.2
const TARGET_P5_FPS = 52
function groupByName(
measurements: PerfMeasurement[]
): Map<string, PerfMeasurement[]> {
@@ -207,6 +215,46 @@ function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function frameTimeToFps(ms: number): number {
return ms > 0 ? 1000 / ms : 0
}
function renderHeadlineSummary(
prGroups: Map<string, PerfMeasurement[]>
): string[] {
const lines: string[] = []
const summaries: string[] = []
for (const [testName, prSamples] of prGroups) {
const avgFrame = medianMetric(prSamples, 'frameDurationMs')
const p95Frame = medianMetric(prSamples, 'p95FrameDurationMs')
const tbt = medianMetric(prSamples, 'totalBlockingTimeMs')
const heap = medianMetric(prSamples, 'heapUsedBytes')
const avgFps = avgFrame !== null ? frameTimeToFps(avgFrame) : null
const p5Fps = p95Frame !== null ? frameTimeToFps(p95Frame) : null
const parts: string[] = [`**${testName}**:`]
if (avgFps !== null) parts.push(`${avgFps.toFixed(1)} avg FPS`)
if (p5Fps !== null) {
const pass = p5Fps >= TARGET_P5_FPS
parts.push(
`${p5Fps.toFixed(1)} P5 FPS ${pass ? '✅' : '❌'} (target: ≥${TARGET_P5_FPS})`
)
}
if (tbt !== null) parts.push(`${tbt.toFixed(0)}ms TBT`)
if (heap !== null) parts.push(`${formatBytes(heap)} heap`)
if (parts.length > 1) summaries.push(parts.join(' · '))
}
if (summaries.length > 0) {
lines.push('> ' + summaries.join('\n> '), '')
}
return lines
}
function renderFullReport(
prGroups: Map<string, PerfMeasurement[]>,
baseline: PerfReport,
@@ -423,6 +471,7 @@ function main() {
const lines: string[] = []
lines.push('## ⚡ Performance Report\n')
lines.push(...renderHeadlineSummary(prGroups))
if (baseline && historical.length >= 2) {
lines.push(...renderFullReport(prGroups, baseline, historical))
@@ -432,9 +481,15 @@ function main() {
lines.push(...renderNoBaselineReport(prGroups))
}
const rawData = {
...current,
measurements: current.measurements.map(
({ allFrameDurationsMs: _, ...rest }) => rest
)
}
lines.push('\n<details><summary>Raw data</summary>\n')
lines.push('```json')
lines.push(JSON.stringify(current, null, 2))
lines.push(JSON.stringify(rawData, null, 2))
lines.push('```')
lines.push('\n</details>')

View File

@@ -192,3 +192,15 @@ export function curvesToLUT(
return lut
}
export function curveDataToFloatLUT(
curve: CurveData,
size: number = 256
): Float32Array {
const lut = new Float32Array(size)
const interpolate = createInterpolator(curve.points, curve.interpolation)
for (let i = 0; i < size; i++) {
lut[i] = interpolate(i / (size - 1))
}
return lut
}

View File

@@ -143,11 +143,16 @@
<Button
v-if="shouldShowDeleteButton"
size="icon"
data-testid="assets-delete-selected"
@click="handleDeleteSelected"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button size="icon" @click="handleDownloadSelected">
<Button
size="icon"
data-testid="assets-download-selected"
@click="handleDownloadSelected"
>
<i class="icon-[lucide--download] size-4" />
</Button>
</template>
@@ -156,12 +161,17 @@
<Button
v-if="shouldShowDeleteButton"
variant="secondary"
data-testid="assets-delete-selected"
@click="handleDeleteSelected"
>
<span>{{ $t('mediaAsset.selection.deleteSelected') }}</span>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button variant="secondary" @click="handleDownloadSelected">
<Button
variant="secondary"
data-testid="assets-download-selected"
@click="handleDownloadSelected"
>
<span>{{ $t('mediaAsset.selection.downloadSelected') }}</span>
<i class="icon-[lucide--download] size-4" />
</Button>

View File

@@ -2258,6 +2258,7 @@
"dataset": "مجموعة بيانات",
"debug": "تصحيح",
"deprecated": "مهمل",
"detection": "الكشف",
"edit_models": "تحرير النماذج",
"flux": "تدفق",
"gligen": "gligen",
@@ -3239,6 +3240,7 @@
"termsAgreement": "بالمتابعة، أنت توافق على {terms} و{privacy} الخاصة بـ Comfy Org.",
"totalDueToday": "الإجمالي المستحق اليوم"
},
"refreshCredits": "تحديث الرصيد",
"renewsDate": "تجديد في {date}",
"required": {
"pollingFailed": "فشل تفعيل الاشتراك",

View File

@@ -2190,6 +2190,10 @@
"image": {
"name": "الصورة"
},
"keep_aspect": {
"name": "keep_aspect",
"tooltip": "ما إذا كان سيتم تمديد الاقتصاص ليتناسب مع حجم الإخراج، أو إضافة حواف سوداء للحفاظ على نسبة العرض إلى الارتفاع."
},
"output_height": {
"name": "ارتفاع الناتج",
"tooltip": "الارتفاع الذي يتم تغيير حجم كل قص إليه."
@@ -2313,6 +2317,23 @@
}
}
},
"DrawBBoxes": {
"display_name": "رسم مربعات الإحاطة",
"inputs": {
"bboxes": {
"name": "bboxes"
},
"image": {
"name": "image"
}
},
"outputs": {
"0": {
"name": "out_image",
"tooltip": null
}
}
},
"DualCFGGuider": {
"display_name": "موجّه CFG مزدوج",
"inputs": {
@@ -12226,6 +12247,34 @@
}
}
},
"RTDETR_detect": {
"display_name": "اكتشاف RT-DETR",
"inputs": {
"class_name": {
"name": "class_name",
"tooltip": "تصفية الاكتشافات حسب الفئة. اختر 'all' لتعطيل التصفية."
},
"image": {
"name": "image"
},
"max_detections": {
"name": "max_detections",
"tooltip": "الحد الأقصى لعدد الاكتشافات التي سيتم إرجاعها لكل صورة. بالترتيب التنازلي حسب درجة الثقة."
},
"model": {
"name": "model"
},
"threshold": {
"name": "threshold"
}
},
"outputs": {
"0": {
"name": "bboxes",
"tooltip": null
}
}
},
"RandomCropImages": {
"display_name": "قص عشوائي للصور",
"inputs": {
@@ -15631,6 +15680,10 @@
"1": {
"name": "FBX",
"tooltip": null
},
"2": {
"name": "صورة UV",
"tooltip": null
}
}
},

View File

@@ -1678,6 +1678,8 @@
"scheduling": "scheduling",
"create": "create",
"deprecated": "deprecated",
"detection": "detection",
"": "",
"debug": "debug",
"model": "model",
"ElevenLabs": "ElevenLabs",
@@ -1688,7 +1690,6 @@
"unet": "unet",
"sigmas": "sigmas",
"BFL": "BFL",
"": "",
"Gemini": "Gemini",
"video_models": "video_models",
"gligen": "gligen",

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