Compare commits

..

21 Commits

Author SHA1 Message Date
Rizumu Ayaka
9d93fda3f1 test: add perf tests for workflow loading time
Add two @perf tests measuring workflow load + settling time:
- large workflow (245 nodes): measures DOM creation and layout cost
- media-heavy workflow (585 nodes + 128 media): measures combined
  node creation and media preview loading pressure

The media-heavy test intercepts /api/view requests with a small
test image to ensure deterministic media rendering cost.
2026-04-02 16:52:52 +08:00
jaeone94
d9466947b2 feat: detect and resolve missing media inputs in error tab (#10309)
## Summary

Add detection and resolution UI for missing image/video/audio inputs
(LoadImage, LoadVideo, LoadAudio nodes) in the Errors tab, mirroring the
existing missing model pipeline.

## Changes

- **What**: New `src/platform/missingMedia/` module — scan pipeline
detects missing media files on workflow load (sync for OSS, async for
cloud), surfaces them in the error tab with upload dropzone, thumbnail
library select, and 2-step confirm flow
- **Detection**: `scanAllMediaCandidates()` checks combo widget values
against options; cloud path defers to `verifyCloudMediaCandidates()` via
`assetsStore.updateInputs()`
- **UI**: `MissingMediaCard` groups by media type; `MissingMediaRow`
shows node name (single) or filename+count (multiple), upload dropzone
with drag & drop, `MissingMediaLibrarySelect` with image/video
thumbnails
- **Resolution**: Upload via `/upload/image` API or select from library
→ status card → checkmark confirm → widget value applied, item removed
from error list
- **Integration**: `executionErrorStore` aggregates into
`hasAnyError`/`totalErrorCount`; `useNodeErrorFlagSync` flags nodes on
canvas; `useErrorGroups` renders in error tab
- **Shared**: Extract `ACCEPTED_IMAGE_TYPES`/`ACCEPTED_VIDEO_TYPES` to
`src/utils/mediaUploadUtil.ts`; extract `resolveComboValues` to
`src/utils/litegraphUtil.ts` (shared across missingMedia + missingModel
scan)
- **Reverse clearing**: Widget value changes on nodes auto-remove
corresponding missing media errors (via `clearWidgetRelatedErrors`)

## Testing

### Unit tests (22 tests)
- `missingMediaScan.test.ts` (12): groupCandidatesByName,
groupCandidatesByMediaType (ordering, multi-name),
verifyCloudMediaCandidates (missing/present, abort before/after
updateInputs, already resolved true/false, no-pending skip, updateInputs
spy)
- `missingMediaStore.test.ts` (10): setMissingMedia, clearMissingMedia
(full lifecycle with interaction state), missingMediaNodeIds,
hasMissingMediaOnNode, removeMissingMediaByWidget
(match/no-match/last-entry), createVerificationAbortController

### E2E tests (10 scenarios in `missingMedia.spec.ts`)
- Detection: error overlay shown, Missing Inputs group in errors tab,
correct row count, dropzone + library select visibility, no false
positive for valid media
- Upload flow: file picker → uploading status card → confirm → row
removed
- Library select: dropdown → selected status card → confirm → row
removed
- Cancel: pending selection → returns to upload/library UI
- All resolved: Missing Inputs group disappears
- Locate node: canvas pans to missing media node

## Review Focus

- Cloud verification path: `verifyCloudMediaCandidates` compares widget
value against `asset_hash` — implicit contract
- 2-step confirm mirrors missing model pattern (`pendingSelection` →
confirm/cancel)
- Event propagation guard on dropzone (`@drop.prevent.stop`) to prevent
canvas LoadImage node creation
- `clearAllErrors()` intentionally does NOT clear missing media (same as
missing models — preserves pending repairs)
- `runMissingMediaPipeline` is now `async` and `await`-ed, matching
model pipeline

## Test plan

- [x] OSS: load workflow with LoadImage referencing non-existent file →
error tab shows it
- [x] Upload file via dropzone → status card shows "Uploaded" → confirm
→ widget updated, error removed
- [x] Select from library with thumbnail preview → confirm → widget
updated, error removed
- [x] Cancel pending selection → returns to upload/library UI
- [x] Load workflow with valid images → no false positives
- [x] Click locate-node → canvas navigates to the node
- [x] Multiple nodes referencing different missing files → correct row
count
- [x] Widget value change on node → missing media error auto-removed

## Screenshots


https://github.com/user-attachments/assets/631c0cb0-9706-4db2-8615-f24a4c3fe27d
2026-04-01 17:59:02 +09:00
jaeone94
bb96e3c95c fix: resolve subgraph promoted widget panel regressions (#10648)
## Summary

Fix four bugs in the subgraph promoted widget panel where linked
promotions were not distinguished from independent ones, causing
incorrect UI state in both the SubgraphEditor (Settings) panel and the
Parameters tab WidgetActions menu.

## Changes

- **What**: Add `isLinkedPromotion` helper to correctly identify widgets
driven by subgraph input connections. Fix `disambiguatingSourceNodeId`
lookup mismatch that broke `isWidgetShownOnParents` and
`handleHideInput` for non-nested promoted widgets. Replace fragile CSS
icon selectors with `data-testid` attributes.

## Bugs fixed

Companion fix PR for #10502 (red-green test PR). All 4 E2E tests from
#10502 now pass:

| Bug | Root cause | Fix |
|-----|-----------|-----|
| Linked promoted widgets have hide toggle enabled | `SubgraphEditor`
only checked `node.id === -1` (physical) — linked promotions from
subgraph input connections were not detected | Added `isLinkedPromotion`
helper that checks `input._widget` bindings; `SubgraphNodeWidget`
`:is-physical` prop now covers both physical and linked cases |
| Linked promoted widgets show eye icon instead of link icon | Same root
cause as above — `isPhysical` prop was only true for `node.id === -1` |
Extended the `:is-physical` condition to include `isLinkedPromotion`
check |
| Widget labels show raw names instead of renamed values |
`SubgraphEditor` passed `widget.name` instead of `widget.label \|\|
widget.name` | Changed `:widget-name` binding to prefer `widget.label` |
| WidgetActions menu shows Hide/Show for linked promotions |
`v-if="hasParents"` didn't exclude linked promotions | Added
`canToggleVisibility` computed that combines `hasParents` with
`!isLinked` check via `isLinkedPromotion` |

### Additional bugs discovered and fixed

| Bug | Root cause | Fix |
|-----|-----------|-----|
| "Show input" always displayed instead of "Hide input" for promoted
widgets | `SectionWidgets.isWidgetShownOnParents` used
`getSourceNodeId(widget)` which falls back to `widget.sourceNodeId` when
`disambiguatingSourceNodeId` is undefined — this mismatches the
promotion store key (`undefined`) | Changed to
`widget.disambiguatingSourceNodeId` directly |
| "Hide input" click does nothing | `WidgetActions.handleHideInput` used
`getSourceNodeId(widget)` for the same reason — `demote()` couldn't find
the entry to remove | Same fix — use `widget.disambiguatingSourceNodeId`
directly |

## Tests added

### E2E (Playwright) —
`browser_tests/tests/subgraphPromotedWidgetPanel.spec.ts`

| Test | What it verifies |
|------|-----------------|
| linked promoted widgets have hide toggle disabled | All toggle buttons
in SubgraphEditor shown section are disabled for linked widgets (covers
1-level and 2-level nested promotions via `subgraph-nested-promotion`
fixture) |
| linked promoted widgets show link icon instead of eye icon | Link
icons appear for linked widgets, no eye icons present |
| widget labels display renamed values instead of raw names |
`widget.label` is displayed when set, not `widget.name` |
| linked promoted widget menu should not show Hide/Show input |
Three-dot menu on Parameters tab omits Hide/Show options for linked
promotions, Rename is still available |

### Unit (Vitest) — `src/core/graph/subgraph/promotionUtils.test.ts`

7 tests covering `isLinkedPromotion`: basic matching, negative cases,
nested subgraph with `disambiguatingSourceNodeId`, multiple inputs, and
mixed linked/independent state.

### Unit (Vitest) —
`src/components/rightSidePanel/parameters/WidgetActions.test.ts`

- Added `isSubgraphNode: () => false` to mock nodes to prevent crash
from new `isLinked` computed

## Review Focus

- `isLinkedPromotion` reads `input._widget` (WeakRef-backed,
non-reactive) directly in the template. This is intentional — `_widget`
bindings are set during subgraph initialization before the user opens
the panel, so stale reads don't occur in practice. A computed-based
approach was attempted but reverted because `_widget` changes cannot
trigger Vue reactivity.
- `getSourceNodeId` removal in `SectionWidgets` and `WidgetActions` is
intentional — the old fallback (`?? widget.sourceNodeId`) caused key
mismatches with the promotion store for non-nested widgets.

## Screenshots
Before
<img width="723" height="935" alt="image"
src="https://github.com/user-attachments/assets/09862578-a0d1-45b4-929c-f22f7494ebe2"
/>

After
<img width="999" height="952" alt="image"
src="https://github.com/user-attachments/assets/ed8fe604-6b44-46b9-a315-6da31d6b405a"
/>
2026-04-01 17:10:30 +09:00
jaeone94
df42b7a2a8 fix: collapsed node connection link positions (#10641)
## Summary

Fix connection links rendering at wrong positions when nodes are
collapsed in Vue nodes mode.

## Changes

- **What**: Fall back to `clientPosToCanvasPos` for collapsed node slot
positioning since DOM-relative scale derivation is invalid when layout
store preserves expanded size. Clear stale `cachedOffset` on collapse
and defer sync when canvas is not yet initialized.
- 3 unit tests for collapsed node slot sync fallback
(clientPosToCanvasPos, cachedOffset clearing, canvas-not-initialized
deferral)
- 3 E2E tests for collapsed node link positions (within bounds, after
position change, after expand recovery)

## Review Focus

- `clientPosToCanvasPos` fallback is safe for collapsed nodes because
collapse is user-initiated (no loading-time transform desync risk that
#9121 originally fixed)
- `cachedOffset` clearing prevents stale expanded-state offsets during
collapsed node drag
- Regression from #9121 (DOM-relative scale) combined with #9680
(collapsed node ResizeObserver skip)

## Screenshots 
Before
<img width="1030" height="434" alt="image"
src="https://github.com/user-attachments/assets/2f8b8a1f-ed22-4588-ab62-72b89880e53f"
/>

After
<img width="1029" height="476" alt="image"
src="https://github.com/user-attachments/assets/52dbbf7c-61ed-465b-ae19-a9781513e7e8"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10641-fix-collapsed-node-connection-link-positions-3316d73d365081f4aee3fecb92c83b91)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Alexander Brown <DrJKL0424@gmail.com>
2026-04-01 13:49:12 +09:00
Kelly Yang
4f3a5ae184 fix(load3d): fix squashed controls in 3D inspector side panel (#10768)
## Summary

Fixes squashed `input controls` (color picker, sliders, dropdowns) in
the 3D asset inspector side panel.

## Screenshots 

before
<img width="3012" height="1580" alt="image"
src="https://github.com/user-attachments/assets/edc6fadc-cdc5-4a4e-92e7-57faabfeb1a4"
/>

after
<img width="4172" height="2062" alt="image"
src="https://github.com/user-attachments/assets/766324ce-e8f7-43fc-899e-ae275f880e59"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10768-fix-load3d-fix-squashed-controls-in-3D-inspector-side-panel-3346d73d365081e8b438de8115180685)
by [Unito](https://www.unito.io)
2026-04-01 00:34:37 -04:00
Dante
c77c8a9476 test: migrate fromAny to fromPartial for type-checked test mocks (#10788)
## Summary
- Convert `fromAny` → `fromPartial` in 7 test files where object
literals or interfaces are passed
- `fromPartial` type-checks the provided fields, unlike `fromAny` which
bypasses all checking (same as `as unknown as`)
- Class-based types (`LGraphNode`, `LGraph`) remain `fromAny` due to
shoehorn's `PartialDeep` incompatibility with class constructors

## Changes
- **Pure conversions** (all `fromAny` → `fromPartial`):
`domWidgetZIndex`, `matchPromotedInput`, `promotionUtils`,
`subgraphNavigationStore`
- **Mixed** (some converted, some kept): `promotedWidgetView`,
`widgetUtil`
- **Cleanup**: `nodeOutputStore` type param normalization

Follows up on #10761.

## Test plan
- [x] `pnpm typecheck` passes
- [x] `pnpm vitest run` on all 7 changed files — 169 tests pass
- [x] `pnpm lint` passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10788-test-migrate-fromAny-to-fromPartial-for-type-checked-test-mocks-3356d73d365081f7bf61d48a47af530c)
by [Unito](https://www.unito.io)
2026-03-31 21:11:50 -07:00
Dante
380fae9a0d chore(test): remove dead QueueHelper from browser tests (#10771)
## Summary
- Remove unused `QueueHelper` class and its `comfyPage.queue` property
- `QueueHelper` mocks the legacy `/api/queue` tuple format which the app
no longer uses (now `/api/jobs` via `fetchQueue()`)
- `comfyPage.queue.*` is never called in any test

Fixes #10670

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10771-chore-test-remove-dead-QueueHelper-from-browser-tests-3346d73d36508117bb19db9492bcbed3)
by [Unito](https://www.unito.io)
2026-03-31 19:55:52 +09:00
pythongosssss
515f234143 fix: Ensure all save/save as buttons are the same width (#10681)
## Summary

Makes the save/save as buttons in the builder footer toolbar all a fixed
size so when switching states the elements dont jump

## Changes

- **What**: 
- Apply widths from design to the buttons
- Add tests that measure the sizes of the buttons

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10681-fix-Ensure-all-save-save-as-buttons-are-the-same-width-3316d73d36508187bb74c5a977ea876f)
by [Unito](https://www.unito.io)
2026-03-31 02:47:27 -07:00
Dante
61049425a3 fix(DisplayCarousel): use back button in grid view and remove hover icons (#10655)
## Summary
- Grid view top-left icon changed from square to back arrow
(`arrow-left`) per Figma spec
- Back button is always visible in grid view (no longer
hover-dependent), uses sticky positioning
- Removed hover opacity effect on grid thumbnails

## Related
- Figma:
https://www.figma.com/design/vALUV83vIdBzEsTJAhQgXq/Comfy-Design-System?node-id=6008-83034&m=dev
- Figma:
https://www.figma.com/design/vALUV83vIdBzEsTJAhQgXq/Comfy-Design-System?node-id=6008-83069&m=dev

## Test plan
- [x] All 31 existing DisplayCarousel tests pass
- [ ] Visual check: grid view shows back arrow icon (top-left, always
visible)
- [ ] Visual check: hovering grid thumbnails shows no overlay icons
- [ ] Verify back button stays visible when scrolling through many grid
items

## Screenshot
### Before
<img width="492" height="364" alt="스크린샷 2026-03-28 오후 4 31 54"
src="https://github.com/user-attachments/assets/f9f36521-e993-45de-b692-59fba22a026d"
/>
<img width="457" height="400" alt="스크린샷 2026-03-28 오후 4 32 03"
src="https://github.com/user-attachments/assets/004f6380-8ad7-4167-b1f4-ebc4bdb559cc"
/>

### After
<img width="596" height="388" alt="스크린샷 2026-03-28 오후 4 31 43"
src="https://github.com/user-attachments/assets/e5585887-ad36-42e3-a6c0-e6eacb90dad7"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10655-fix-DisplayCarousel-use-back-button-in-grid-view-and-remove-hover-icons-3316d73d365081c5826afd63c50994ba)
by [Unito](https://www.unito.io)
2026-03-31 12:17:24 +09:00
Alexander Brown
661e3d7949 test: migrate as unknown as to @total-typescript/shoehorn (#10761)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Replace all `as unknown as Type` assertions in 59 unit test files with
type-safe `@total-typescript/shoehorn` functions
- Use `fromPartial<Type>()` for partial mock objects where deep-partial
type-checks (21 files)
- Use `fromAny<Type>()` for fundamentally incompatible types: null,
undefined, primitives, variables, class expressions, and mocks with
test-specific extra properties that `PartialDeepObject` rejects
(remaining files)
- All explicit type parameters preserved so TypeScript return types are
correct
- Browser test `.spec.ts` files excluded (shoehorn unavailable in
`page.evaluate` browser context)

## Verification

- `pnpm typecheck` 
- `pnpm lint` 
- `pnpm format` 
- Pre-commit hooks passed (format + oxlint + eslint + typecheck)
- Migrated test files verified passing (ran representative subset)
- No test behavior changes — only type assertion syntax changed
- No UI changes — screenshots not applicable

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10761-test-migrate-as-unknown-as-to-total-typescript-shoehorn-3336d73d365081f6b8adc44db5dcc380)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-30 19:20:18 -07:00
Alexander Brown
1624750a02 fix(test): fix bulk context menu test using correct Playwright patterns (#10762)
*PR Created by the Glary-Bot Agent*

---

## Summary

Fixes the `Bulk context menu shows when multiple assets selected` test
that is failing on main.

**Root cause — two issues:**

1. `click({ modifiers: ['ControlOrMeta'] })` does not fire `keydown`
events that VueUse's `useKeyModifier('Control')` tracks (used in
`useAssetSelection.ts`). Multi-select silently fails because the
composable never sees the Control key pressed. Fix: use
`keyboard.down('Control')` / `keyboard.up('Control')` around the click.

2. `click({ button: 'right' })` can be intercepted by canvas overlays
(documented gotcha in `browser_tests/AGENTS.md`). Fix: use
`dispatchEvent('contextmenu', { bubbles: true, cancelable: true })`
which bypasses overlay interception.

Also removed the `toPass()` retry wrapper since the root causes are now
addressed directly.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10762-fix-test-fix-bulk-context-menu-test-using-correct-Playwright-patterns-3346d73d3650811c843ee4a39d3ab305)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-03-30 18:38:25 -07:00
Comfy Org PR Bot
4cbf4994e9 1.43.11 (#10763)
Patch version increment to 1.43.11

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10763-1-43-11-3346d73d3650814f922fd9405cde85b1)
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>
2026-03-30 17:51:39 -07:00
Benjamin Lu
86a3938d11 test: add runtime-safe browser_tests alias (#10735)
## What changed

Added a runtime-safe `#e2e/*` alias for `browser_tests`, updated the
browser test docs, and migrated a representative fixture/spec import
path to the new convention.

## Why

`@/*` only covers `src/`, so browser test imports were falling back to
deep relative paths. `#e2e/*` resolves in both Node/Playwright runtime
and TypeScript.

## Validation

- `pnpm format`
- `pnpm typecheck:browser`
- `pnpm exec playwright test browser_tests/tests/actionbar.spec.ts
--list`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10735-test-add-runtime-safe-browser_tests-alias-3336d73d36508122b253cb36a4ead1c1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-30 19:24:09 +00:00
jaeone94
e11a1776ed fix: prevent saving active workflow content to inactive tab on close (#10745)
## Summary

- Closing an inactive workflow tab and clicking "Save" overwrites that
workflow with the **active** tab's content, causing permanent data loss
- `saveWorkflow()` and `saveWorkflowAs()` call `checkState()` which
serializes `app.rootGraph` (the active canvas) into the inactive
workflow's `changeTracker.activeState`
- Guard `checkState()` to only run when the workflow being saved is the
active one — in both `saveWorkflow` and `saveWorkflowAs`

## Linked Issues

- Fixes https://github.com/Comfy-Org/ComfyUI/issues/13230

## Root Cause

PR #9137 (commit `9fb93a5b0`, v1.41.7) added
`workflow.changeTracker?.checkState()` inside `saveWorkflow()` and
`saveWorkflowAs()`. `checkState()` always serializes `app.rootGraph` —
the graph on the canvas. When called on an inactive tab's change
tracker, it captures the active tab's data instead.

## Test plan

- [x] E2E: "Closing an inactive tab with save preserves its own content"
— persisted workflow B with added node, close while A is active, re-open
and verify
- [x] E2E: "Closing an inactive unsaved tab with save preserves its own
content" — temporary workflow B with added node, close while A is
active, save-as with filename, re-open and verify
- [x] Manual: open A and B, edit B, switch to A, close B tab, click
Save, re-open B — content should be B's not A's
2026-03-30 12:12:38 -07:00
Benjamin Lu
161522b138 chore: remove stale tests-ui config (#10736)
## What changed

Removed stale `tests-ui` configuration and documentation references from
the repo.

## Why

`tests-ui/` no longer exists, but the repo still carried:
- a dead `@tests-ui/*` tsconfig path
- stale `tests-ui/**/*` include
- a Vite watch ignore for a missing directory
- documentation examples that still referenced the old path

## Validation

- `pnpm format:check`
- `pnpm typecheck`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10736-chore-remove-stale-tests-ui-config-3336d73d3650814a98bedfc113b6eb9b)
by [Unito](https://www.unito.io)
2026-03-30 11:59:00 -07:00
Johnpaul Chiwetelu
61144ea1d5 test: add 23 E2E tests for Vue node context menu actions (#10603)
## Summary
- Add 23 Playwright E2E tests for all right-click context menu actions
on Vue nodes
- **Single node (7 tests)**: rename, copy/paste, duplicate, pin/unpin,
bypass/remove bypass, minimize/expand, convert to subgraph
- **Image node (4 tests)**: copy image to clipboard, paste image from
clipboard, open image in new tab, download via save image
- **Subgraph (3 tests)**: convert + unpack roundtrip, edit subgraph
widgets opens properties panel, add to library and find in node library
search
- **Multi-node (9 tests)**: batch rename, copy/paste, duplicate,
pin/unpin, bypass/remove bypass, minimize/expand, frame nodes, convert
to group node, convert to subgraph
- Uses `ControlOrMeta` modifier for multi-node selection

## Test plan
- [x] All 23 tests pass locally (`pnpm test:browser:local`)
- [x] TypeScript type check passes (`pnpm typecheck:browser`)
- [x] ESLint passes
- [x] CodeRabbit review: no findings

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10603-test-add-23-E2E-tests-for-Vue-node-context-menu-actions-3306d73d3650818a932fc62205ac6fa8)
by [Unito](https://www.unito.io)
2026-03-30 19:31:51 +01:00
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
175 changed files with 34063 additions and 1063 deletions

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

@@ -119,7 +119,7 @@ When writing new tests, follow these patterns:
```typescript
// Import the test fixture
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Feature Name', () => {
// Set up test environment if needed
@@ -148,6 +148,12 @@ Always check for existing helpers and fixtures before implementing new ones:
Most common testing needs are already addressed by these helpers, which will make your tests more consistent and reliable.
### Import Conventions
- Prefer `@e2e/*` for imports within `browser_tests/`
- Continue using `@/*` for imports from `src/`
- Avoid introducing new deep relative imports within `browser_tests/` when the alias is available
### Key Testing Patterns
1. **Focus elements explicitly**:

View File

@@ -0,0 +1,68 @@
{
"last_node_id": 12,
"last_link_id": 0,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["nonexistent_test_image_aaa.png", "image"]
},
{
"id": 11,
"type": "LoadImage",
"pos": [450, 50],
"size": [315, 314],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["nonexistent_test_image_bbb.png", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,42 @@
{
"last_node_id": 10,
"last_link_id": 0,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["nonexistent_test_image_12345.png", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -2,42 +2,41 @@ 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'
import { ComfyMouse } from './ComfyMouse'
import { VueNodeHelpers } from './VueNodeHelpers'
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
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 { ComfyActionbar } from '@e2e/helpers/actionbar'
import { ComfyTemplates } from '@e2e/helpers/templates'
import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
import { TestIds } from '@e2e/fixtures/selectors'
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { sleep } from '@e2e/fixtures/utils/timing'
import { VueNodeHelpers } from '@e2e/fixtures/VueNodeHelpers'
import { BottomPanel } from '@e2e/fixtures/components/BottomPanel'
import { ComfyNodeSearchBox } from '@e2e/fixtures/components/ComfyNodeSearchBox'
import { ComfyNodeSearchBoxV2 } from '@e2e/fixtures/components/ComfyNodeSearchBoxV2'
import { ContextMenu } from '@e2e/fixtures/components/ContextMenu'
import { QueuePanel } from '@e2e/fixtures/components/QueuePanel'
import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
import {
AssetsSidebarTab,
NodeLibrarySidebarTab,
WorkflowsSidebarTab
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
import { AssetsHelper } from './helpers/AssetsHelper'
import { CanvasHelper } from './helpers/CanvasHelper'
import { PerformanceHelper } from './helpers/PerformanceHelper'
import { QueueHelper } from './helpers/QueueHelper'
import { ClipboardHelper } from './helpers/ClipboardHelper'
import { CommandHelper } from './helpers/CommandHelper'
import { DragDropHelper } from './helpers/DragDropHelper'
import { FeatureFlagHelper } from './helpers/FeatureFlagHelper'
import { KeyboardHelper } from './helpers/KeyboardHelper'
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
import { SettingsHelper } from './helpers/SettingsHelper'
import { AppModeHelper } from './helpers/AppModeHelper'
import { SubgraphHelper } from './helpers/SubgraphHelper'
import { ToastHelper } from './helpers/ToastHelper'
import { WorkflowHelper } from './helpers/WorkflowHelper'
import { assetPath } from './utils/paths'
} from '@e2e/fixtures/components/SidebarTab'
import { Topbar } from '@e2e/fixtures/components/Topbar'
import { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
import { CommandHelper } from '@e2e/fixtures/helpers/CommandHelper'
import { DragDropHelper } from '@e2e/fixtures/helpers/DragDropHelper'
import { FeatureFlagHelper } from '@e2e/fixtures/helpers/FeatureFlagHelper'
import { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
import { NodeOperationsHelper } from '@e2e/fixtures/helpers/NodeOperationsHelper'
import { PerformanceHelper } from '@e2e/fixtures/helpers/PerformanceHelper'
import { SettingsHelper } from '@e2e/fixtures/helpers/SettingsHelper'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { ToastHelper } from '@e2e/fixtures/helpers/ToastHelper'
import { WorkflowHelper } from '@e2e/fixtures/helpers/WorkflowHelper'
import type { WorkspaceStore } from '../types/globals'
dotenvConfig()
@@ -121,16 +120,6 @@ type KeysOfType<T, Match> = {
[K in keyof T]: T[K] extends Match ? K : never
}[keyof T]
class MediaLightbox {
readonly root: Locator
readonly closeButton: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
this.closeButton = this.root.getByLabel('Close')
}
}
class ConfirmDialog {
public readonly root: Locator
public readonly delete: Locator
@@ -192,7 +181,6 @@ export class ComfyPage {
public readonly templates: ComfyTemplates
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly mediaLightbox: MediaLightbox
public readonly vueNodes: VueNodeHelpers
public readonly appMode: AppModeHelper
public readonly subgraph: SubgraphHelper
@@ -211,7 +199,6 @@ export class ComfyPage {
public readonly queuePanel: QueuePanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly queue: QueueHelper
/** Worker index to test user ID */
public readonly userIds: string[] = []
@@ -241,7 +228,6 @@ export class ComfyPage {
this.templates = new ComfyTemplates(page)
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.mediaLightbox = new MediaLightbox(page)
this.vueNodes = new VueNodeHelpers(page)
this.appMode = new AppModeHelper(this)
this.subgraph = new SubgraphHelper(this)
@@ -260,7 +246,6 @@ export class ComfyPage {
this.queuePanel = new QueuePanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.queue = new QueueHelper(page)
}
get visibleToasts() {

View File

@@ -1,14 +1,11 @@
import type { Locator, Page } from '@playwright/test'
export class ComfyNodeSearchFilterSelectionPanel {
readonly root: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
}
constructor(public readonly page: Page) {}
get header() {
return this.root
return this.page
.getByRole('dialog')
.locator('div')
.filter({ hasText: 'Add node filter condition' })
}

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

@@ -83,14 +83,6 @@ export class AppModeHelper {
return this.page.locator('[data-testid="linear-widgets"]')
}
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
get imagePickerPopover(): Locator {
return this.page
.getByRole('dialog')
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
.first()
}
/**
* Get the actions menu trigger for a widget in the app mode widget list.
* @param widgetName Text shown in the widget label (e.g. "seed").

View File

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

@@ -30,6 +30,10 @@ export class BuilderFooterHelper {
return this.page.getByTestId(TestIds.builder.saveButton)
}
get saveGroup(): Locator {
return this.page.getByTestId(TestIds.builder.saveGroup)
}
get saveAsButton(): Locator {
return this.page.getByTestId(TestIds.builder.saveAsButton)
}

View File

@@ -1,79 +0,0 @@
import type { Page, Route } from '@playwright/test'
export class QueueHelper {
private queueRouteHandler: ((route: Route) => void) | null = null
private historyRouteHandler: ((route: Route) => void) | null = null
constructor(private readonly page: Page) {}
/**
* Mock the /api/queue endpoint to return specific queue state.
*/
async mockQueueState(
running: number = 0,
pending: number = 0
): Promise<void> {
this.queueRouteHandler = (route: Route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
queue_running: Array.from({ length: running }, (_, i) => [
i,
`running-${i}`,
{},
{},
[]
]),
queue_pending: Array.from({ length: pending }, (_, i) => [
i,
`pending-${i}`,
{},
{},
[]
])
})
})
await this.page.route('**/api/queue', this.queueRouteHandler)
}
/**
* Mock the /api/history endpoint with completed/failed job entries.
*/
async mockHistory(
jobs: Array<{ promptId: string; status: 'success' | 'error' }>
): Promise<void> {
const history: Record<string, unknown> = {}
for (const job of jobs) {
history[job.promptId] = {
prompt: [0, job.promptId, {}, {}, []],
outputs: {},
status: {
status_str: job.status === 'success' ? 'success' : 'error',
completed: true
}
}
}
this.historyRouteHandler = (route: Route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(history)
})
await this.page.route('**/api/history**', this.historyRouteHandler)
}
/**
* Clear all route mocks set by this helper.
*/
async clearMocks(): Promise<void> {
if (this.queueRouteHandler) {
await this.page.unroute('**/api/queue', this.queueRouteHandler)
this.queueRouteHandler = null
}
if (this.historyRouteHandler) {
await this.page.unroute('**/api/history**', this.historyRouteHandler)
this.historyRouteHandler = null
}
}
}

View File

@@ -44,7 +44,15 @@ export const TestIds = {
about: 'about-panel',
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model'
missingModelsGroup: 'error-group-missing-model',
missingMediaGroup: 'error-group-missing-media',
missingMediaRow: 'missing-media-row',
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
missingMediaLibrarySelect: 'missing-media-library-select',
missingMediaStatusCard: 'missing-media-status-card',
missingMediaConfirmButton: 'missing-media-confirm-button',
missingMediaCancelButton: 'missing-media-cancel-button',
missingMediaLocateButton: 'missing-media-locate-button'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -61,6 +69,16 @@ export const TestIds = {
propertiesPanel: {
root: 'properties-panel'
},
subgraphEditor: {
toggle: 'subgraph-editor-toggle',
shownSection: 'subgraph-editor-shown-section',
hiddenSection: 'subgraph-editor-hidden-section',
widgetToggle: 'subgraph-widget-toggle',
widgetLabel: 'subgraph-widget-label',
iconLink: 'icon-link',
iconEye: 'icon-eye',
widgetActionsMenuButton: 'widget-actions-menu-button'
},
node: {
titleInput: 'node-title-input'
},
@@ -70,6 +88,9 @@ export const TestIds = {
colorBlue: 'blue',
colorRed: 'red'
},
menu: {
moreMenuContent: 'more-menu-content'
},
widgets: {
container: 'node-widgets',
widget: 'node-widget',
@@ -82,6 +103,7 @@ export const TestIds = {
footerNav: 'builder-footer-nav',
saveButton: 'builder-save-button',
saveAsButton: 'builder-save-as-button',
saveGroup: 'builder-save-group',
saveAsChevron: 'builder-save-as-chevron',
ioItem: 'builder-io-item',
ioItemTitle: 'builder-io-item-title',
@@ -130,5 +152,7 @@ export type TestIdValue =
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.menu)[keyof typeof TestIds.menu]
| (typeof TestIds.subgraphEditor)[keyof typeof TestIds.subgraphEditor]
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]

View File

@@ -0,0 +1,98 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
export interface SlotMeasurement {
key: string
offsetX: number
offsetY: number
}
export interface NodeSlotData {
nodeId: string
nodeW: number
nodeH: number
slots: SlotMeasurement[]
}
/**
* Collect slot center offsets relative to the parent node element.
* Returns `null` when the node element is not found.
*/
export async function measureNodeSlotOffsets(
page: Page,
nodeId: string
): Promise<NodeSlotData | null> {
return page.evaluate((id) => {
const nodeEl = document.querySelector(`[data-node-id="${id}"]`)
if (!nodeEl || !(nodeEl instanceof HTMLElement)) return null
const nodeRect = nodeEl.getBoundingClientRect()
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
const slots: SlotMeasurement[] = []
for (const slotEl of slotEls) {
const slotRect = slotEl.getBoundingClientRect()
slots.push({
key: (slotEl as HTMLElement).dataset.slotKey ?? 'unknown',
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
})
}
return {
nodeId: id,
nodeW: nodeRect.width,
nodeH: nodeRect.height,
slots
}
}, nodeId)
}
/**
* Assert that every slot falls within the node dimensions (± `margin` px).
*/
export function expectSlotsWithinBounds(
data: NodeSlotData,
margin: number,
label?: string
) {
const prefix = label ? `${label}: ` : ''
for (const slot of data.slots) {
expect(
slot.offsetX,
`${prefix}Slot ${slot.key} X=${slot.offsetX} outside width=${data.nodeW}`
).toBeGreaterThanOrEqual(-margin)
expect(
slot.offsetX,
`${prefix}Slot ${slot.key} X=${slot.offsetX} outside width=${data.nodeW}`
).toBeLessThanOrEqual(data.nodeW + margin)
expect(
slot.offsetY,
`${prefix}Slot ${slot.key} Y=${slot.offsetY} outside height=${data.nodeH}`
).toBeGreaterThanOrEqual(-margin)
expect(
slot.offsetY,
`${prefix}Slot ${slot.key} Y=${slot.offsetY} outside height=${data.nodeH}`
).toBeLessThanOrEqual(data.nodeH + margin)
}
}
/**
* Wait for slots, measure, and assert within bounds — single-node convenience.
*/
export async function assertNodeSlotsWithinBounds(
page: Page,
nodeId: string,
margin: number = 20
) {
await page
.locator(`[data-node-id="${nodeId}"] [data-slot-key]`)
.first()
.waitFor()
const data = await measureNodeSlotOffsets(page, nodeId)
expect(data, `Node ${nodeId} not found in DOM`).not.toBeNull()
expectSlotsWithinBounds(data!, margin, `Node ${nodeId}`)
}

View File

@@ -10,12 +10,10 @@ import { TestIds } from '../fixtures/selectors'
export class ComfyTemplates {
readonly content: Locator
readonly dialog: Locator
readonly allTemplateCards: Locator
constructor(readonly page: Page) {
this.content = page.getByTestId(TestIds.templates.content)
this.dialog = page.getByRole('dialog')
this.allTemplateCards = page.locator('[data-testid^="template-workflow-"]')
}

View File

@@ -2,8 +2,8 @@ import type { Response } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { StatusWsMessage } from '../../src/schemas/apiSchema'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { webSocketFixture } from '@e2e/fixtures/ws'
import type { WorkspaceStore } from '../types/globals'
const test = mergeTests(comfyPageFixture, webSocketFixture)

View File

@@ -145,7 +145,10 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
// The unstyled PrimeVue Popover renders with role="dialog".
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
const popover = comfyPage.appMode.imagePickerPopover
const popover = comfyPage.page
.getByRole('dialog')
.filter({ has: comfyPage.page.getByRole('button', { name: 'All' }) })
.first()
await expect(popover).toBeVisible({ timeout: 5000 })
const isInViewport = await popover.evaluate((el) => {

View File

@@ -189,6 +189,41 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await expect(saveAs.nameInput).toBeVisible()
})
test('Save button width is consistent across all states', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
// State 1: Disabled "Save as" (no outputs selected)
const disabledBox = await appMode.footer.saveAsButton.boundingBox()
expect(disabledBox).toBeTruthy()
// Select I/O to enable the button
await appMode.steps.goToInputs()
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await appMode.select.selectInputWidget(ksampler)
await appMode.steps.goToOutputs()
await appMode.select.selectOutputNode()
// State 2: Enabled "Save as" (unsaved, has outputs)
const enabledBox = await appMode.footer.saveAsButton.boundingBox()
expect(enabledBox).toBeTruthy()
expect(enabledBox!.width).toBe(disabledBox!.width)
// Save the workflow to transition to the Save + chevron state
await builderSaveAs(appMode, `${Date.now()} width-test`, 'App')
await appMode.saveAs.closeButton.click()
await comfyPage.nextFrame()
// State 3: Save + chevron button group (saved workflow)
const saveButtonGroupBox = await appMode.footer.saveGroup.boundingBox()
expect(saveButtonGroupBox).toBeTruthy()
expect(saveButtonGroupBox!.width).toBe(disabledBox!.width)
})
test('Connect output popover appears when no outputs selected', async ({
comfyPage
}) => {

View File

@@ -0,0 +1,68 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { assertNodeSlotsWithinBounds } from '../fixtures/utils/slotBoundsUtil'
const NODE_ID = '3'
const NODE_TITLE = 'KSampler'
test.describe(
'Collapsed node link positions',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.vueNodes.waitForNodes()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test('link endpoints stay within collapsed node bounds', async ({
comfyPage
}) => {
const node = await comfyPage.vueNodes.getFixtureByTitle(NODE_TITLE)
await node.toggleCollapse()
await comfyPage.nextFrame()
await assertNodeSlotsWithinBounds(comfyPage.page, NODE_ID)
})
test('links follow collapsed node after drag', async ({ comfyPage }) => {
const node = await comfyPage.vueNodes.getFixtureByTitle(NODE_TITLE)
await node.toggleCollapse()
await comfyPage.nextFrame()
const box = await node.boundingBox()
expect(box).not.toBeNull()
await comfyPage.page.mouse.move(
box!.x + box!.width / 2,
box!.y + box!.height / 2
)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(
box!.x + box!.width / 2 + 200,
box!.y + box!.height / 2 + 100,
{ steps: 10 }
)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
await assertNodeSlotsWithinBounds(comfyPage.page, NODE_ID)
})
test('links recover correct positions after expand', async ({
comfyPage
}) => {
const node = await comfyPage.vueNodes.getFixtureByTitle(NODE_TITLE)
await node.toggleCollapse()
await comfyPage.nextFrame()
await node.toggleCollapse()
await comfyPage.nextFrame()
await assertNodeSlotsWithinBounds(comfyPage.page, NODE_ID)
})
}
)

View File

@@ -18,7 +18,7 @@ test.describe('Confirm dialog text wrapping', { tag: ['@mobile'] }, () => {
.catch(() => {})
}, longFilename)
const dialog = comfyPage.confirmDialog.root
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
const confirmButton = dialog.getByRole('button', { name: 'Confirm' })

View File

@@ -0,0 +1,225 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
async function loadMissingMediaAndOpenErrorsTab(
comfyPage: ComfyPage,
workflow = 'missing/missing_media_single'
) {
await comfyPage.workflow.loadWorkflow(workflow)
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeVisible()
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(errorOverlay).not.toBeVisible()
}
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaUploadDropzone
)
const [fileChooser] = await Promise.all([
comfyPage.page.waitForEvent('filechooser'),
dropzone.click()
])
await fileChooser.setFiles(comfyPage.assetPath('test_upload_image.png'))
}
async function confirmPendingSelection(comfyPage: ComfyPage) {
const confirmButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaConfirmButton
)
await expect(confirmButton).toBeEnabled()
await confirmButton.click()
}
function getMediaRow(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaRow)
}
function getStatusCard(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaStatusCard)
}
function getDropzone(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
}
test.describe('Missing media inputs in Error Tab', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
test.describe('Detection', () => {
test('Shows error overlay when workflow has missing media inputs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
const messages = errorOverlay.getByTestId(
TestIds.dialogs.errorOverlayMessages
)
await expect(messages).toBeVisible()
await expect(messages).toHaveText(/missing required inputs/i)
})
test('Shows missing media group in errors tab after clicking See Errors', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).toBeVisible()
})
test('Shows correct number of missing media rows', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(
comfyPage,
'missing/missing_media_multiple'
)
await expect(getMediaRow(comfyPage)).toHaveCount(2)
})
test('Shows upload dropzone and library select for each missing item', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await expect(getDropzone(comfyPage)).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
).toBeVisible()
})
test('Does not show error overlay when all media inputs exist', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
})
})
test.describe('Upload flow (2-step confirm)', () => {
test('Upload via file picker shows status card then allows confirm', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Library select flow (2-step confirm)', () => {
test('Selecting from library shows status card then allows confirm', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
const librarySelect = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLibrarySelect
)
await librarySelect.getByRole('combobox').click()
const optionCount = await comfyPage.page.getByRole('option').count()
if (optionCount === 0) {
test.skip()
return
}
await comfyPage.page.getByRole('option').first().click()
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Cancel selection', () => {
test('Cancelling pending selection returns to upload/library UI', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await expect(getDropzone(comfyPage)).not.toBeVisible()
await comfyPage.page
.getByTestId(TestIds.dialogs.missingMediaCancelButton)
.click()
await expect(getStatusCard(comfyPage)).not.toBeVisible()
await expect(getDropzone(comfyPage)).toBeVisible()
})
})
test.describe('All resolved', () => {
test('Missing Inputs group disappears when all items are resolved', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await uploadFileViaDropzone(comfyPage)
await confirmPendingSelection(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).not.toBeVisible()
})
})
test.describe('Locate node', () => {
test('Locate button navigates canvas to the missing media node', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
const offsetBefore = await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
const locateButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLocateButton
)
await expect(locateButton).toBeVisible()
await locateButton.click()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
})
.not.toEqual(offsetBefore)
})
})
})

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { assetPath } from '../fixtures/utils/paths'
import { logMeasurement, recordMeasurement } from '../helpers/perfReporter'
test.describe('Performance', { tag: ['@perf'] }, () => {
@@ -348,6 +349,63 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
})
})
test.describe('workflow loading', () => {
test('large workflow load and settle', async ({ comfyPage }) => {
await comfyPage.perf.startMeasuring()
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
// Settling period: 120 frames (~2s) for DOM creation + layout stabilization
for (let i = 0; i < 120; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('large-workflow-load')
recordMeasurement(m)
logMeasurement('Large workflow load', m, [
'durationMs',
'styleRecalcs',
'layouts',
'taskDurationMs',
'totalBlockingTimeMs',
'domNodes',
'heapDeltaBytes'
])
})
test('media-heavy workflow load and settle', async ({ comfyPage }) => {
// Intercept all media preview requests with a small test image
await comfyPage.page.route(/\/api\/view\?/, (route) =>
route.fulfill({
path: assetPath('image64x64.webp'),
contentType: 'image/webp'
})
)
await comfyPage.perf.startMeasuring()
await comfyPage.workflow.loadWorkflow('profiling-workflow-media-heavy')
// Longer settling: 240 frames (~4s) for 585 nodes + 128 media previews
for (let i = 0; i < 240; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('media-heavy-workflow-load')
recordMeasurement(m)
logMeasurement('Media-heavy workflow load', m, [
'durationMs',
'styleRecalcs',
'layouts',
'taskDurationMs',
'totalBlockingTimeMs',
'frameDurationMs',
'domNodes',
'heapDeltaBytes'
])
})
})
test('workflow execution', async ({ comfyPage }) => {
// Uses lightweight PrimitiveString → PreviewAny workflow (no GPU needed)
await comfyPage.workflow.loadWorkflow('execution/partial_execution')

View File

@@ -41,7 +41,7 @@ test.describe('MediaLightbox', { tag: ['@slow'] }, () => {
await assetCard.hover()
await assetCard.getByLabel('Zoom in').click()
const gallery = comfyPage.mediaLightbox.root
const gallery = comfyPage.page.getByRole('dialog')
await expect(gallery).toBeVisible()
return { gallery }
@@ -58,13 +58,13 @@ test.describe('MediaLightbox', { tag: ['@slow'] }, () => {
await runAndOpenGallery(comfyPage)
await comfyPage.page.keyboard.press('Escape')
await expect(comfyPage.mediaLightbox.root).not.toBeVisible()
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
})
test('closes gallery when clicking close button', async ({ comfyPage }) => {
const { gallery } = await runAndOpenGallery(comfyPage)
await gallery.getByLabel('Close').click()
await expect(comfyPage.mediaLightbox.root).not.toBeVisible()
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
})
})

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,594 @@ 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: use keyboard.down/up so useKeyModifier('Control') detects
// the modifier — click({ modifiers }) only sets the mouse event flag and
// does not fire a keydown event that VueUse tracks.
await cards.first().click()
await comfyPage.page.keyboard.down('Control')
await cards.nth(1).click()
await comfyPage.page.keyboard.up('Control')
// 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 })
// Use dispatchEvent instead of click({ button: 'right' }) to avoid any
// overlay intercepting the event, and assert directly without toPass.
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await cards.first().dispatchEvent('contextmenu', {
bubbles: true,
cancelable: true,
button: 2
})
await expect(contextMenu).toBeVisible()
// 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

@@ -7,6 +7,10 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
import { comfyPageFixture as test, comfyExpect } from '../../fixtures/ComfyPage'
import { SubgraphHelper } from '../../fixtures/helpers/SubgraphHelper'
import {
expectSlotsWithinBounds,
measureNodeSlotOffsets
} from '../../fixtures/utils/slotBoundsUtil'
// Constants
const RENAMED_INPUT_NAME = 'renamed_input'
@@ -19,20 +23,6 @@ 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')
@@ -604,71 +594,19 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
)
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[] = []
const nodeIds = await comfyPage.page.evaluate(() =>
window
.app!.graph._nodes.filter((n) => !!n.isSubgraphNode?.())
.map((n) => String(n.id))
)
expect(nodeIds.length).toBeGreaterThan(0)
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)
}
for (const nodeId of nodeIds) {
const data = await measureNodeSlotOffsets(comfyPage.page, nodeId)
expect(data, `Node ${nodeId} not found in DOM`).not.toBeNull()
expectSlotsWithinBounds(data!, SLOT_BOUNDS_MARGIN, `Node ${nodeId}`)
}
})
})

View File

@@ -0,0 +1,148 @@
import type { Locator } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
async function ensurePropertiesPanel(comfyPage: ComfyPage) {
const panel = comfyPage.menu.propertiesPanel.root
if (!(await panel.isVisible())) {
await comfyPage.actionbar.propertiesButton.click()
}
await expect(panel).toBeVisible()
return panel
}
async function selectSubgraphAndOpenEditor(
comfyPage: ComfyPage,
nodeTitle: string
) {
const subgraphNodes = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
expect(subgraphNodes.length).toBeGreaterThan(0)
await subgraphNodes[0].click('title')
await ensurePropertiesPanel(comfyPage)
const editorToggle = comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle)
await expect(editorToggle).toBeVisible()
await editorToggle.click()
const shownSection = comfyPage.page.getByTestId(
TestIds.subgraphEditor.shownSection
)
await expect(shownSection).toBeVisible()
return shownSection
}
async function collectWidgetLabels(shownSection: Locator) {
const labels = shownSection.getByTestId(TestIds.subgraphEditor.widgetLabel)
const texts = await labels.allTextContents()
return texts.map((t) => t.trim())
}
test.describe(
'Subgraph promoted widget panel',
{ tag: ['@node', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test.describe('SubgraphEditor (Settings panel)', () => {
test('linked promoted widgets have hide toggle disabled', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
const shownSection = await selectSubgraphAndOpenEditor(
comfyPage,
'Sub 0'
)
const toggleButtons = shownSection.getByTestId(
TestIds.subgraphEditor.widgetToggle
)
await expect(toggleButtons.first()).toBeVisible()
const count = await toggleButtons.count()
for (let i = 0; i < count; i++) {
await expect(toggleButtons.nth(i)).toBeDisabled()
}
})
test('linked promoted widgets show link icon instead of eye icon', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
const shownSection = await selectSubgraphAndOpenEditor(
comfyPage,
'Sub 0'
)
const linkIcons = shownSection.getByTestId(
TestIds.subgraphEditor.iconLink
)
await expect(linkIcons.first()).toBeVisible()
const eyeIcons = shownSection.getByTestId(
TestIds.subgraphEditor.iconEye
)
await expect(eyeIcons).toHaveCount(0)
})
test('widget labels display renamed values instead of raw names', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/test-values-input-subgraph'
)
const shownSection = await selectSubgraphAndOpenEditor(
comfyPage,
'Input Test Subgraph'
)
const allTexts = await collectWidgetLabels(shownSection)
expect(allTexts.length).toBeGreaterThan(0)
// The fixture has a widget with name="text" but
// label="renamed_from_sidepanel". The panel should show the
// renamed label, not the raw widget name.
expect(allTexts).toContain('renamed_from_sidepanel')
expect(allTexts).not.toContain('text')
})
})
test.describe('Parameters tab (WidgetActions menu)', () => {
test('linked promoted widget menu should not show Hide/Show input', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('Sub 0')
expect(subgraphNodes.length).toBeGreaterThan(0)
await subgraphNodes[0].click('title')
const panel = await ensurePropertiesPanel(comfyPage)
const moreButtons = panel.getByTestId(
TestIds.subgraphEditor.widgetActionsMenuButton
)
await expect(moreButtons.first()).toBeVisible()
await moreButtons.first().click()
const menu = comfyPage.page.getByTestId(TestIds.menu.moreMenuContent)
await expect(menu).toBeVisible()
await expect(menu.getByText('Hide input')).toHaveCount(0)
await expect(menu.getByText('Show input')).toHaveCount(0)
await expect(menu.getByText('Rename')).toBeVisible()
})
})
}
)

View File

@@ -116,7 +116,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
const dialog = comfyPage.templates.dialog.filter({
const dialog = comfyPage.page.getByRole('dialog').filter({
has: comfyPage.page.getByRole('heading', { name: 'Modèles', exact: true })
})
await expect(dialog).toBeVisible()
@@ -220,9 +220,8 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(comfyPage.templates.content).toBeVisible()
// Wait for filter bar select components to render
const sortBySelect = comfyPage.templates.dialog.getByRole('combobox', {
name: /Sort/
})
const dialog = comfyPage.page.getByRole('dialog')
const sortBySelect = dialog.getByRole('combobox', { name: /Sort/ })
await expect(sortBySelect).toBeVisible()
// Screenshot the filter bar containing MultiSelect and SingleSelect

View File

@@ -0,0 +1,528 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
const BYPASS_CLASS = /before:bg-bypass\/60/
const PIN_INDICATOR = '[data-testid="node-pin-indicator"]'
async function clickExactMenuItem(comfyPage: ComfyPage, name: string) {
await comfyPage.page.getByRole('menuitem', { name, exact: true }).click()
await comfyPage.nextFrame()
}
async function openContextMenu(comfyPage: ComfyPage, nodeTitle: string) {
const header = comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.locator('.lg-node-header')
await header.click()
await header.click({ button: 'right' })
const menu = comfyPage.page.locator('.p-contextmenu')
await menu.waitFor({ state: 'visible' })
return menu
}
async function openMultiNodeContextMenu(
comfyPage: ComfyPage,
titles: string[]
) {
// deselectAll via evaluate — clearSelection() clicks at a fixed position
// which can hit nodes or the toolbar overlay
await comfyPage.page.evaluate(() => window.app!.canvas.deselectAll())
await comfyPage.nextFrame()
for (const title of titles) {
const header = comfyPage.vueNodes
.getNodeByTitle(title)
.locator('.lg-node-header')
await header.click({ modifiers: ['ControlOrMeta'] })
}
await comfyPage.nextFrame()
const firstHeader = comfyPage.vueNodes
.getNodeByTitle(titles[0])
.locator('.lg-node-header')
const box = await firstHeader.boundingBox()
if (!box) throw new Error(`Header for "${titles[0]}" not found`)
await comfyPage.page.mouse.click(
box.x + box.width / 2,
box.y + box.height / 2,
{ button: 'right' }
)
const menu = comfyPage.page.locator('.p-contextmenu')
await menu.waitFor({ state: 'visible' })
return menu
}
function getNodeWrapper(comfyPage: ComfyPage, nodeTitle: string): Locator {
return comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: nodeTitle })
.getByTestId('node-inner-wrapper')
}
async function getNodeRef(comfyPage: ComfyPage, nodeTitle: string) {
const refs = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
return refs[0]
}
test.describe('Vue Node Context Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.vueNodes.waitForNodes()
})
test.describe('Single Node Actions', () => {
test('should rename node via context menu', async ({ comfyPage }) => {
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Rename')
const titleInput = comfyPage.page.locator(
'.node-title-editor input[type="text"]'
)
await titleInput.waitFor({ state: 'visible' })
await titleInput.fill('My Renamed Sampler')
await titleInput.press('Enter')
await comfyPage.nextFrame()
const renamedNode =
comfyPage.vueNodes.getNodeByTitle('My Renamed Sampler')
await expect(renamedNode).toBeVisible()
})
test('should copy and paste node via context menu', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openContextMenu(comfyPage, 'Load Checkpoint')
await clickExactMenuItem(comfyPage, 'Copy')
// Internal clipboard paste (menu Copy uses canvas clipboard, not OS)
await comfyPage.page.evaluate(() => {
window.app!.canvas.pasteFromClipboard({ connectInputs: false })
})
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + 1
)
})
test('should duplicate node via context menu', async ({ comfyPage }) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openContextMenu(comfyPage, 'Load Checkpoint')
await clickExactMenuItem(comfyPage, 'Duplicate')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + 1
)
})
test('should pin and unpin node via context menu', async ({
comfyPage
}) => {
const nodeTitle = 'Load Checkpoint'
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
// Pin via context menu
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Pin')
const pinIndicator = comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.locator(PIN_INDICATOR)
await expect(pinIndicator).toBeVisible()
expect(await nodeRef.isPinned()).toBe(true)
// Verify drag blocked
const header = comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.locator('.lg-node-header')
const posBeforeDrag = await header.boundingBox()
if (!posBeforeDrag) throw new Error('Header not found')
await comfyPage.canvasOps.dragAndDrop(
{ x: posBeforeDrag.x + 10, y: posBeforeDrag.y + 10 },
{ x: posBeforeDrag.x + 256, y: posBeforeDrag.y + 256 }
)
const posAfterDrag = await header.boundingBox()
expect(posAfterDrag).toEqual(posBeforeDrag)
// Unpin via context menu
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Unpin')
await expect(pinIndicator).not.toBeVisible()
expect(await nodeRef.isPinned()).toBe(false)
})
test('should bypass node and remove bypass via context menu', async ({
comfyPage
}) => {
const nodeTitle = 'Load Checkpoint'
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Bypass')
expect(await nodeRef.isBypassed()).toBe(true)
await expect(getNodeWrapper(comfyPage, nodeTitle)).toHaveClass(
BYPASS_CLASS
)
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Remove Bypass')
expect(await nodeRef.isBypassed()).toBe(false)
await expect(getNodeWrapper(comfyPage, nodeTitle)).not.toHaveClass(
BYPASS_CLASS
)
})
test('should minimize and expand node via context menu', async ({
comfyPage
}) => {
const fixture = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(fixture.body).toBeVisible()
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Minimize Node')
await expect(fixture.body).not.toBeVisible()
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Expand Node')
await expect(fixture.body).toBeVisible()
})
test('should convert node to subgraph via context menu', async ({
comfyPage
}) => {
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode).toBeVisible()
await expect(
comfyPage.vueNodes.getNodeByTitle('KSampler')
).not.toBeVisible()
})
})
test.describe('Image Node Actions', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page
.context()
.grantPermissions(['clipboard-read', 'clipboard-write'])
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes(1)
})
test('should copy image to clipboard via context menu', async ({
comfyPage
}) => {
await openContextMenu(comfyPage, 'Load Image')
await clickExactMenuItem(comfyPage, 'Copy Image')
// Verify the clipboard contains an image
const hasImage = await comfyPage.page.evaluate(async () => {
const items = await navigator.clipboard.read()
return items.some((item) =>
item.types.some((t) => t.startsWith('image/'))
)
})
expect(hasImage).toBe(true)
})
test('should paste image to LoadImage node via context menu', async ({
comfyPage
}) => {
// Capture the original image src from the node's preview
const imagePreview = comfyPage.page.locator('.image-preview img')
const originalSrc = await imagePreview.getAttribute('src')
// Write a test image into the browser clipboard
await comfyPage.page.evaluate(async () => {
const resp = await fetch('/api/view?filename=example.png&type=input')
const blob = await resp.blob()
await navigator.clipboard.write([
new ClipboardItem({ [blob.type]: blob })
])
})
// Right-click and select Paste Image
await openContextMenu(comfyPage, 'Load Image')
await clickExactMenuItem(comfyPage, 'Paste Image')
// Verify the image preview src changed
await expect(imagePreview).not.toHaveAttribute('src', originalSrc!)
})
test('should open image in new tab via context menu', async ({
comfyPage
}) => {
await openContextMenu(comfyPage, 'Load Image')
const popupPromise = comfyPage.page.waitForEvent('popup')
await clickExactMenuItem(comfyPage, 'Open Image')
const popup = await popupPromise
expect(popup.url()).toContain('/api/view')
expect(popup.url()).toContain('filename=')
await popup.close()
})
test('should download image via Save Image context menu', async ({
comfyPage
}) => {
await openContextMenu(comfyPage, 'Load Image')
const downloadPromise = comfyPage.page.waitForEvent('download')
await clickExactMenuItem(comfyPage, 'Save Image')
const download = await downloadPromise
expect(download.suggestedFilename()).toBeTruthy()
})
})
test.describe('Subgraph Actions', () => {
test('should convert to subgraph and unpack back', async ({
comfyPage
}) => {
// Convert KSampler to subgraph
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode).toBeVisible()
await expect(
comfyPage.vueNodes.getNodeByTitle('KSampler')
).not.toBeVisible()
// Unpack the subgraph
await openContextMenu(comfyPage, 'New Subgraph')
await clickExactMenuItem(comfyPage, 'Unpack Subgraph')
await expect(comfyPage.vueNodes.getNodeByTitle('KSampler')).toBeVisible()
await expect(
comfyPage.vueNodes.getNodeByTitle('New Subgraph')
).not.toBeVisible()
})
test('should open properties panel via Edit Subgraph Widgets', async ({
comfyPage
}) => {
// Convert to subgraph first
await openContextMenu(comfyPage, 'Empty Latent Image')
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
await comfyPage.nextFrame()
// Right-click subgraph and edit widgets
await openContextMenu(comfyPage, 'New Subgraph')
await clickExactMenuItem(comfyPage, 'Edit Subgraph Widgets')
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
test('should add subgraph to library and find in node library', async ({
comfyPage
}) => {
// Convert to subgraph first
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
await comfyPage.nextFrame()
// Add to library
await openContextMenu(comfyPage, 'New Subgraph')
await clickExactMenuItem(comfyPage, 'Add Subgraph to Library')
// Fill the blueprint name
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
await comfyPage.nodeOps.fillPromptDialog('TestBlueprint')
// Open node library sidebar and search for the blueprint
await comfyPage.page.getByRole('button', { name: 'Node Library' }).click()
await comfyPage.nextFrame()
const searchBox = comfyPage.page.getByRole('combobox', {
name: 'Search'
})
await searchBox.waitFor({ state: 'visible' })
await searchBox.fill('TestBlueprint')
await comfyPage.nextFrame()
await expect(comfyPage.page.getByText('TestBlueprint')).toBeVisible()
})
})
test.describe('Multi-Node Actions', () => {
const nodeTitles = ['Load Checkpoint', 'KSampler']
test('should batch rename selected nodes via context menu', async ({
comfyPage
}) => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Rename')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
await comfyPage.nodeOps.fillPromptDialog('MyNode')
await expect(comfyPage.vueNodes.getNodeByTitle('MyNode 1')).toBeVisible()
await expect(comfyPage.vueNodes.getNodeByTitle('MyNode 2')).toBeVisible()
})
test('should copy and paste selected nodes via context menu', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Copy')
await comfyPage.page.evaluate(() => {
window.app!.canvas.pasteFromClipboard({ connectInputs: false })
})
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + nodeTitles.length
)
})
test('should duplicate selected nodes via context menu', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Duplicate')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + nodeTitles.length
)
})
test('should pin and unpin selected nodes via context menu', async ({
comfyPage
}) => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Pin')
for (const title of nodeTitles) {
const pinIndicator = comfyPage.vueNodes
.getNodeByTitle(title)
.locator(PIN_INDICATOR)
await expect(pinIndicator).toBeVisible()
}
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Unpin')
for (const title of nodeTitles) {
const pinIndicator = comfyPage.vueNodes
.getNodeByTitle(title)
.locator(PIN_INDICATOR)
await expect(pinIndicator).not.toBeVisible()
}
})
test('should bypass and remove bypass on selected nodes via context menu', async ({
comfyPage
}) => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Bypass')
for (const title of nodeTitles) {
const nodeRef = await getNodeRef(comfyPage, title)
expect(await nodeRef.isBypassed()).toBe(true)
await expect(getNodeWrapper(comfyPage, title)).toHaveClass(BYPASS_CLASS)
}
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Remove Bypass')
for (const title of nodeTitles) {
const nodeRef = await getNodeRef(comfyPage, title)
expect(await nodeRef.isBypassed()).toBe(false)
await expect(getNodeWrapper(comfyPage, title)).not.toHaveClass(
BYPASS_CLASS
)
}
})
test('should minimize and expand selected nodes via context menu', async ({
comfyPage
}) => {
const fixture1 =
await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
const fixture2 = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(fixture1.body).toBeVisible()
await expect(fixture2.body).toBeVisible()
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Minimize Node')
await expect(fixture1.body).not.toBeVisible()
await expect(fixture2.body).not.toBeVisible()
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Expand Node')
await expect(fixture1.body).toBeVisible()
await expect(fixture2.body).toBeVisible()
})
test('should frame selected nodes via context menu', async ({
comfyPage
}) => {
const initialGroupCount = await comfyPage.page.evaluate(
() => window.app!.graph.groups.length
)
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Frame Nodes')
const newGroupCount = await comfyPage.page.evaluate(
() => window.app!.graph.groups.length
)
expect(newGroupCount).toBe(initialGroupCount + 1)
})
test('should convert to group node via context menu', async ({
comfyPage
}) => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Convert to Group Node')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
await comfyPage.nodeOps.fillPromptDialog('TestGroupNode')
const groupNodes = await comfyPage.nodeOps.getNodeRefsByType(
'workflow>TestGroupNode'
)
expect(groupNodes.length).toBe(1)
})
test('should convert selected nodes to subgraph via context menu', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode).toBeVisible()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount - nodeTitles.length + 1
)
})
})
})

View File

@@ -323,6 +323,174 @@ test.describe('Workflow Persistence', () => {
expect(linkCountAfter).toBe(linkCountBefore)
})
test('Closing an inactive tab with save preserves its own content', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description:
'PR #10745 — saveWorkflow called checkState on inactive tab, serializing the active graph instead'
})
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
const suffix = Date.now().toString(36)
const nameA = `test-A-${suffix}`
const nameB = `test-B-${suffix}`
// Save the default workflow as A
await comfyPage.menu.topbar.saveWorkflow(nameA)
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
// Create B: duplicate and save
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
await comfyPage.nextFrame()
await comfyPage.menu.topbar.saveWorkflow(nameB)
// Add a Note node in B to mark it as modified
await comfyPage.page.evaluate(() => {
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
})
await comfyPage.nextFrame()
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
expect(nodeCountB).toBe(nodeCountA + 1)
// Trigger checkState so isModified is set
await comfyPage.page.evaluate(() => {
const em = window.app!.extensionManager as unknown as Record<
string,
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
>
em.workflow?.activeWorkflow?.changeTracker?.checkState()
})
// Switch to A via topbar tab (making B inactive)
await comfyPage.menu.topbar.getWorkflowTab(nameA).click()
await comfyPage.workflow.waitForWorkflowIdle()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
.toBe(nodeCountA)
// Close inactive B tab via middle-click — triggers "Save before closing?"
await comfyPage.menu.topbar.getWorkflowTab(nameB).click({
button: 'middle'
})
// Click "Save" in the dirty close dialog
const saveButton = comfyPage.page.getByRole('button', { name: 'Save' })
await saveButton.waitFor({ state: 'visible' })
await saveButton.click()
await comfyPage.workflow.waitForWorkflowIdle()
await comfyPage.nextFrame()
// Verify we're still on A with A's content
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
.toBe(nodeCountA)
// Re-open B from sidebar saved list
const workflowsTab = comfyPage.menu.workflowsTab
await workflowsTab.open()
await workflowsTab.getPersistedItem(nameB).dblclick()
await comfyPage.workflow.waitForWorkflowIdle()
// B should have the extra Note node we added, not A's node count
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
.toBe(nodeCountB)
})
test('Closing an inactive unsaved tab with save preserves its own content', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description:
'PR #10745 — saveWorkflowAs called checkState on inactive temp tab, serializing the active graph'
})
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
const suffix = Date.now().toString(36)
const nameA = `test-A-${suffix}`
const nameB = `test-B-${suffix}`
// Save the default workflow as A
await comfyPage.menu.topbar.saveWorkflow(nameA)
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
// Create B as an unsaved workflow with a Note node
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
})
await comfyPage.nextFrame()
// Trigger checkState so isModified is set
await comfyPage.page.evaluate(() => {
const em = window.app!.extensionManager as unknown as Record<
string,
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
>
em.workflow?.activeWorkflow?.changeTracker?.checkState()
})
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
expect(nodeCountB).toBe(1)
expect(nodeCountA).not.toBe(nodeCountB)
// Switch to A via topbar tab (making unsaved B inactive)
await comfyPage.menu.topbar.getWorkflowTab(nameA).click()
await comfyPage.workflow.waitForWorkflowIdle()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
.toBe(nodeCountA)
// Close inactive unsaved B tab — triggers "Save before closing?"
await comfyPage.menu.topbar
.getWorkflowTab('Unsaved Workflow')
.click({ button: 'middle' })
// Click "Save" in the dirty close dialog (scoped to dialog)
const dialog = comfyPage.page.getByRole('dialog')
const saveButton = dialog.getByRole('button', { name: 'Save' })
await saveButton.waitFor({ state: 'visible' })
await saveButton.click()
// Fill in the filename dialog
const saveDialog = comfyPage.menu.topbar.getSaveDialog()
await saveDialog.waitFor({ state: 'visible' })
await saveDialog.fill(nameB)
await comfyPage.page.keyboard.press('Enter')
await comfyPage.workflow.waitForWorkflowIdle()
await comfyPage.nextFrame()
// Verify we're still on A with A's content
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
.toBe(nodeCountA)
// Re-open B from sidebar saved list
const workflowsTab = comfyPage.menu.workflowsTab
await workflowsTab.open()
await workflowsTab.getPersistedItem(nameB).dblclick()
await comfyPage.workflow.waitForWorkflowIdle()
// B should have 1 node (the Note), not A's node count
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
.toBe(nodeCountB)
})
test('Splitter panel sizes persist correctly in localStorage', async ({
comfyPage
}) => {

View File

@@ -363,7 +363,7 @@ Test your feature flags with different combinations:
### Example Test
```typescript
// In tests-ui/tests/api.featureFlags.test.ts
// Example from a colocated unit test
it('should handle preview metadata based on feature flag', () => {
// Mock server supports feature
api.serverFeatureFlags = { supports_preview_metadata: true }

View File

@@ -17,7 +17,7 @@ This guide covers patterns and examples for testing Pinia stores in the ComfyUI
Basic setup for testing Pinia stores:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
// Example from a colocated store unit test
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -51,7 +51,7 @@ describe('useWorkflowStore', () => {
Testing store state changes:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
// Example from a colocated store unit test
it('should create a temporary workflow with a unique path', () => {
const workflow = store.createTemporary()
expect(workflow.path).toBe('workflows/Unsaved Workflow.json')
@@ -72,7 +72,7 @@ it('should create a temporary workflow not clashing with persisted workflows', a
Testing store actions:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
// Example from a colocated store unit test
describe('openWorkflow', () => {
it('should load and open a temporary workflow', async () => {
// Create a test workflow
@@ -115,7 +115,7 @@ describe('openWorkflow', () => {
Testing store getters:
```typescript
// Example from: tests-ui/tests/store/modelStore.test.ts
// Example from a colocated store unit test
describe('getters', () => {
beforeEach(() => {
setActivePinia(createPinia())
@@ -162,7 +162,7 @@ describe('getters', () => {
Mocking API and other dependencies:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
// Example from a colocated store unit test
// Add mock for api at the top of the file
vi.mock('@/scripts/api', () => ({
api: {
@@ -205,7 +205,7 @@ describe('syncWorkflows', () => {
Testing store watchers and reactive behavior:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
// Example from a colocated store unit test
import { nextTick } from 'vue'
describe('Subgraphs', () => {
@@ -253,7 +253,7 @@ describe('Subgraphs', () => {
Testing store integration with other parts of the application:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
// Example from a colocated store unit test
describe('renameWorkflow', () => {
it('should rename workflow and update bookmarks', async () => {
const workflow = store.createTemporary('dir/test.json')

View File

@@ -18,7 +18,7 @@ This guide covers patterns and examples for unit testing utilities, composables,
Testing Vue composables requires handling reactivity correctly:
```typescript
// Example from: tests-ui/tests/composables/useServerLogs.test.ts
// Example from a colocated composable unit test
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useServerLogs } from '@/composables/useServerLogs'
@@ -59,7 +59,7 @@ describe('useServerLogs', () => {
Testing LiteGraph-related functionality:
```typescript
// Example from: tests-ui/tests/litegraph.test.ts
// Example from a colocated LiteGraph unit test
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph'
import { describe, expect, it } from 'vitest'
@@ -93,7 +93,7 @@ describe('LGraph', () => {
Testing with ComfyUI workflow files:
```typescript
// Example from: tests-ui/tests/comfyWorkflow.test.ts
// Example from a colocated workflow unit test
import { describe, expect, it } from 'vitest'
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { defaultGraph } from '@/scripts/defaultGraph'
@@ -125,7 +125,7 @@ describe('workflow validation', () => {
Mocking the ComfyUI API object:
```typescript
// Example from: tests-ui/tests/composables/useServerLogs.test.ts
// Example from a colocated composable unit test
import { describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
@@ -183,7 +183,7 @@ describe('Function using debounce', () => {
When you need to test real debounce/throttle behavior:
```typescript
// Example from: tests-ui/tests/composables/useWorkflowAutoSave.test.ts
// Example from a colocated composable unit test
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('debounced function', () => {
@@ -223,7 +223,7 @@ describe('debounced function', () => {
Creating mock node definitions for testing:
```typescript
// Example from: tests-ui/tests/apiTypes.test.ts
// Example from a colocated schema unit test
import { describe, expect, it } from 'vitest'
import {
type ComfyNodeDef,

View File

@@ -230,15 +230,6 @@ export default defineConfig([
]
}
},
{
files: ['tests-ui/**/*'],
rules: {
'@typescript-eslint/consistent-type-imports': [
'error',
{ disallowTypeAnnotations: false }
]
}
},
{
files: ['**/*.spec.ts'],
ignores: ['browser_tests/tests/**/*.spec.ts'],

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.43.9",
"version": "1.43.11",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -140,6 +140,7 @@
"@testing-library/jest-dom": "catalog:",
"@testing-library/user-event": "catalog:",
"@testing-library/vue": "catalog:",
"@total-typescript/shoehorn": "catalog:",
"@types/fs-extra": "catalog:",
"@types/jsdom": "catalog:",
"@types/node": "catalog:",

11
pnpm-lock.yaml generated
View File

@@ -135,6 +135,9 @@ catalogs:
'@tiptap/starter-kit':
specifier: ^2.27.2
version: 2.27.2
'@total-typescript/shoehorn':
specifier: ^0.1.2
version: 0.1.2
'@types/fs-extra':
specifier: ^11.0.4
version: 11.0.4
@@ -651,6 +654,9 @@ importers:
'@testing-library/vue':
specifier: 'catalog:'
version: 8.1.0(@vue/compiler-sfc@3.5.28)(vue@3.5.13(typescript@5.9.3))
'@total-typescript/shoehorn':
specifier: 'catalog:'
version: 0.1.2
'@types/fs-extra':
specifier: 'catalog:'
version: 11.0.4
@@ -4274,6 +4280,9 @@ packages:
'@tmcp/auth':
optional: true
'@total-typescript/shoehorn@0.1.2':
resolution: {integrity: sha512-p7nNZbOZIofpDNyP0u1BctFbjxD44Qc+oO5jufgQdFdGIXJLc33QRloJpq7k5T59CTgLWfQSUxsuqLcmeurYRw==}
'@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
@@ -13308,6 +13317,8 @@ snapshots:
esm-env: 1.2.2
tmcp: 1.19.0(typescript@5.9.3)
'@total-typescript/shoehorn@0.1.2': {}
'@tweenjs/tween.js@23.1.3': {}
'@tybys/wasm-util@0.10.1':

View File

@@ -46,6 +46,7 @@ catalog:
'@tiptap/extension-table-row': ^2.27.2
'@tiptap/pm': 2.27.2
'@tiptap/starter-kit': ^2.27.2
'@total-typescript/shoehorn': ^0.1.2
'@types/fs-extra': ^11.0.4
'@types/jsdom': ^21.1.7
'@types/node': ^24.1.0

View File

@@ -1,3 +1,4 @@
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
@@ -43,12 +44,12 @@ describe('downloadUtil', () => {
createObjectURLSpy.mockClear().mockReturnValue('blob:mock-url')
revokeObjectURLSpy.mockClear().mockImplementation(() => {})
// Create a mock anchor element
mockLink = {
mockLink = fromPartial<HTMLAnchorElement>({
href: '',
download: '',
click: vi.fn(),
style: { display: '' }
} as unknown as HTMLAnchorElement
})
// Spy on DOM methods
vi.spyOn(document, 'createElement').mockReturnValue(mockLink)
@@ -172,12 +173,14 @@ describe('downloadUtil', () => {
const headersMock = {
get: vi.fn().mockReturnValue(null)
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
})
)
downloadFile(testUrl)
@@ -198,11 +201,13 @@ describe('downloadUtil', () => {
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchMock.mockResolvedValue({
ok: false,
status: 404,
blob: vi.fn()
} as Partial<Response> as Response)
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: false,
status: 404,
blob: vi.fn()
})
)
downloadFile(testUrl)
@@ -224,12 +229,14 @@ describe('downloadUtil', () => {
const headersMock = {
get: vi.fn().mockReturnValue('attachment; filename="user-friendly.png"')
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
})
)
downloadFile(testUrl)
@@ -256,12 +263,14 @@ describe('downloadUtil', () => {
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%E4%B8%AD%E6%96%87.png'
)
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
})
)
downloadFile(testUrl)
@@ -282,12 +291,14 @@ describe('downloadUtil', () => {
const headersMock = {
get: vi.fn().mockReturnValue(null)
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
})
)
downloadFile(testUrl, 'my-fallback.png')
@@ -328,11 +339,13 @@ describe('downloadUtil', () => {
const testUrl = 'https://storage.googleapis.com/bucket/image.png'
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
} as unknown as Response)
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
})
)
await openFileInNewTab(testUrl)
@@ -346,11 +359,13 @@ describe('downloadUtil', () => {
mockIsCloud.value = true
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
} as unknown as Response)
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
})
)
await openFileInNewTab('https://example.com/image.png')
@@ -364,11 +379,10 @@ describe('downloadUtil', () => {
const testUrl = 'https://storage.googleapis.com/bucket/missing.png'
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: false,
status: 404
} as unknown as Response)
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
fetchMock.mockResolvedValue(
fromPartial<Response>({ ok: false, status: 404 })
)
await openFileInNewTab(testUrl)
@@ -381,11 +395,13 @@ describe('downloadUtil', () => {
mockIsCloud.value = true
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: true, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
} as unknown as Response)
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
})
)
await openFileInNewTab('https://example.com/image.png')

View File

@@ -33,76 +33,91 @@
{{ t('g.next') }}
<i class="icon-[lucide--chevron-right]" aria-hidden="true" />
</Button>
<ConnectOutputPopover
v-if="!hasOutputs"
:is-select-active="isSelectStep"
@switch="navigateToStep('builder:outputs')"
>
<div class="relative min-w-24">
<!--
Invisible sizers: both labels rendered with matching button padding
so the container's intrinsic width equals the wider label.
height:0 + overflow:hidden keeps them invisible without affecting height.
-->
<div class="max-h-0 overflow-y-hidden" aria-hidden="true">
<div class="px-4 py-2 text-sm">{{ t('g.save') }}</div>
<div class="px-4 py-2 text-sm">{{ t('builderToolbar.saveAs') }}</div>
</div>
<ConnectOutputPopover
v-if="!hasOutputs"
class="w-full"
:is-select-active="isSelectStep"
@switch="navigateToStep('builder:outputs')"
>
<Button
size="lg"
class="w-full"
:class="disabledSaveClasses"
data-testid="builder-save-as-button"
>
{{ isSaved ? t('g.save') : t('builderToolbar.saveAs') }}
</Button>
</ConnectOutputPopover>
<ButtonGroup
v-else-if="isSaved"
data-testid="builder-save-group"
class="w-full rounded-lg bg-secondary-background has-[[data-save-chevron]:hover]:bg-secondary-background-hover"
>
<Button
size="lg"
:disabled="!isModified"
class="flex-1"
:class="isModified ? activeSaveClasses : disabledSaveClasses"
data-testid="builder-save-button"
@click="save()"
>
{{ t('g.save') }}
</Button>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<Button
size="lg"
:aria-label="t('builderToolbar.saveAs')"
data-save-chevron
data-testid="builder-save-as-chevron"
class="w-6 rounded-l-none border-l border-border-default px-0"
>
<i
class="icon-[lucide--chevron-down] size-4"
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
align="end"
:side-offset="4"
class="z-1001 min-w-36 rounded-lg border border-border-subtle bg-base-background p-1 shadow-interface"
>
<DropdownMenuItem as-child @select="saveAs()">
<Button
variant="secondary"
size="lg"
class="w-full justify-start font-normal"
>
{{ t('builderToolbar.saveAs') }}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</ButtonGroup>
<Button
v-else
size="lg"
:class="cn('w-24', disabledSaveClasses)"
class="w-full"
:class="activeSaveClasses"
data-testid="builder-save-as-button"
@click="saveAs()"
>
{{ isSaved ? t('g.save') : t('builderToolbar.saveAs') }}
{{ t('builderToolbar.saveAs') }}
</Button>
</ConnectOutputPopover>
<ButtonGroup
v-else-if="isSaved"
class="w-24 rounded-lg bg-secondary-background has-[[data-save-chevron]:hover]:bg-secondary-background-hover"
>
<Button
size="lg"
:disabled="!isModified"
class="flex-1"
:class="isModified ? activeSaveClasses : disabledSaveClasses"
data-testid="builder-save-button"
@click="save()"
>
{{ t('g.save') }}
</Button>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<Button
size="lg"
:aria-label="t('builderToolbar.saveAs')"
data-save-chevron
data-testid="builder-save-as-chevron"
class="w-6 rounded-l-none border-l border-border-default px-0"
>
<i
class="icon-[lucide--chevron-down] size-4"
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
align="end"
:side-offset="4"
class="z-1001 min-w-36 rounded-lg border border-border-subtle bg-base-background p-1 shadow-interface"
>
<DropdownMenuItem as-child @select="saveAs()">
<Button
variant="secondary"
size="lg"
class="w-full justify-start font-normal"
>
{{ t('builderToolbar.saveAs') }}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</ButtonGroup>
<Button
v-else
size="lg"
:class="activeSaveClasses"
data-testid="builder-save-as-button"
@click="saveAs()"
>
{{ t('builderToolbar.saveAs') }}
</Button>
</div>
</nav>
</div>
</template>
@@ -126,8 +141,6 @@ import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
import BuilderOpensAsPopover from './BuilderOpensAsPopover.vue'
import { setWorkflowDefaultView } from './builderViewOptions'
import ConnectOutputPopover from './ConnectOutputPopover.vue'

View File

@@ -51,7 +51,10 @@
}
"
>
<div class="flex min-w-40 flex-col gap-2 p-2">
<div
class="flex min-w-40 flex-col gap-2 p-2"
data-testid="more-menu-content"
>
<slot :close="hide" />
</div>
</Popover>

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

@@ -1,3 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -9,7 +11,6 @@ import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { createTestingPinia } from '@pinia/testing'
type TestWidget = BaseDOMWidget<object | string>
@@ -28,7 +29,7 @@ function createNode(
}
function createWidget(id: string, node: LGraphNode, y = 12): TestWidget {
return {
return fromPartial<TestWidget>({
id,
node,
name: 'test_widget',
@@ -40,16 +41,16 @@ function createWidget(id: string, node: LGraphNode, y = 12): TestWidget {
computedHeight: 40,
margin: 10,
isVisible: () => true
} as unknown as TestWidget
})
}
function createCanvas(graph: LGraph): LGraphCanvas {
return {
return fromPartial<LGraphCanvas>({
graph,
low_quality: false,
read_only: false,
isNodeVisible: vi.fn(() => true)
} as unknown as LGraphCanvas
})
}
function drawFrame(canvas: LGraphCanvas) {

View File

@@ -1,14 +1,14 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import type { DomWidgetState } from '@/stores/domWidgetStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import DomWidget from './DomWidget.vue'
const mockUpdatePosition = vi.fn()
@@ -63,7 +63,7 @@ function createWidgetState(overrideDisabled: boolean): DomWidgetState {
}
})
const widget = {
const widget = fromPartial<BaseDOMWidget<object | string>>({
id: 'dom-widget-id',
name: 'test_widget',
type: 'custom',
@@ -71,7 +71,7 @@ function createWidgetState(overrideDisabled: boolean): DomWidgetState {
options: {},
node,
computedDisabled: false
} as unknown as BaseDOMWidget<object | string>
})
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {

View File

@@ -1,7 +1,7 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { getDomWidgetZIndex } from './domWidgetZIndex'
describe('getDomWidgetZIndex', () => {
@@ -15,7 +15,7 @@ describe('getDomWidgetZIndex', () => {
first.order = 0
second.order = 1
const nodes = (graph as unknown as { _nodes: LGraphNode[] })._nodes
const nodes = fromPartial<{ _nodes: LGraphNode[] }>(graph)._nodes
nodes.splice(nodes.indexOf(first), 1)
nodes.push(first)

View File

@@ -197,4 +197,15 @@ onBeforeUnmount(() => {
:deep(.p-panel-content) {
padding: 0;
}
:deep(.p-slider) {
height: 6px;
}
:deep(.p-slider-handle) {
width: 14px;
height: 14px;
margin-top: -4px;
margin-left: -7px;
}
</style>

View File

@@ -1,9 +1,7 @@
<template>
<div class="space-y-4">
<div class="space-y-4">
<label>
{{ t('load3d.viewer.cameraType') }}
</label>
<div class="flex flex-col gap-2">
<label>{{ t('load3d.viewer.cameraType') }}</label>
<Select
v-model="cameraType"
:options="cameras"
@@ -13,7 +11,7 @@
</Select>
</div>
<div v-if="showFOVButton" class="space-y-4">
<div v-if="showFOVButton" class="flex flex-col gap-2">
<label>{{ t('load3d.fov') }}</label>
<Slider
v-model="fov"

View File

@@ -1,5 +1,5 @@
<template>
<div class="space-y-4">
<div class="flex flex-col gap-2">
<label>{{ $t('load3d.lightIntensity') }}</label>
<Slider

View File

@@ -1,6 +1,6 @@
<template>
<div class="space-y-4">
<div>
<div class="flex flex-col gap-2">
<label>{{ $t('load3d.upDirection') }}</label>
<Select
v-model="upDirection"
@@ -10,7 +10,7 @@
/>
</div>
<div v-if="!hideMaterialMode">
<div v-if="!hideMaterialMode" class="flex flex-col gap-2">
<label>{{ $t('load3d.materialMode') }}</label>
<Select
v-model="materialMode"

View File

@@ -1,10 +1,10 @@
<template>
<div class="space-y-4">
<div v-if="!hasBackgroundImage">
<div v-if="!hasBackgroundImage" class="flex flex-col gap-2">
<label>
{{ $t('load3d.backgroundColor') }}
</label>
<input v-model="backgroundColor" type="color" class="w-full" />
<input v-model="backgroundColor" type="color" class="h-8 w-full" />
</div>
<div>

View File

@@ -303,6 +303,7 @@ function handleTitleCancel() {
v-if="isSingleSubgraphNode"
variant="secondary"
size="icon"
data-testid="subgraph-editor-toggle"
:class="cn(isEditingSubgraph && 'bg-secondary-background-selected')"
@click="
rightSidePanelStore.openPanel(

View File

@@ -15,6 +15,7 @@
<div
v-if="singleRuntimeErrorCard"
data-testid="runtime-error-panel"
aria-live="polite"
class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-3"
>
<div
@@ -168,7 +169,15 @@
v-else-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-model="handleLocateModel"
@locate-model="handleLocateAssetNode"
/>
<!-- Missing Media -->
<MissingMediaCard
v-else-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateAssetNode"
/>
</PropertiesAccordionItem>
</TransitionGroup>
@@ -225,6 +234,7 @@ import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
import { isCloud } from '@/platform/distribution/types'
import {
downloadModel,
@@ -261,7 +271,8 @@ const isSearching = computed(() => searchQuery.value.trim() !== '')
const fullSizeGroupTypes = new Set([
'missing_node',
'swap_nodes',
'missing_model'
'missing_model',
'missing_media'
])
function getGroupSize(group: ErrorGroup) {
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
@@ -283,6 +294,7 @@ const {
missingNodeCache,
missingPackGroups,
missingModelGroups,
missingMediaGroups,
swapNodeGroups
} = useErrorGroups(searchQuery, t)
@@ -393,7 +405,7 @@ function handleLocateMissingNode(nodeId: string) {
focusNode(nodeId, missingNodeCache.value)
}
function handleLocateModel(nodeId: string) {
function handleLocateAssetNode(nodeId: string) {
focusNode(nodeId)
}

View File

@@ -1,3 +1,4 @@
import { fromAny } from '@total-typescript/shoehorn'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -159,7 +160,7 @@ describe('swapNodeGroups computed', () => {
it('excludes string nodeType entries', async () => {
const swap = getSwapNodeGroups([
'StringGroupNode' as unknown as MissingNodeType,
fromAny<MissingNodeType, unknown>('StringGroupNode'),
makeMissingNodeType('OldNode', {
nodeId: '1',
isReplaceable: true,

View File

@@ -25,3 +25,4 @@ export type ErrorGroup =
| { type: 'missing_node'; title: string; priority: number }
| { type: 'swap_nodes'; title: string; priority: number }
| { type: 'missing_model'; title: string; priority: number }
| { type: 'missing_media'; title: string; priority: number }

View File

@@ -20,7 +20,7 @@ export function useErrorActions() {
is_external: true,
source: 'error_dialog'
})
void commandStore.execute('Comfy.ContactSupport')
return commandStore.execute('Comfy.ContactSupport')
}
function findOnGitHub(errorMessage: string) {

View File

@@ -1,3 +1,4 @@
import { fromAny } from '@total-typescript/shoehorn'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -215,7 +216,7 @@ describe('useErrorGroups', () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
'StringGroupNode' as unknown as MissingNodeType
fromAny<MissingNodeType, unknown>('StringGroupNode')
])
await nextTick()

View File

@@ -4,6 +4,7 @@ import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
@@ -29,7 +30,9 @@ import type {
MissingModelCandidate,
MissingModelGroup
} from '@/platform/missingModel/types'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
import {
isNodeExecutionId,
compareExecutionId
@@ -239,6 +242,7 @@ export function useErrorGroups(
const executionErrorStore = useExecutionErrorStore()
const missingNodesStore = useMissingNodesErrorStore()
const missingModelStore = useMissingModelStore()
const missingMediaStore = useMissingMediaStore()
const canvasStore = useCanvasStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
const collapseState = reactive<Record<string, boolean>>({})
@@ -635,6 +639,27 @@ export function useErrorGroups(
]
}
const missingMediaGroups = computed<MissingMediaGroup[]>(() => {
const candidates = missingMediaStore.missingMediaCandidates
if (!candidates?.length) return []
return groupCandidatesByMediaType(candidates)
})
function buildMissingMediaGroups(): ErrorGroup[] {
if (!missingMediaGroups.value.length) return []
const totalItems = missingMediaGroups.value.reduce(
(count, group) => count + group.items.length,
0
)
return [
{
type: 'missing_media' as const,
title: `${t('rightSidePanel.missingMedia.missingMediaTitle')} (${totalItems})`,
priority: 3
}
]
}
const allErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
@@ -645,6 +670,7 @@ export function useErrorGroups(
return [
...buildMissingNodeGroups(),
...buildMissingModelGroups(),
...buildMissingMediaGroups(),
...toSortedGroups(groupsMap)
]
})
@@ -663,6 +689,7 @@ export function useErrorGroups(
return [
...buildMissingNodeGroups(),
...buildMissingModelGroups(),
...buildMissingMediaGroups(),
...executionGroups
]
})
@@ -699,6 +726,7 @@ export function useErrorGroups(
groupedErrorMessages,
missingPackGroups,
missingModelGroups,
missingMediaGroups,
swapNodeGroups
}
}

View File

@@ -1,4 +1,4 @@
import { computed, onMounted, onUnmounted, reactive, toValue } from 'vue'
import { computed, reactive, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
@@ -28,66 +28,73 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
)
})
let cancelled = false
onUnmounted(() => {
cancelled = true
})
watch(
() => toValue(cardSource),
async (card, _, onCleanup) => {
let cancelled = false
onCleanup(() => {
cancelled = true
})
onMounted(async () => {
const card = toValue(cardSource)
const runtimeErrors = card.errors
.map((error, idx) => ({ error, idx }))
.filter(({ error }) => error.isRuntimeError)
for (const key of Object.keys(enrichedDetails)) {
delete enrichedDetails[key as unknown as number]
}
if (runtimeErrors.length === 0) return
const runtimeErrors = card.errors
.map((error, idx) => ({ error, idx }))
.filter(({ error }) => error.isRuntimeError)
if (!systemStatsStore.systemStats) {
if (systemStatsStore.isLoading) {
await until(systemStatsStore.isLoading).toBe(false)
} else {
try {
await systemStatsStore.refetchSystemStats()
} catch (e) {
console.warn('Failed to fetch system stats for error report:', e)
return
if (runtimeErrors.length === 0) return
if (!systemStatsStore.systemStats) {
if (systemStatsStore.isLoading) {
await until(() => systemStatsStore.isLoading).toBe(false)
} else {
try {
await systemStatsStore.refetchSystemStats()
} catch (e) {
console.warn('Failed to fetch system stats for error report:', e)
return
}
}
}
}
if (!systemStatsStore.systemStats || cancelled) return
if (!systemStatsStore.systemStats || cancelled) return
const logs = await api
.getLogs()
.catch(() => 'Failed to retrieve server logs')
if (cancelled) return
const logs = await api
.getLogs()
.catch(() => 'Failed to retrieve server logs')
if (cancelled) return
const workflow = (() => {
try {
return app.rootGraph.serialize()
} catch (e) {
console.warn('Failed to serialize workflow for error report:', e)
return null
const workflow = (() => {
try {
return app.rootGraph.serialize()
} catch (e) {
console.warn('Failed to serialize workflow for error report:', e)
return null
}
})()
if (!workflow) return
for (const { error, idx } of runtimeErrors) {
try {
const report = generateErrorReport({
exceptionType: error.exceptionType ?? FALLBACK_EXCEPTION_TYPE,
exceptionMessage: error.message,
traceback: error.details,
nodeId: card.nodeId,
nodeType: card.title,
systemStats: systemStatsStore.systemStats,
serverLogs: logs,
workflow
})
enrichedDetails[idx] = report
} catch (e) {
console.warn('Failed to generate error report:', e)
}
}
})()
if (!workflow) return
for (const { error, idx } of runtimeErrors) {
try {
const report = generateErrorReport({
exceptionType: error.exceptionType ?? FALLBACK_EXCEPTION_TYPE,
exceptionMessage: error.message,
traceback: error.details,
nodeId: card.nodeId,
nodeType: card.title,
systemStats: systemStatsStore.systemStats,
serverLogs: logs,
workflow
})
enrichedDetails[idx] = report
} catch (e) {
console.warn('Failed to generate error report:', e)
}
}
})
},
{ immediate: true }
)
return { displayedDetailsMap }
}

View File

@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { getSourceNodeId } from '@/core/graph/subgraph/promotionUtils'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
@@ -79,7 +78,6 @@ function isWidgetShownOnParents(
): boolean {
return parents.some((parent) => {
if (isPromotedWidgetView(widget)) {
const sourceNodeId = getSourceNodeId(widget)
const interiorNodeId =
String(widgetNode.id) === String(parent.id)
? widget.sourceNodeId
@@ -88,7 +86,7 @@ function isWidgetShownOnParents(
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
sourceNodeId: interiorNodeId,
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: sourceNodeId
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
})
}
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {

View File

@@ -14,10 +14,7 @@ import {
import { useI18n } from 'vue-i18n'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
getSourceNodeId,
getWidgetName
} from '@/core/graph/subgraph/promotionUtils'
import { getWidgetName } from '@/core/graph/subgraph/promotionUtils'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
@@ -132,7 +129,9 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: getSourceNodeId(widget)
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
? widget.disambiguatingSourceNodeId
: undefined
})
)
})

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import type { Slots } from 'vue'
@@ -10,7 +11,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { usePromotionStore } from '@/stores/promotionStore'
import WidgetActions from './WidgetActions.vue'
const { mockGetInputSpecForWidget } = vi.hoisted(() => ({
@@ -93,13 +93,14 @@ describe('WidgetActions', () => {
}
function createMockNode(): LGraphNode {
return {
return fromAny<LGraphNode, unknown>({
id: 1,
type: 'TestNode',
rootGraph: { id: 'graph-test' },
computeSize: vi.fn(),
size: [200, 100]
} as unknown as LGraphNode
size: [200, 100],
isSubgraphNode: () => false
})
}
function mountWidgetActions(widget: IBaseWidget, node: LGraphNode) {
@@ -216,17 +217,18 @@ describe('WidgetActions', () => {
mockGetInputSpecForWidget.mockReturnValue({
type: 'CUSTOM'
})
const parentSubgraphNode = {
const parentSubgraphNode = fromAny<SubgraphNode, unknown>({
id: 4,
rootGraph: { id: 'graph-test' },
computeSize: vi.fn(),
size: [300, 150]
} as unknown as SubgraphNode
const node = {
})
const node = fromAny<LGraphNode, unknown>({
id: 4,
type: 'SubgraphNode',
rootGraph: { id: 'graph-test' }
} as unknown as LGraphNode
rootGraph: { id: 'graph-test' },
isSubgraphNode: () => false
})
const widget = {
name: 'text',
type: 'text',

View File

@@ -9,7 +9,7 @@ import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetT
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demoteWidget,
getSourceNodeId,
isLinkedPromotion,
promoteWidget
} from '@/core/graph/subgraph/promotionUtils'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -47,6 +47,11 @@ const promotionStore = usePromotionStore()
const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
const isLinked = computed(() => {
if (!node.isSubgraphNode() || !isPromotedWidgetView(widget)) return false
return isLinkedPromotion(node, widget.sourceNodeId, widget.sourceWidgetName)
})
const canToggleVisibility = computed(() => hasParents.value && !isLinked.value)
const favoriteNode = computed(() =>
isShownOnParents && hasParents.value ? parents[0] : node
)
@@ -76,8 +81,6 @@ function handleHideInput() {
if (!parents?.length) return
if (isPromotedWidgetView(widget)) {
const disambiguatingSourceNodeId = getSourceNodeId(widget)
for (const parent of parents) {
const source: PromotedWidgetSource = {
sourceNodeId:
@@ -85,7 +88,7 @@ function handleHideInput() {
? widget.sourceNodeId
: String(node.id),
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
}
promotionStore.demote(parent.rootGraph.id, parent.id, source)
parent.computeSize(parent.size)
@@ -114,6 +117,7 @@ function handleResetToDefault() {
<template>
<MoreButton
is-vertical
data-testid="widget-actions-menu-button"
class="bg-transparent text-muted-foreground transition-all hover:bg-secondary-background-hover hover:text-base-foreground active:scale-95"
>
<template #default="{ close }">
@@ -133,7 +137,7 @@ function handleResetToDefault() {
</Button>
<Button
v-if="hasParents"
v-if="canToggleVisibility"
variant="textonly"
size="unset"
class="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -72,13 +73,13 @@ const i18n = createI18n({
})
function createMockNode(overrides: Partial<LGraphNode> = {}): LGraphNode {
return {
return fromAny<LGraphNode, unknown>({
id: 1,
type: 'TestNode',
isSubgraphNode: () => false,
graph: { rootGraph: { id: 'test-graph-id' } },
...overrides
} as unknown as LGraphNode
})
}
function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
@@ -128,7 +129,7 @@ function createMockPromotedWidgetView(
return 0
}
}
return new MockPromotedWidgetView() as unknown as IBaseWidget
return fromAny<IBaseWidget, unknown>(new MockPromotedWidgetView())
}
function mountWidgetItem(

View File

@@ -11,6 +11,7 @@ import {
getPromotableWidgets,
getSourceNodeId,
getWidgetName,
isLinkedPromotion,
isRecommendedWidget,
promoteWidget,
pruneDisconnected
@@ -88,14 +89,13 @@ const activeWidgets = computed<WidgetItem[]>({
promotionStore.setPromotions(
node.rootGraph.id,
node.id,
value.map(([n, w]) => {
const sid = getSourceNodeId(w)
return {
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
...(sid && { disambiguatingSourceNodeId: sid })
}
})
value.map(([n, w]) => ({
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
disambiguatingSourceNodeId: isPromotedWidgetView(w)
? w.disambiguatingSourceNodeId
: undefined
}))
)
refreshPromotedWidgetRendering()
}
@@ -123,7 +123,9 @@ const candidateWidgets = computed<WidgetItem[]>(() => {
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
disambiguatingSourceNodeId: getSourceNodeId(w)
disambiguatingSourceNodeId: isPromotedWidgetView(w)
? w.disambiguatingSourceNodeId
: undefined
})
)
})
@@ -162,6 +164,18 @@ function refreshPromotedWidgetRendering() {
canvasStore.canvas?.setDirty(true, true)
}
function isItemLinked([node, widget]: WidgetItem): boolean {
return (
node.id === -1 ||
(!!activeNode.value &&
isLinkedPromotion(
activeNode.value,
String(node.id),
getWidgetName(widget)
))
)
}
function toKey(item: WidgetItem) {
const sid = getSourceNodeId(item[1])
return sid
@@ -187,8 +201,14 @@ function showAll() {
}
}
function hideAll() {
const node = activeNode.value
for (const item of filteredActive.value) {
if (String(item[0].id) === '-1') continue
if (
node &&
isLinkedPromotion(node, String(item[0].id), getWidgetName(item[1]))
)
continue
demote(item)
}
}
@@ -223,6 +243,7 @@ onMounted(() => {
<div
v-if="filteredActive.length"
data-testid="subgraph-editor-shown-section"
class="flex flex-col border-b border-interface-stroke"
>
<div
@@ -244,8 +265,8 @@ onMounted(() => {
:key="toKey([node, widget])"
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
:node-title="node.title"
:widget-name="widget.name"
:is-physical="node.id === -1"
:widget-name="widget.label || widget.name"
:is-physical="isItemLinked([node, widget])"
:is-draggable="!searchQuery"
@toggle-visibility="demote([node, widget])"
/>
@@ -254,6 +275,7 @@ onMounted(() => {
<div
v-if="filteredCandidates.length"
data-testid="subgraph-editor-hidden-section"
class="flex flex-col border-b border-interface-stroke"
>
<div

View File

@@ -1,9 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import type { ClassValue } from '@/utils/tailwindUtil'
const props = defineProps<{
const {
nodeTitle,
widgetName,
isDraggable = false,
isPhysical = false,
class: className
} = defineProps<{
nodeTitle: string
widgetName: string
isDraggable?: boolean
@@ -14,13 +22,13 @@ defineEmits<{
(e: 'toggleVisibility'): void
}>()
function getIcon() {
return props.isPhysical
const icon = computed(() =>
isPhysical
? 'icon-[lucide--link]'
: props.isDraggable
: isDraggable
? 'icon-[lucide--eye]'
: 'icon-[lucide--eye-off]'
}
)
</script>
<template>
@@ -29,8 +37,8 @@ function getIcon() {
cn(
'flex items-center gap-1 rounded-sm px-2 py-1 break-all',
'bg-node-component-surface',
props.isDraggable && 'ring-accent-background hover:ring-1',
props.class
isDraggable && 'ring-accent-background hover:ring-1',
className
)
"
>
@@ -38,15 +46,18 @@ function getIcon() {
<div class="line-clamp-1 text-xs text-text-secondary">
{{ nodeTitle }}
</div>
<div class="line-clamp-1 text-sm/8">{{ widgetName }}</div>
<div class="line-clamp-1 text-sm/8" data-testid="subgraph-widget-label">
{{ widgetName }}
</div>
</div>
<Button
variant="muted-textonly"
size="sm"
data-testid="subgraph-widget-toggle"
:disabled="isPhysical"
@click.stop="$emit('toggleVisibility')"
>
<i :class="getIcon()" />
<i :class="icon" :data-testid="isPhysical ? 'icon-link' : 'icon-eye'" />
</Button>
<div
v-if="isDraggable"

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

@@ -1,3 +1,4 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { useDomClipping } from './useDomClipping'
@@ -8,7 +9,7 @@ function createMockElement(rect: {
width: number
height: number
}): HTMLElement {
return {
return fromPartial<HTMLElement>({
getBoundingClientRect: vi.fn(
() =>
({
@@ -20,7 +21,7 @@ function createMockElement(rect: {
toJSON: () => ({})
}) as DOMRect
)
} as unknown as HTMLElement
})
}
function createMockCanvas(rect: {
@@ -29,7 +30,7 @@ function createMockCanvas(rect: {
width: number
height: number
}): HTMLCanvasElement {
return {
return fromPartial<HTMLCanvasElement>({
getBoundingClientRect: vi.fn(
() =>
({
@@ -41,7 +42,7 @@ function createMockCanvas(rect: {
toJSON: () => ({})
}) as DOMRect
)
} as unknown as HTMLCanvasElement
})
}
describe('useDomClipping', () => {

View File

@@ -1,5 +1,6 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
@@ -194,7 +195,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(
undefined as unknown as LGraph
fromAny<LGraph, unknown>(undefined)
)
store.lastNodeErrors = {
[String(node.id)]: {

View File

@@ -1,3 +1,4 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
@@ -8,7 +9,6 @@ import {
createMockLGraphNode,
createMockLGraphGroup
} from '@/utils/__tests__/litegraphTestUtils'
import { useGraphHierarchy } from './useGraphHierarchy'
vi.mock('@/renderer/core/canvas/canvasStore')
@@ -36,7 +36,10 @@ describe('useGraphHierarchy', () => {
mockNode = createMockNode()
mockGroups = []
mockCanvasStore = {
mockCanvasStore = fromAny<
Partial<ReturnType<typeof useCanvasStore>>,
unknown
>({
canvas: {
graph: {
groups: mockGroups
@@ -51,7 +54,7 @@ describe('useGraphHierarchy', () => {
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {}
} as unknown as Partial<ReturnType<typeof useCanvasStore>>
})
vi.mocked(useCanvasStore).mockReturnValue(
mockCanvasStore as ReturnType<typeof useCanvasStore>

View File

@@ -1,5 +1,6 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
@@ -11,10 +12,10 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -277,18 +278,20 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const secondPromotedView = promotedViews[1]
if (!secondPromotedView) throw new Error('Expected second promoted view')
;(
secondPromotedView as unknown as {
fromAny<
{
sourceNodeId: string
sourceWidgetName: string
}
).sourceNodeId = '9999'
;(
secondPromotedView as unknown as {
},
unknown
>(secondPromotedView).sourceNodeId = '9999'
fromAny<
{
sourceNodeId: string
sourceWidgetName: string
}
).sourceWidgetName = 'stale_widget'
},
unknown
>(secondPromotedView).sourceWidgetName = 'stale_widget'
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))

View File

@@ -1,8 +1,8 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { useImageMenuOptions } from './useImageMenuOptions'
vi.mock('vue-i18n', async (importOriginal) => {
@@ -112,9 +112,11 @@ describe('useImageMenuOptions', () => {
getType: vi.fn().mockResolvedValue(mockBlob)
}
mockClipboard({
read: vi.fn().mockResolvedValue([mockClipboardItem])
} as unknown as Clipboard)
mockClipboard(
fromPartial<Clipboard>({
read: vi.fn().mockResolvedValue([mockClipboardItem])
})
)
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
@@ -131,7 +133,7 @@ describe('useImageMenuOptions', () => {
it('handles missing clipboard API gracefully', async () => {
const node = createImageNode()
mockClipboard({ read: undefined } as unknown as Clipboard)
mockClipboard(fromPartial<Clipboard>({ read: undefined }))
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
@@ -148,9 +150,11 @@ describe('useImageMenuOptions', () => {
getType: vi.fn()
}
mockClipboard({
read: vi.fn().mockResolvedValue([mockClipboardItem])
} as unknown as Clipboard)
mockClipboard(
fromPartial<Clipboard>({
read: vi.fn().mockResolvedValue([mockClipboardItem])
})
)
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)

View File

@@ -3,6 +3,7 @@ import { computed, watch } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import type { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import type { NodeError } from '@/schemas/apiSchema'
@@ -32,7 +33,8 @@ function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void {
function reconcileNodeErrorFlags(
rootGraph: LGraph,
nodeErrors: Record<string, NodeError> | null,
missingModelExecIds: Set<string>
missingModelExecIds: Set<string>,
missingMediaExecIds: Set<string> = new Set()
): void {
// Collect nodes and slot info that should be flagged
// Includes both error-owning nodes and their ancestor containers
@@ -64,6 +66,11 @@ function reconcileNodeErrorFlags(
if (node) flaggedNodes.add(node)
}
for (const execId of missingMediaExecIds) {
const node = getNodeByExecutionId(rootGraph, execId)
if (node) flaggedNodes.add(node)
}
forEachNode(rootGraph, (node) => {
setNodeHasErrors(node, flaggedNodes.has(node))
@@ -78,7 +85,8 @@ function reconcileNodeErrorFlags(
export function useNodeErrorFlagSync(
lastNodeErrors: Ref<Record<string, NodeError> | null>,
missingModelStore: ReturnType<typeof useMissingModelStore>
missingModelStore: ReturnType<typeof useMissingModelStore>,
missingMediaStore: ReturnType<typeof useMissingMediaStore>
): () => void {
const settingStore = useSettingStore()
const showErrorsTab = computed(() =>
@@ -89,12 +97,13 @@ export function useNodeErrorFlagSync(
[
lastNodeErrors,
() => missingModelStore.missingModelNodeIds,
() => missingMediaStore.missingMediaNodeIds,
showErrorsTab
],
() => {
if (!app.isGraphReady) return
// Legacy (LGraphNode) only: suppress missing-model error flags when
// the Errors tab is hidden, since legacy nodes lack the per-widget
// Legacy (LGraphNode) only: suppress missing-model/media error flags
// when the Errors tab is hidden, since legacy nodes lack the per-widget
// red highlight that Vue nodes use to indicate *why* a node has errors.
// Vue nodes compute hasAnyError independently and are unaffected.
reconcileNodeErrorFlags(
@@ -102,6 +111,9 @@ export function useNodeErrorFlagSync(
lastNodeErrors.value,
showErrorsTab.value
? missingModelStore.missingModelAncestorExecutionIds
: new Set(),
showErrorsTab.value
? missingMediaStore.missingMediaAncestorExecutionIds
: new Set()
)
},

View File

@@ -1,10 +1,11 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useMaskEditorSaver } from './useMaskEditorSaver'
@@ -21,7 +22,7 @@ vi.mock('@/stores/maskEditorDataStore', () => ({
}))
function createMockCtx(): CanvasRenderingContext2D {
return {
return fromPartial<CanvasRenderingContext2D>({
drawImage: vi.fn(),
getImageData: vi.fn(() => ({
data: new Uint8ClampedArray(4 * 4 * 4),
@@ -30,11 +31,11 @@ function createMockCtx(): CanvasRenderingContext2D {
})),
putImageData: vi.fn(),
globalCompositeOperation: 'source-over'
} as unknown as CanvasRenderingContext2D
})
}
function createMockCanvas(): HTMLCanvasElement {
return {
return fromPartial<HTMLCanvasElement>({
width: 4,
height: 4,
getContext: vi.fn(() => createMockCtx()),
@@ -42,7 +43,7 @@ function createMockCanvas(): HTMLCanvasElement {
cb(new Blob(['x'], { type: 'image/png' }))
}),
toDataURL: vi.fn(() => 'data:image/png;base64,mock')
} as unknown as HTMLCanvasElement
})
}
const mockEditorStore: Record<string, HTMLCanvasElement | null> = {
@@ -96,7 +97,7 @@ describe('useMaskEditorSaver', () => {
app.nodeOutputs = {}
app.nodePreviewImages = {}
mockNode = {
mockNode = fromAny<LGraphNode, unknown>({
id: 42,
type: 'LoadImage',
images: [],
@@ -107,7 +108,7 @@ describe('useMaskEditorSaver', () => {
widgets_values: ['original.png [input]'],
properties: { image: 'original.png [input]' },
graph: { setDirtyCanvas: vi.fn() }
} as unknown as LGraphNode
})
mockDataStore.sourceNode = mockNode
mockDataStore.inputData = {
@@ -135,7 +136,7 @@ describe('useMaskEditorSaver', () => {
vi.spyOn(document, 'createElement').mockImplementation(
(tagName: string, options?: ElementCreationOptions) => {
if (tagName === 'canvas')
return createMockCanvas() as unknown as HTMLCanvasElement
return fromAny<HTMLCanvasElement, unknown>(createMockCanvas())
return originalCreateElement(tagName, options)
}
)

View File

@@ -1,3 +1,4 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -44,12 +45,12 @@ vi.mock('@/stores/assetsStore', () => ({
}))
function createMockNode(): LGraphNode {
return {
return fromAny<LGraphNode, unknown>({
isUploading: false,
imgs: [new Image()],
graph: { setDirtyCanvas: vi.fn() },
size: [300, 400]
} as unknown as LGraphNode
})
}
function createFile(name = 'test.png'): File {

View File

@@ -1,8 +1,8 @@
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodePreviewAndDrag } from './useNodePreviewAndDrag'
const mockStartDrag = vi.fn()
@@ -72,9 +72,9 @@ describe('useNodePreviewAndDrag', () => {
toJSON: () => ({})
})
const mockEvent = {
const mockEvent = fromPartial<MouseEvent>({
currentTarget: mockElement
} as Partial<MouseEvent> as MouseEvent
})
result.handleMouseEnter(mockEvent)
expect(result.isHovered.value).toBe(true)
@@ -85,9 +85,9 @@ describe('useNodePreviewAndDrag', () => {
const result = useNodePreviewAndDrag(nodeDef)
const mockElement = document.createElement('div')
const mockEvent = {
const mockEvent = fromPartial<MouseEvent>({
currentTarget: mockElement
} as Partial<MouseEvent> as MouseEvent
})
result.handleMouseEnter(mockEvent)
expect(result.isHovered.value).toBe(false)
@@ -116,9 +116,9 @@ describe('useNodePreviewAndDrag', () => {
setData: vi.fn(),
setDragImage: vi.fn()
}
const mockEvent = {
const mockEvent = fromAny<DragEvent, unknown>({
dataTransfer: mockDataTransfer
} as unknown as DragEvent
})
result.handleDragStart(mockEvent)
@@ -151,10 +151,10 @@ describe('useNodePreviewAndDrag', () => {
result.isDragging.value = true
const mockEvent = {
const mockEvent = fromPartial<DragEvent>({
clientX: 100,
clientY: 200
} as Partial<DragEvent> as DragEvent
})
result.handleDragEnd(mockEvent)
@@ -168,11 +168,11 @@ describe('useNodePreviewAndDrag', () => {
result.isDragging.value = true
const mockEvent = {
const mockEvent = fromPartial<DragEvent>({
dataTransfer: { dropEffect: 'none' },
clientX: 300,
clientY: 400
} as Partial<DragEvent> as DragEvent
})
result.handleDragEnd(mockEvent)

View File

@@ -1,3 +1,4 @@
import { fromAny } from '@total-typescript/shoehorn'
import { useEventListener } from '@vueuse/core'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -79,10 +80,10 @@ describe('useServerLogs', () => {
// Simulate receiving a log event
const mockEvent = new CustomEvent('logs', {
detail: {
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: [{ m: 'Log message 1' }, { m: 'Log message 2' }]
} as unknown as LogsWsMessage
})
}) as CustomEvent<LogsWsMessage>
eventCallback(mockEvent)
@@ -103,14 +104,14 @@ describe('useServerLogs', () => {
) => void
const mockEvent = new CustomEvent('logs', {
detail: {
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: [
{ m: 'Log message 1 dont remove me' },
{ m: 'remove me' },
{ m: '' }
]
} as unknown as LogsWsMessage
})
}) as CustomEvent<LogsWsMessage>
eventCallback(mockEvent)

View File

@@ -1,3 +1,4 @@
import { fromAny } from '@total-typescript/shoehorn'
import { ref } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -80,10 +81,12 @@ describe('useWaveAudioPlayer', () => {
const mockDecodeAudioData = vi.fn(() => Promise.resolve(mockAudioBuffer))
const mockClose = vi.fn().mockResolvedValue(undefined)
globalThis.AudioContext = class {
decodeAudioData = mockDecodeAudioData
close = mockClose
} as unknown as typeof AudioContext
globalThis.AudioContext = fromAny<typeof AudioContext, unknown>(
class {
decodeAudioData = mockDecodeAudioData
close = mockClose
}
)
mockFetchApi.mockResolvedValue({
ok: true,

View File

@@ -1,7 +1,7 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it } from 'vitest'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { matchPromotedInput } from './matchPromotedInput'
type MockInput = {
@@ -31,10 +31,12 @@ describe(matchPromotedInput, () => {
}
const matched = matchPromotedInput(
[aliasInput, exactInput] as unknown as Array<{
name: string
_widget?: IBaseWidget
}>,
fromPartial<
Array<{
name: string
_widget?: IBaseWidget
}>
>([aliasInput, exactInput]),
targetWidget
)
@@ -48,7 +50,7 @@ describe(matchPromotedInput, () => {
}
const matched = matchPromotedInput(
[aliasInput] as unknown as Array<{ name: string; _widget?: IBaseWidget }>,
fromPartial<Array<{ name: string; _widget?: IBaseWidget }>>([aliasInput]),
targetWidget
)
@@ -65,10 +67,12 @@ describe(matchPromotedInput, () => {
}
const matched = matchPromotedInput(
[firstAliasInput, secondAliasInput] as unknown as Array<{
name: string
_widget?: IBaseWidget
}>,
fromPartial<
Array<{
name: string
_widget?: IBaseWidget
}>
>([firstAliasInput, secondAliasInput]),
targetWidget
)

View File

@@ -1,6 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
// Barrel import must come first to avoid circular dependency
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
@@ -97,11 +98,12 @@ function promotedWidgets(node: SubgraphNode): PromotedWidgetView[] {
}
function callSyncPromotions(node: SubgraphNode) {
;(
node as unknown as {
fromAny<
{
_syncPromotions: () => void
}
)._syncPromotions()
},
unknown
>(node)._syncPromotions()
}
describe(createPromotedWidgetView, () => {
@@ -156,7 +158,9 @@ describe(createPromotedWidgetView, () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
// node is defined via Object.defineProperty at runtime but not on the TS interface
expect((view as unknown as Record<string, unknown>).node).toBe(subgraphNode)
expect(fromAny<Record<string, unknown>, unknown>(view).node).toBe(
subgraphNode
)
})
test('serialize is false', () => {
@@ -289,7 +293,7 @@ describe(createPromotedWidgetView, () => {
value: 'initial',
options: {}
} satisfies Pick<IBaseWidget, 'name' | 'type' | 'value' | 'options'>
const fallbackWidget = fallbackWidgetShape as unknown as IBaseWidget
const fallbackWidget = fromPartial<IBaseWidget>(fallbackWidgetShape)
innerNode.widgets = [fallbackWidget]
const widgetValueStore = useWidgetValueStore()
@@ -398,13 +402,13 @@ describe(createPromotedWidgetView, () => {
subgraphNode.pos = [10, 20]
const innerNode = firstInnerNode(innerNodes)
const mouse = vi.fn(() => true)
const legacyWidget = {
const legacyWidget = fromAny<IBaseWidget, unknown>({
name: 'legacyMouse',
type: 'mystery-legacy',
value: 'val',
options: {},
mouse
} as unknown as IBaseWidget
})
innerNode.widgets = [legacyWidget]
const view = createPromotedWidgetView(
@@ -1448,17 +1452,20 @@ describe('widgets getter caching', () => {
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
const reconcileSpy = vi.spyOn(
subgraphNode as unknown as {
_buildPromotionReconcileState: (
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
linkedEntries: Array<{
inputName: string
inputKey: string
sourceNodeId: string
sourceWidgetName: string
}>
) => unknown
},
fromAny<
{
_buildPromotionReconcileState: (
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
linkedEntries: Array<{
inputName: string
inputKey: string
sourceNodeId: string
sourceWidgetName: string
}>
) => unknown
},
unknown
>(subgraphNode),
'_buildPromotionReconcileState'
)
@@ -1478,17 +1485,20 @@ describe('widgets getter caching', () => {
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
const reconcileSpy = vi.spyOn(
subgraphNode as unknown as {
_buildPromotionReconcileState: (
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
linkedEntries: Array<{
inputName: string
inputKey: string
sourceNodeId: string
sourceWidgetName: string
}>
) => unknown
},
fromAny<
{
_buildPromotionReconcileState: (
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
linkedEntries: Array<{
inputName: string
inputKey: string
sourceNodeId: string
sourceWidgetName: string
}>
) => unknown
},
unknown
>(subgraphNode),
'_buildPromotionReconcileState'
)
@@ -1522,9 +1532,14 @@ describe('widgets getter caching', () => {
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
const resolveSpy = vi.spyOn(
subgraphNode as unknown as {
_resolveLinkedPromotionBySubgraphInput: (...args: unknown[]) => unknown
},
fromAny<
{
_resolveLinkedPromotionBySubgraphInput: (
...args: unknown[]
) => unknown
},
unknown
>(subgraphNode),
'_resolveLinkedPromotionBySubgraphInput'
)
@@ -1923,32 +1938,34 @@ function createFakeCanvasContext() {
function createInspectableCanvasContext(fillText = vi.fn()) {
const fallback = vi.fn()
return new Proxy(
{
fillText,
beginPath: vi.fn(),
roundRect: vi.fn(),
rect: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
arc: vi.fn(),
measureText: (text: string) => ({ width: text.length * 8 }),
fillStyle: '#fff',
strokeStyle: '#fff',
textAlign: 'left',
globalAlpha: 1,
lineWidth: 1
} as Record<string, unknown>,
{
get(target, key) {
if (typeof key === 'string' && key in target)
return target[key as keyof typeof target]
return fallback
return fromAny<CanvasRenderingContext2D, unknown>(
new Proxy(
{
fillText,
beginPath: vi.fn(),
roundRect: vi.fn(),
rect: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
arc: vi.fn(),
measureText: (text: string) => ({ width: text.length * 8 }),
fillStyle: '#fff',
strokeStyle: '#fff',
textAlign: 'left',
globalAlpha: 1,
lineWidth: 1
} as Record<string, unknown>,
{
get(target, key) {
if (typeof key === 'string' && key in target)
return target[key as keyof typeof target]
return fallback
}
}
}
) as unknown as CanvasRenderingContext2D
)
)
}
function createTwoLevelNestedSubgraph() {

View File

@@ -1,13 +1,14 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { usePromotionStore } from '@/stores/promotionStore'
const updatePreviewsMock = vi.hoisted(() => vi.fn())
@@ -19,6 +20,7 @@ import {
CANVAS_IMAGE_PREVIEW_WIDGET,
getPromotableWidgets,
hasUnpromotedWidgets,
isLinkedPromotion,
isPreviewPseudoWidget,
promoteRecommendedWidgets,
pruneDisconnected
@@ -29,7 +31,7 @@ function widget(
Pick<IBaseWidget, 'name' | 'serialize' | 'type' | 'options'>
>
): IBaseWidget {
return { name: 'widget', ...overrides } as unknown as IBaseWidget
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
}
describe('isPreviewPseudoWidget', () => {
@@ -333,3 +335,84 @@ describe('hasUnpromotedWidgets', () => {
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
})
describe('isLinkedPromotion', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function linkedWidget(
sourceNodeId: string,
sourceWidgetName: string,
extra: Record<string, unknown> = {}
): IBaseWidget {
return {
sourceNodeId,
sourceWidgetName,
name: 'value',
type: 'text',
value: '',
options: {},
y: 0,
...extra
} as unknown as IBaseWidget
}
function createSubgraphWithInputs(count = 1) {
const subgraph = createTestSubgraph({
inputs: Array.from({ length: count }, (_, i) => ({
name: `input_${i}`,
type: 'STRING' as const
}))
})
return createTestSubgraphNode(subgraph)
}
it('returns true when an input has a matching _widget', () => {
const subgraphNode = createSubgraphWithInputs()
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(true)
})
it('returns false when no inputs exist or none match', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
expect(isLinkedPromotion(subgraphNode, '999', 'nonexistent')).toBe(false)
})
it('returns false when sourceNodeId matches but sourceWidgetName does not', () => {
const subgraphNode = createSubgraphWithInputs()
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
expect(isLinkedPromotion(subgraphNode, '3', 'wrong_name')).toBe(false)
})
it('returns false when _widget is undefined on input', () => {
const subgraphNode = createSubgraphWithInputs()
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(false)
})
it('matches by sourceNodeId even when disambiguatingSourceNodeId is present', () => {
const subgraphNode = createSubgraphWithInputs()
subgraphNode.inputs[0]._widget = linkedWidget('6', 'text', {
disambiguatingSourceNodeId: '1'
})
expect(isLinkedPromotion(subgraphNode, '6', 'text')).toBe(true)
expect(isLinkedPromotion(subgraphNode, '1', 'text')).toBe(false)
})
it('identifies multiple linked widgets across different inputs', () => {
const subgraphNode = createSubgraphWithInputs(2)
subgraphNode.inputs[0]._widget = linkedWidget('3', 'string_a')
subgraphNode.inputs[1]._widget = linkedWidget('4', 'value')
expect(isLinkedPromotion(subgraphNode, '3', 'string_a')).toBe(true)
expect(isLinkedPromotion(subgraphNode, '4', 'value')).toBe(true)
expect(isLinkedPromotion(subgraphNode, '3', 'value')).toBe(false)
expect(isLinkedPromotion(subgraphNode, '5', 'string_a')).toBe(false)
})
})

View File

@@ -27,6 +27,27 @@ export function getWidgetName(w: IBaseWidget): string {
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
}
/**
* Returns true if the given promotion entry corresponds to a linked promotion
* on the subgraph node. Linked promotions are driven by subgraph input
* connections and cannot be independently hidden or shown.
*/
export function isLinkedPromotion(
subgraphNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string
): boolean {
return subgraphNode.inputs.some((input) => {
const w = input._widget
return (
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === sourceNodeId &&
w.sourceWidgetName === sourceWidgetName
)
})
}
export function getSourceNodeId(w: IBaseWidget): string | undefined {
if (!isPromotedWidgetView(w)) return undefined
return w.disambiguatingSourceNodeId ?? w.sourceNodeId
@@ -39,7 +60,9 @@ function toPromotionSource(
return {
sourceNodeId: String(node.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: getSourceNodeId(widget)
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
? widget.disambiguatingSourceNodeId
: undefined
}
}

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
@@ -101,14 +102,14 @@ describe('resolveSubgraphInputLink', () => {
vi.spyOn(subgraph, 'getLink').mockImplementation((linkId) => {
if (typeof linkId !== 'number') return originalGetLink(linkId)
if (linkId === stale.linkId) {
return {
return fromPartial<ReturnType<typeof subgraph.getLink>>({
resolve: () => ({
inputNode: {
inputs: undefined,
getWidgetFromSlot: () => ({ name: 'ignored' })
}
})
} as unknown as ReturnType<typeof subgraph.getLink>
})
}
return originalGetLink(linkId)

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
@@ -72,8 +73,8 @@ describe('MatchType during configure', () => {
const link2Id = switchNode.inputs[1].link!
const outputTypeBefore = switchNode.outputs[0].type
;(
app as unknown as { configuringGraphLevel: number }
fromAny<{ configuringGraphLevel: number }, unknown>(
app
).configuringGraphLevel = 1
try {
@@ -92,8 +93,8 @@ describe('MatchType during configure', () => {
expect(graph.links[link2Id]).toBeDefined()
expect(switchNode.outputs[0].type).toBe(outputTypeBefore)
} finally {
;(
app as unknown as { configuringGraphLevel: number }
fromAny<{ configuringGraphLevel: number }, unknown>(
app
).configuringGraphLevel = 0
}
})

View File

@@ -1,7 +1,7 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import {
LGraph,
LGraphCanvas,
@@ -60,7 +60,7 @@ function createCanvas(graph: LGraph): LGraphCanvas {
el.getContext = vi
.fn()
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
.mockReturnValue(fromAny<CanvasRenderingContext2D, unknown>(ctx))
el.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,

View File

@@ -6,12 +6,12 @@
* and basic I/O management.
*/
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { createUuidv4, Subgraph } from '@/lib/litegraph/src/litegraph'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
assertSubgraphStructure,
@@ -48,7 +48,7 @@ describe('Subgraph Construction', () => {
it('should require a root graph', () => {
const subgraphData = createTestSubgraphData()
const createWithoutRoot = () =>
new Subgraph(null as unknown as LGraph, subgraphData)
new Subgraph(fromAny<LGraph, unknown>(null), subgraphData)
expect(createWithoutRoot).toThrow('Root graph is required')
})

View File

@@ -4,13 +4,13 @@
* Tests for SubgraphNode instances including construction,
* IO synchronization, and edge cases.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
createTestSubgraph,
@@ -933,14 +933,17 @@ describe('SubgraphNode promotion view keys', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const nodeWithKeyBuilder = subgraphNode as unknown as {
_makePromotionViewKey: (
inputKey: string,
interiorNodeId: string,
widgetName: string,
inputName?: string
) => string
}
const nodeWithKeyBuilder = fromAny<
{
_makePromotionViewKey: (
inputKey: string,
interiorNodeId: string,
widgetName: string,
inputName?: string
) => string
},
unknown
>(subgraphNode)
const firstKey = nodeWithKeyBuilder._makePromotionViewKey(
'65',

View File

@@ -1,3 +1,4 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it, vi } from 'vitest'
import { createBitmapCache } from './svgBitmapCache'
@@ -25,9 +26,9 @@ describe('createBitmapCache', () => {
)
}
const stubContext = {
const stubContext = fromPartial<CanvasRenderingContext2D>({
drawImage: vi.fn()
} as unknown as CanvasRenderingContext2D
})
it('returns the SVG when image is not yet complete', () => {
const svg = mockSvg({ complete: false, naturalWidth: 0 })

View File

@@ -1,12 +1,13 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { cachedMeasureText, clearTextMeasureCache } from './textMeasureCache'
function createMockCtx(font = '12px sans-serif'): CanvasRenderingContext2D {
return {
return fromPartial<CanvasRenderingContext2D>({
font,
measureText: vi.fn((text: string) => ({ width: text.length * 7 }))
} as unknown as CanvasRenderingContext2D
})
}
describe('textMeasureCache', () => {

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
@@ -167,7 +168,7 @@ describe('BaseWidget store integration', () => {
const defaultValue = 'You are an expert image-generation engine.'
const widget = createTestWidget(node, {
name: 'system_prompt',
value: undefined as unknown as number
value: fromAny<number, unknown>(undefined)
})
// Simulate what addDOMWidget does: override value with getter/setter

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