Compare commits

...

41 Commits

Author SHA1 Message Date
Benjamin Lu
c29c85afd1 fix: handle pre-init PostHog logout reset 2026-06-02 10:23:54 -07:00
Steven Tran
7abaf91e0b fix(telemetry): anchor PostHog reset to session state, drop trackLogout 2026-06-02 08:17:42 -07:00
nav
f4653b7365 fix(telemetry): handle logout before PostHog finishes initializing
If trackLogout fires before the dynamic posthog-js import resolves,
this.posthog?.reset(true) is a no-op and the reset is never applied
later — leaving a path for identity/session bleed across users on
shared browsers. Defer the reset to the init callback via a flag,
and clear the pre-logout event queue at logout time so events
queued before logout don't surface under the post-logout anonymous
distinct_id.
2026-06-01 08:09:19 -07:00
nav-tej
d99f08e4ea Merge branch 'main' into nav/posthog-qa-reset-on-logout 2026-06-01 07:57:16 -07:00
Dante
e16a0bfe82 fix(knip): narrow Playwright entrypoints so browser-test dead exports are reported (FE-717) (#12496)
## Summary

Narrow Knip's Playwright `entry` to actual spec files so dead exports in
browser-test fixtures are reported instead of being hidden by treating
every helper as an entrypoint.

## Changes

- **What**:
- `knip.config.ts`: Playwright `entry` changed from the broad
`['**/*.@(spec|test)…', 'browser_tests/**/*.ts']` to
`['browser_tests/**/*.@(spec|test).?(c|m)[jt]s?(x)']`.
`globalSetup`/`globalTeardown` stay covered via Knip's playwright config
resolution; fixtures remain in the project graph so their unused exports
surface.
- Resolved the 54 dead findings this exposed: over-exported symbols used
only within their own module are now module-private (dropped `export`,
no behavioral change); genuinely unreferenced fixtures were deleted
(asset/template `ALL_*` aggregators + orphaned `STABLE_*` data,
`TemplateHelper` distribution helpers + `generateTemplates`, dead
types/utils, and the unused `nodeDefinitions.ts` module).
- **Breaking**: none — test-only changes.

## Review Focus

- Deletions are limited to fixtures with zero importers on `main`
(verified via `pnpm knip`); the bulk of the diff is `export`-keyword
removal.
- Verified: `pnpm knip` (browser_tests clean), `pnpm typecheck`, `pnpm
typecheck:browser`, oxfmt/oxlint/eslint all pass.

Linear: FE-717
2026-06-01 07:53:29 +00:00
Dante
3a8ddfb6f1 fix: wrap long workflow name in Open shared workflow dialog (FE-828) (#12540)
## Summary

The "Open shared workflow" dialog rendered the workflow name in an
`<h2>` with no
wrapping control. A long, space-free name (e.g. a content-hash filename)
is a
single unbreakable "word", so with the default `overflow-wrap: normal`
it could
not wrap. It overflowed its box and, because PrimeVue's
`.p-dialog-content` is
`overflow-x: auto`, the dialog scrolled horizontally instead of
wrapping.

CDP measurement on the unfixed build (96-char name): dialog content
`scrollWidth 1336` vs `clientWidth 702` -> horizontal scroll. After
adding
`wrap-anywhere` to the heading: `scrollWidth 702 == clientWidth 702`,
name wraps
to multiple lines, full name still in the DOM.

### before
<img width="704" height="295" alt="before-dialog"
src="https://github.com/user-attachments/assets/ea05ab32-a80d-4210-951c-f43d595bd6eb"
/>

### after
<img width="704" height="359" alt="after-dialog"
src="https://github.com/user-attachments/assets/cbf3019e-5e71-4dba-a1fd-ea3586dd995a"
/>

## Changes

- `OpenSharedWorkflowDialogContent.vue`: add `wrap-anywhere` to the
workflow-name
`<h2>` so a long unbreakable name wraps within the dialog bounds instead
of
  forcing horizontal scroll. The parent already has `min-w-0`.
- Breaking: none

## Red-Green Verification

| Commit | CI | Purpose |
|--------|-----|---------|
| [`test:`
d9158d1](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26678596015)
| 🔴 Red (failure) | Proves the test catches the bug — cloud
e2e failed at `expect(scrollWidth).toBeLessThanOrEqual(clientWidth +
1)`: received 1332, expected <= 703 |
| [`fix:`
08e75a1](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26678974321)
| 🟢 Green (success) | Proves the fix resolves it |

Fixes FE-828

## Test Plan

- [x] CI red on test-only commit
- [x] CI green on fix commit
- [x] E2E regression
`browser_tests/tests/dialogs/openSharedWorkflowDialog.spec.ts` (@cloud):
mocks a long-named shared workflow, asserts the dialog does not overflow
horizontally
- [x] Manual CDP verification (before/after above)
2026-06-01 07:53:16 +00:00
Dante
5a53df8d79 feat(dialog): migrate Manager dialog to Reka-UI (Phase 4) (#12403)
## Summary

Phase 4 of the dialog migration. Flips `useManagerDialog` onto the Reka
renderer added in Phase 0, with content sizing that matches the legacy
`.manager-dialog` CSS (1724px × 80vh, expanding to 2200×1320 above
3000px). Public API of `useManagerDialog` / `useDialogService` is
unchanged.

Parent:
[FE-571](https://linear.app/comfyorg/issue/FE-571/dialog-system-migration-primevue-reka-ui-parent)
This phase:
[FE-576](https://linear.app/comfyorg/issue/FE-576/phase-4-migrate-manager-dialog-designer-review)
Predecessors: #11719 (Phase 0, merged), #12041 (Phase 1, merged), #12109
(Phase 2, merged), #12182 (Phase 3, **stacked PR base**)

> **Stacked on Phase 3**: this PR targets
`jaewon/fe-575-dialog-reka-migration-phase-3`. Rebase onto `main` after
#12182 lands.

## Changes

### `src/workbench/extensions/manager/composables/useManagerDialog.ts`
(+12)

| Field | Value | Reason |
| --- | --- | --- |
| `renderer` | `'reka'` | Flip onto the new path |
| `size` | `'full'` | Free `DialogContent` to take the contentClass
dimensions |
| `contentClass` | `w-[90vw] max-w-[1724px] sm:max-w-[1724px] h-[80vh]
max-h-[1026px] min-[3000px]:max-w-[2200px] min-[3000px]:max-h-[1320px]
rounded-2xl overflow-hidden` | Mirrors legacy `.manager-dialog` global
CSS exactly |
| `modal` | `false` | Manager hosts PrimeVue overlays (`SingleSelect`,
`SearchAutocomplete`-host scope, sort dropdown) teleported to body. Reka
modal trap disables their pointer-events. Same fix Phase 3 applied to
Settings |

Intentionally left for Phase 6: the global `.manager-dialog` CSS in
`GlobalDialog.vue` `<style>` and the matching `class="manager-dialog"`
on `BaseModalLayout`. Removing them here is a cascade-order risk (they
currently override `BaseModalLayout` size="lg" 1280px cap with 1724px);
Phase 6 owns the CSS-overrides cleanup pass.

### Tests

-
`src/workbench/extensions/manager/composables/useManagerDialog.test.ts`
**(new)** — 5 tests: renderer flip + Manager sizing, non-modal,
`initialTab` forwarding, `initialPackId` forwarding, `hide()` closes.

## Verification

### DOM probes (local dev, `useManagerDialog().show()` against ComfyUI
on :8189)

| Probe | Result |
| --- | --- |
| Manager dialog node | `[role="dialog"]` at z-1804, rect 1724×794 —
`contentClass` applied exactly |
| SingleSelect listbox | Teleported to `BODY > DIV > listbox`, z-3000 —
escapes Reka `overflow-hidden` |
| SearchAutocomplete | Uses Reka `ComboboxPortal` (`z-3000`,
`position="popper"`) — same teleport guarantee by construction |
| Stacked Reka confirm over Manager | Manager z-1804, confirm z-1806 —
vRekaZIndex orders correctly |
| ESC on stacked confirm | Top confirm closes, Manager remains open |

Screenshots from local verification will be attached as PR comments.

## Quality gates

- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — clean for touched files
- [x] `pnpm format` — applied
- [x] `pnpm test:unit` (touched + adjacent):
  - `useManagerDialog.test.ts` — 5/5
  - `src/workbench/extensions/manager/composables/` — 156/156
- `useSettingsDialog.test.ts` + `src/components/dialog/` (Phase 3
regression net) — 86/86
- [ ] CI Playwright matrix
- [x] Manual verification on a backend (ComfyUI :8189)

## Public API impact

None. `useManagerDialog().show(initialTab?, initialPackId?)` keeps the
same signature.

## Out of scope (later phases)

- `ConfirmDialog` callers — Phase 5 (FE-577)
- Removing PrimeVue `Dialog`/`<style>` overrides in `GlobalDialog.vue`
(incl. `.manager-dialog`) — Phase 6 (FE-578)
- Designer pass on Manager dimensions (FE-576 acceptance #1) — owner
Jaewon, async with this PR

## Review focus

1. **Sizing translated literally** — `contentClass` mirrors the existing
`.manager-dialog` CSS rule (height + max-width + max-height + 3000px
breakpoint). Net visible should be byte-identical to today. Worth a
designer pass per FE-576 acceptance criteria.
2. **`modal: false` rationale** — same as Phase 3 Settings: Manager's
PrimeVue-overlay children break under Reka modal focus trap. Acceptance
#2 ("Install/uninstall flows behave identically") is preserved because
no overlay component changed; only the outer dialog renderer.
3. **Phase 6 deferred cleanup** — `.manager-dialog` CSS rule +
`class="manager-dialog"` on `BaseModalLayout` are kept on purpose. They
override `BaseModalLayout size="lg"`'s 1280px cap with 1724px on small
viewports; removing them naively regresses width. Phase 6 will replace
via `size` prop or new variant.

## screenshot 

<img width="1440" height="828" alt="keybinding-panel"
src="https://github.com/user-attachments/assets/c8e4ae15-860c-4f61-b48f-795f5e24912d"
/>
<img width="1440" height="828" alt="nested-modify-keybinding"
src="https://github.com/user-attachments/assets/a6890183-be0f-4831-919e-4edb2ee2bb04"
/>
<img width="1920" height="992" alt="phase4-manager-node-pack-dropdown"
src="https://github.com/user-attachments/assets/e4f97486-8b89-408a-9f83-92fbbfd5c29b"
/>
<img width="1440" height="828" alt="settings-dialog-reka"
src="https://github.com/user-attachments/assets/7764ac51-703e-40a9-9ff8-af664b9ad11a"
/>


## Test plan

- [x] Unit: 5/5 new + 156/156 manager composables + 86/86 adjacent
- [ ] CI: full Vitest + Playwright matrix
- [x] Manual on a backend:
- Open Manager (nav, search filter, install button visible, scroll grid)
- SingleSelect (Node Pack) dropdown not clipped by dialog
`overflow-hidden`
  - Stacked Reka confirm over Manager renders above with correct z-index
  - ESC closes only the top dialog

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-01 13:41:53 +09:00
Comfy Org PR Bot
9e32b7db51 1.46.6 (#12535)
Patch version increment to 1.46.6

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-30 06:33:16 +00:00
Deep Mehta
9813eee22f fix(website): make comfy.org favicon Google-compliant (#12536)
## Summary

Fix the comfy.org favicon declaration so Google Search can actually
refresh it — the logo has shown the old Astro starter chevron in Google
results for weeks even though the icon files were fixed in May.

## Changes

- **What**: The site declared two SVG favicons gated by
`prefers-color-scheme` media queries, no PNG favicon, and a
`favicon.ico` with only a 64×64 image. Google Search's favicon pipeline
can't resolve a single valid icon from that, so on recrawl it keeps the
last good cached icon (the old Astro chevron) instead of the current
mark. Replaced with the standard, checker-clean set:
- `favicon.svg` — single adaptive SVG (light/dark swap via internal CSS)
replacing the two media-query SVG links
- `favicon-96x96.png` — explicit desktop PNG at a Google-recommended
size (multiple of 48)
  - `favicon.ico` — rebuilt to contain 16/32/48 (was 64×64 only)
  - `site.webmanifest` + `web-app-manifest-192x192.png` / `-512x512.png`
- `<head>` simplified to RealFaviconGenerator's recommended markup +
`theme-color`
- **Breaking**: none. Pure static-asset + `<head>` change, no runtime
code touched.

## Review Focus

- This resolves the 3 errors + 3 warnings RealFaviconGenerator reported
for comfy.org (2 SVG favicons / no desktop PNG / ICO missing 16-32-48 /
no web manifest).
- The old `favicon-light.svg` and `favicon-dark.svg` are left in place
(now unreferenced) to avoid deleting assets in this PR — safe to remove
in a follow-up if desired.
- **Post-merge, required to actually fix Search**: in Google Search
Console, run URL Inspection → Request Indexing on `https://comfy.org/`.
The Search favicon only refreshes when Googlebot recrawls the homepage;
without a clean icon set + a recrawl nudge it will not self-correct.

## Before / After

### Before
<img width="939" height="388" alt="image"
src="https://github.com/user-attachments/assets/5fa95fd6-2248-4ed9-921e-9c516f4c0c3e"
/>

### After
<img width="1145" height="447" alt="image"
src="https://github.com/user-attachments/assets/dc33a99d-f7f6-41d4-a83d-03b3b99d0b0d"
/>

## Screenshots (if applicable)
2026-05-30 05:21:29 +00:00
Dante
c7238dd395 fix: Remove duplicate app workflow validation (#12208)
## Summary

`extra.linearData.inputs` validation rejected the entire workflow
whenever a single entry didn't match the strict `z.union([3-tuple,
2-tuple])`, surfacing as `Failed to load shared workflow: invalid
workflow data` for some published cloud shares (e.g.
`share=21e32125c692`).

## Changes

- **What**: Shared workflow load now matches the regular load policy at
`scripts/app.ts:1191-1198` — if Zod validation fails, fall back to the
raw `workflow_json` instead of throwing. Share service no longer runs
schema validation directly; `app.loadGraphData()` continues to validate
and apply the same raw fallback under `Comfy.Validation.Workflows`.
- **Breaking**: None — workflows that previously failed to load through
the share path now load with the same permissive behavior as workflows
opened through any other entry point.

## Review Focus

The original approach added a `tolerantArray` combinator to drop bad
`linearData.inputs` entries inside the schema. After review, the cleaner
direction is to keep schemas strict as the canonical spec and apply
tolerance at the consumer boundary — which `scripts/app.ts:1191-1198`
already does for the regular load path (`graphData = validatedGraphData
?? graphData` with the comment "Ideally we should not block users from
loading the workflow"). This PR aligns the share path with that existing
policy and removes a cross-path inconsistency rather than introducing a
new schema-level concept.

Consequences:
- Schemas in `workflowSchema.ts` stay unchanged (canonical spec).
- The `extra.*` shape drift problem is now solved generally, not just
for `linearData.inputs` — future fields hitting the same class of issue
will load instead of blocking.
- `validateComfyWorkflow` still logs the Zod error via `console.warn`
for debugging.

## Tests

- Existing schema tests stay strict (3-tuple, 2-tuple unions reject bad
shapes).
- New regression test in `workflowShareService.test.ts`: a
`workflow_json` that passes the share response envelope
(`record<unknown>`) but fails `ComfyWorkflowJSON` is returned raw, not
thrown.

### current prod

<img width="1144" height="565" alt="Screenshot 2026-05-13 at 1 29 00 PM"
src="https://github.com/user-attachments/assets/b1abf45b-a588-4ef5-a9ec-d14bd1096b6d"
/>

### test
`/?share=21e32125c692`

<img width="765" height="826" alt="Screenshot 2026-05-13 at 1 27 40 PM"
src="https://github.com/user-attachments/assets/96a3c405-e5fe-4732-9047-fed90768e6f6"
/>

## Follow-up

Structural issue separately: cloud OpenAPI defines `workflow_json` as
opaque `z.record(z.unknown())`
(`packages/ingest-types/src/zod.gen.ts:137,325,375,397,457`), so ingest
schema CI cannot enforce inner shape. Matt Miller is driving the
lift-schema-to-core-OpenAPI direction; tracked separately, not in this
PR.

Fixes FE-690.
2026-05-30 05:20:50 +00:00
Alexander Brown
cedb4e6761 chore: drop no-op security overrides for brace-expansion and ws (#12533)
## Summary

Removes three security overrides added in #12345/#12501 that are now
no-ops: every consumer in the tree already pulls a version at or above
the GHSA-patched release.

## Changes

- **What**: Drops `brace-expansion@^1.0.0`, `brace-expansion@^2.0.0`,
and `ws@^8.0.0` overrides from
[pnpm-workspace.yaml](pnpm-workspace.yaml).
- **Breaking**: None.

| Override removed | Patched at | Natural resolution | GHSA |
|---|---|---|---|
| `brace-expansion@^1.0.0: ^1.1.13` | 1.1.13 | 1.1.15 |
[GHSA-f886-m6hf-6m8v](https://github.com/advisories/GHSA-f886-m6hf-6m8v)
|
| `brace-expansion@^2.0.0: ^2.0.3` | 2.0.3 | 2.1.1 |
[GHSA-f886-m6hf-6m8v](https://github.com/advisories/GHSA-f886-m6hf-6m8v)
|
| `ws@^8.0.0: ^8.20.1` | 8.20.1 | 8.21.0 |
[GHSA-58qx-3vcg-4xpx](https://github.com/advisories/GHSA-58qx-3vcg-4xpx)
|

The remaining security overrides (`lodash`, `yaml`, `minimatch@^9`,
`minimatch@^10`, `ajv@^8`) were re-tested and are still required —
without them dependabot-vulnerable versions (`lodash@4.17.23`,
`yaml@2.7.1`, `minimatch@9.0.1`, `minimatch@10.2.1`,
`ajv@8.12.0`/`8.13.0`) resolve into the tree.

## Review Focus

- The `brace-expansion@5.x` and `minimatch@5.x`/`8.x` lines in the
lockfile naturally resolve to safe versions (`5.0.6`, `5.1.9`, `8.0.7`)
on `main`, so no new overrides are needed alongside this removal.
- Verified by removing each override one-by-one and inspecting `pnpm why
-r`.

## Verification

- `pnpm install` — clean
- `pnpm typecheck` (apps/website + root) → 0 errors
- `pnpm test:unit` (apps/website) → 89/89 passing
- `pnpm lint` (root) → 0 errors (3 pre-existing warnings)
2026-05-29 23:34:37 +00:00
imick-io
13e67561cf fix(website): tweak gallery contact heading and enterprise card color (#12530)
## Summary
- Add `<br>` after "Built something cool with ComfyUI?" in the gallery
contact heading (en + zh-CN) so the Submit link wraps to a new line.
- Switch the Enterprise product card background from
`bg-illustration-forest` to `bg-secondary-cool-gray`.

## Test plan
- [ ] Visit the gallery contact section and confirm the heading wraps
after "ComfyUI?" in both English and Chinese.
- [ ] Verify the Enterprise card on the product cards section renders
with the cool gray background.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-29 21:43:36 +00:00
imick-io
a0411d9beb refactor(website): centralize gallery items into src/data/gallery.ts (#12526)
## Summary

Establish a single source of truth for gallery items. Previously the
items array and the `GalleryItem` interface lived inline in
`GallerySection.vue`, with three sibling components importing the type
*out of a `.vue` file* — coupling pure data and types to a presentation
component.

- **One canonical list.** Items now live in `src/data/gallery.ts`
alongside the `GalleryItem` interface.
- **Per-item visibility without deletion.** New optional `visible?:
boolean` field on each item
- **Stable identity.** Each item now carries a required `id` kebab-slug
derived from its title.
- **lookup helper.** `getGalleryItemById(id)` for callers that want a
single item.

The sibling gallery components (`GalleryCard`, `GalleryDetailModal`,
`GalleryItemAttribution`) now import the `GalleryItem` type from
`../../data/gallery` instead of from the `.vue` file.

## Scope note

I also audited the rest of the website for other surfaces that should
consume the centralized data.

**I need this refactor for the new page `models` coming soon.**

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-29 21:14:07 +00:00
AustinMroz
e97c4b6ab9 Remove flake screenshot (#12529) 2026-05-29 21:08:46 +00:00
Comfy Org PR Bot
f830314429 1.46.5 (#12516)
Patch version increment to 1.46.5

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-29 19:56:09 +00:00
jaeone94
fb58a76a53 fix: preserve validation errors on execution start (#12493)
## Summary

Preserve validation node errors and their overlay when a valid active
root starts execution, so partial workflow runs no longer hide
validation failures.

## Changes

- **What**: Split execution-start clearing from full error clearing;
`execution_start` now clears transient execution/prompt state without
clearing validation `lastNodeErrors`.
- **What**: Keep the ErrorOverlay open when validation errors are still
present, and show it for successful prompt responses that include
`node_errors`.
- **Dependencies**: None.

## Review Focus

Please check the error-clearing boundary between prompt
submission/workflow changes and WebSocket `execution_start`. Full
clearing still happens through `clearAllErrors`; execution start now
uses the narrower clearing path and only dismisses the overlay when
there are no validation node errors to show.

Linear: FE-851

## Red-Green Verification

- Red: `76bcf34c4 test: add failing validation error preservation e2e`
- Green: `9766172ea fix: preserve validation errors on execution start`
- Follow-up: `321c95aba fix: keep validation error overlay during
execution start`
- Coverage: `7b5fab577 test: cover prompt node error overlay`

## Test Plan

- `pnpm exec vitest run src/scripts/app.test.ts`
- `pnpm exec vitest run src/stores/executionStore.test.ts`
- `pnpm exec vitest run src/scripts/app.test.ts
src/stores/executionStore.test.ts --coverage`
- `pnpm format:check -- src/stores/executionErrorStore.ts
src/stores/executionStore.ts src/stores/executionStore.test.ts
src/scripts/app.ts src/scripts/app.test.ts
browser_tests/fixtures/helpers/ExecutionHelper.ts
browser_tests/tests/execution.spec.ts`
- `pnpm exec oxlint src/stores/executionErrorStore.ts
src/stores/executionStore.ts src/stores/executionStore.test.ts
src/scripts/app.ts src/scripts/app.test.ts
browser_tests/tests/execution.spec.ts --type-aware`
- `pnpm typecheck`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://127.0.0.1:5175 pnpm
exec playwright test browser_tests/tests/execution.spec.ts:132`

## Screenshots (Before/After)
Before


https://github.com/user-attachments/assets/04a212b6-66f9-4c77-9056-58bdc642d96e

After


https://github.com/user-attachments/assets/db7813c7-bf8a-4e19-9b66-7f49fd01c305
2026-05-29 17:20:54 +00:00
AustinMroz
dda9822a93 Fix interrupted audio playback from assets panel (#12425)
Under some circumstances (Only firefox+FLAC outputs for me, but reliably
reproducible), clicking the play button on audio outputs in the assets
sidebar tab will fail to start playback. This appears to be caused by
unusual interactions between blob urls, `preload="metadata"`, and FLAC
not defining total content length in the header.

Instead of managing the lifecycle of a blob url, the real audio source
is left in place and caching can be done on the browser side.

I put some extensive time into trying to find a regression test that
works on chromium, but did not see results and decided it's better this
be merged without a test than never get fixed.
2026-05-29 16:59:30 +00:00
AustinMroz
b7990f7645 Fix ghost links on IO remove slot (#12473)
Context menu operations on subgraph IO slots only set the foreground
canvas as dirty, so links would visually persist until a different
operation caused a background draw.
2026-05-29 16:57:52 +00:00
jaeone94
79f2904937 Add special runtime error messaging (#12466)
## Summary

This PR extends the error catalog with targeted runtime error messaging
for common workflow execution failures.

The goal is to show clearer, user-facing copy in the error panel while
preserving the raw API `message` and `details` fields on each error
item. The resolver continues to add display-only fields (`displayTitle`,
`displayMessage`, `displayDetails`, `displayItemLabel`, `toastTitle`,
`toastMessage`) so the UI can render friendlier messages without
changing the underlying error contract.

## What This PR Targets

This PR adds catalog handling for common runtime failure categories,
including:

- General execution failure fallback for uncataloged node runtime errors
- Content blocked / safety check failures
- Credits, subscription, access, and sign-in related failures
- Rate limit and busy-server style failures
- Runtime timeout, stalled generation, and preparation failure cases
- Server stopped / unavailable cases
- Out-of-memory failures
- Image load failures
- Invalid CLIP input failures
- Invalid prompt and invalid workflow request failures
- Request/start/end failure cases
- Model access and model download failures
- Unexpected service failures

Unknown node execution errors resolve to the general runtime fallback:

- Title: `Execution failed`
- Message: `Node threw an error during execution.`
- Item label: node name
- Toast title: `{nodeName} failed`
- Toast message: `This node threw an error during execution. Check its
inputs or try a different configuration.`


## Implementation Notes

### Resolver Split

The previous resolver file was doing too much, so this PR splits
source-specific catalog logic into smaller modules:

- `catalogIds.ts` defines FE-resolved catalog IDs that normalize
multiple sources or do not map 1:1 to an API error type.
- `catalogI18n.ts` owns shared translation/fallback helpers.
- `validationErrorResolver.ts` keeps validation catalog logic.
- `promptErrorResolver.ts` keeps prompt-specific catalog logic and
handles non-node-scoped failures before falling back to prompt-specific
keys.
- `executionErrorResolver.ts` handles node-scoped runtime errors.
- `runtimeErrorMatcher.ts` owns conservative runtime error matching.
- `runtimeErrorCopy.ts` builds runtime display/toast fields from catalog
IDs and fallback copy.
- `missingErrorResolver.ts` keeps the existing missing-model/node/media
group display copy.

`errorMessageResolver.ts` is now a thin facade over these smaller
resolvers.

### Conservative Matching

Runtime errors can share generic exception labels, so this PR keeps
matching narrow: exact strings or stable prefixes only. The matcher is
ordered, and the first matching rule wins, so specific user-actionable
failures are checked before broader fallbacks.

The matching is intentionally not fuzzy. For example, arbitrary messages
that merely mention moderation terminology are not treated as
content-blocked unless they match one of the known client-visible
failure strings. This avoids false positives while covering the targeted
high-volume cases.

### Raw Detail Preservation

Some cataloged errors keep the original message as `displayDetails` when
it contains useful troubleshooting context. This is display-only; the
raw API `message` and `details` fields remain unchanged on the original
error item.

## Out Of Scope / Follow-Ups

This PR does not redesign the error overlay or right-side error panel
UI. It only provides the resolved fields those surfaces can consume.

Planned follow-up work remains:

- Regroup execution errors by message/catalog type where appropriate
- Error overlay copy/layout updates for single vs multiple errors
- Right panel visual design updates
- Rendering `displayItemLabel` in the revised panel design
- More specific UI actions for auth, retry, and similar flows
- Broader fallthrough observability if we decide it is needed

Non-English locale files are intentionally not updated here. The
repository uses `src/locales/en/main.json` as the source of truth and
the release i18n sync flow generates the other locale files.

## Validation

Ran successfully:

- `pnpm format`
- `pnpm lint:unstaged`
- `pnpm typecheck`
- `pnpm test:unit`
- `pnpm vitest run
src/platform/errorCatalog/errorMessageResolver.test.ts`
- `pnpm vitest run
src/platform/errorCatalog/errorMessageResolver.test.ts
src/components/rightSidePanel/errors/useErrorGroups.test.ts`
- `pnpm build`
- `pnpm knip`

Notes:

- `pnpm build` still prints existing asset/icon and dynamic import
warnings, but exits successfully.
- `pnpm knip` exits successfully and still reports the existing
`flac.ts` tag hint.

## Screenshots (Before / After) 

[Diff.zip](https://github.com/user-attachments/files/28300639/Diff.zip)
Uploaded as a ZIP file due to the image upload limit
2026-05-29 13:48:44 +00:00
Dante
c57944f315 fix: hide duplicate LiteGraph Resize/Collapse/Expand entries from Vue node menu (FE-867) (#12487)
## Summary

https://linear.app/comfyorg/issue/FE-867/bug-node-expand-menu-doesnt-work-nodes-immediately-collapse-after
Recreates #12175 on a fresh `main` base (original branch's CI failed
only because its `frontend-dist` artifact had expired — not a code
issue). Original work by @christian-byrne / Glary-Bot, cherry-picked
here so it can land while he's offline.

The Vue right-click "More Options" node menu shows duplicates for
collapse/expand functionality:

- **Vue source**: `Minimize Node` / `Expand Node` (works)
- **LiteGraph source**: `Resize`, `Collapse`, `Expand` (silently no-op
in this menu — the converter wrapper invokes
`LGraphCanvas.onMenuNodeCollapse` without the `node` arg it expects)

Suppress the LiteGraph duplicates in `convertContextMenuToOptions` by
matching the built-in **callback identity**
(`LGraphCanvas.onMenuResizeNode`, `LGraphCanvas.onMenuNodeCollapse`),
not the raw label. Matching by identity avoids accidentally hiding
extension-provided items that share those labels.

Also align `CORE_MENU_ITEMS` / `MENU_ORDER` on the Vue label `Expand
Node` so the toggled Minimize/Expand pair sorts correctly.

## Scope of suppression

Only the Vue node menu (via `convertContextMenuToOptions`) is affected.
The raw `LGraphCanvas.getNodeMenuOptions` output is untouched, so:

- The legacy right-click menu (`Comfy.UseNewMenu` disabled) still has
`Collapse` / `Resize`.
- `useLoad3d.ts`, which calls `new
LiteGraph.ContextMenu(app.canvas.getNodeMenuOptions(node), ...)`, is
unaffected.
- Extensions that monkey-patch `getNodeMenuOptions` continue to receive
the full option list.

## Tests

- `contextMenuConverter.test.ts`: covers both that built-in entries are
dropped by identity AND that extension-provided items with the same
labels survive.
- E2E `selectionToolboxMoreActions.spec.ts`: asserts the Vue "More
Options" menu shows `Minimize Node` but no `Resize`/`Collapse`/`Expand`.
- `pnpm typecheck` clean.

Supersedes #12175.

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-29 22:28:08 +09:00
Terry Jia
26dfa5c547 refactor: drop redundant rotation field from Load3D camera_info (#12515)
## Summary
As discussed in slack, we would like to remove rotation field
2026-05-29 09:02:01 -04:00
Terry Jia
07d7b0c84f refactor(load3d): simplify model_info schema and align naming to model_3d (#12519)
## Summary
1. Drop multi-object identity fields (uuid, name, type) since
multi-object support is not yet in scope, these are renderer-side
identifiers with no link back to the ComfyUI asset.
2. Drop rotation and matrix as redundant encodings of the same
transform, applying the same Jacob-redundancy point used on camera_info
3. Rename ModelTransform -> Model3DTransform and ModelInfo ->
Model3DInfo to align with the existing Load3D / File3D / model_3d naming
and disambiguate from AI 'model' (per Alexis).
4. The output key also moves from model_info to model_3d_info to match.
2026-05-29 09:01:32 -04:00
jaeone94
d86483a6af refactor: consolidate middle-button pan handling (#12491)
## Summary

Refactors middle mouse button pan handling around the intent of #11409,
dropping the outdated implementation details from that PR and aligning
the core behavior with the current main branch.

## Changes

- **What**: Centralized phase-specific middle mouse button handling in
`src/base/pointerUtils.ts`, added a shared Vue widget forwarding helper,
and updated canvas, LiteGraph, Vue node, and mask editor call sites to
use the same semantics.
- **Breaking**: None expected. This keeps existing middle-click pan
behavior while making pointerdown, pointermove, pointerup, and auxclick
checks explicit for their event phases.
- **Dependencies**: None.

## Review Focus

This PR is intentionally narrower than #11409. That PR had the right
goal, but its implementation became outdated against main: mask editor
tests now have helper coverage on main, Vue node/widget code has
shifted, and a blanket replacement with `isMiddlePointerInput` would
lose the bitmask behavior needed during pointermove drags.

The core difference is that this PR preserves the useful part of #11409,
namely removing scattered ad-hoc MMB checks, while avoiding stale
changes that no longer fit the current codebase.

Key behavior changes:

- `isMiddlePointerInput` is the conservative pointerdown-style check:
changed middle button or strict middle-only `buttons === 4`.
- `isMiddleButtonHeld` handles pointermove-style held-button bitmasks so
chorded drags with the middle button still pan.
- `isMiddleButtonEvent` handles pointerup/auxclick-style changed-button
events.
- Call sites now choose the phase-specific helper directly instead of
routing through an event-type dispatcher.
- String and markdown widgets now share
`forwardMiddleButtonToCanvas(...)` instead of duplicating three pointer
listeners each.
- The widget helper intentionally keeps the existing
`app.canvas.processMouseDown/Move/Up` forwarding route and only
centralizes the duplicated listener logic.
- Mask editor pan handling, Vue node pointer forwarding, graph canvas
pan forwarding, LiteGraph middle-click checks, input indicators, and
transform settling now use the centralized helpers.

Coverage added or updated:

- Unit coverage for middle-button helper semantics, including chorded
pointermove drags and pointercancel held-bit behavior.
- Unit coverage for widget forwarding helper down/move/up routing.
- Regression coverage for canvas, mask editor, Vue node media preview,
and transform-settling pointer handling.
- Browser coverage for middle-click drag panning on a Vue node, a
multiline string widget, and the mask editor canvas.

Validation run:

- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm test:unit src/base/pointerUtils.test.ts
src/renderer/extensions/vueNodes/widgets/utils/forwardMiddleButtonToCanvas.test.ts
src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.test.ts
src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.test.ts
src/renderer/core/canvas/useCanvasInteractions.test.ts
src/composables/maskeditor/useToolManager.test.ts
src/renderer/core/layout/transform/useTransformSettling.test.ts
src/composables/node/useNodeImage.test.ts
src/composables/node/useNodeAnimatedImage.test.ts
src/components/graph/SelectionToolbox.test.ts
src/lib/litegraph/src/LGraphCanvas.slotHitDetection.test.ts`
- `pnpm typecheck:browser`
- `pnpm test:browser:local
browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts
browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts
browser_tests/tests/maskEditor.spec.ts --project chromium --grep
"Middle-click drag"`
- Commit hook: staged file format/lint, `pnpm typecheck`

## Screenshots (if applicable)

Not applicable; this is interaction behavior covered by unit and browser
tests.
2026-05-29 16:12:12 +09:00
Alexander Brown
671e0cecdf chore: upgrade ESLint to v10.4.0 (#12517)
## Summary

Upgrade ESLint from v9 to v10.4.0, bump compatible plugins/configs, and
fix the 32 violations introduced by the new `eslint:recommended` rules.

## Changes

- **What**:
  - Catalog bumps in `pnpm-workspace.yaml`:
    - `eslint` 9.39.1 → 10.4.0
    - `@eslint/js` 9.39.1 → 10.0.1
    - `typescript-eslint` 8.49.0 → 8.60.0
    - `eslint-plugin-vue` 10.6.2 → 10.9.1
    - `eslint-plugin-import-x` 4.16.1 → 4.16.2
    - `eslint-plugin-unused-imports` 4.3.0 → 4.4.1
    - `@intlify/eslint-plugin-vue-i18n` 4.1.1 → 4.5.0
- Fixed 4 `preserve-caught-error` violations by passing `{ cause }` to
rethrown errors in `useMaskEditorLoader.ts` and `usePainter.ts`.
- Fixed 28 `no-useless-assignment` violations across litegraph
(`LGraph.ts`, `LGraphCanvas.ts`, `LiteGraphGlobal.ts`, `polyfills.ts`)
and seven other files (`colorUtil.ts`, `linkFixer.ts`, `usePaste.ts`,
`audioService.ts`, `versionUtil.ts`, `subscriptionCheckoutTracker.ts`,
`ShiftClick.test.ts`) by removing dead initializers or redundant writes.
- Removed two now-unnecessary `@ts-expect-error` directives on
`importX.flatConfigs.*` (newer plugin ships correct types).
- **Breaking**: None for runtime; consumers using ESLint locally must
use Node ≥20.19/22.13/24 (already required by repo `engines.node:
">=25"`).

## Review Focus

- Audit of the [v10 migration
guide](https://eslint.org/docs/latest/use/migrate-to-10.0.0) found no
other patterns in this codebase to address (no `eslint-env` comments, no
removed `context.*`/`SourceCode` APIs, no `RuleTester` usage, no
affected rule configs).
- `no-useless-assignment` fixes were chosen conservatively: prefer
keeping the variable with a typed declaration over deleting the
statement, except where the assignment was clearly dead after the last
read.
- Per-file diffs in litegraph (especially `LGraphCanvas.ts`) — worth a
glance to confirm intent.

## Verification

- `pnpm exec eslint --version` → v10.4.0
- `pnpm exec eslint src` → 0 errors
- `pnpm typecheck` → clean
- Unit tests for every touched file pass (319/319 in spot checks:
`colorUtil`, `linkFixer`, `audioService`, `usePaste`, `usePainter`,
`versionUtil`, `LGraph`, `litegraph`, all `LGraphCanvas.*`,
`ShiftClick`).

Co-authored-by: Amp <amp@ampcode.com>
2026-05-28 23:25:45 -07:00
guill
e02ee17d3d fix(website): memoize GitHub stars fetch to one call per build (#12495)
*PR Created by the Glary-Bot Agent*

---

## Problem

The GitHub star badge silently disappears from the comfy.org navigation.
Verified by curl-ing the live homepage:

```
props="{...&quot;github-stars&quot;:[0,&quot;&quot;]}"
```

`SiteNav.vue` only renders the badge when `githubStars` is truthy, so an
empty string hides it.

## Root cause

`apps/website/src/layouts/BaseLayout.astro` `await`s
`fetchGitHubStars('Comfy-Org', 'ComfyUI')` in its frontmatter. Astro
evaluates layout frontmatter **per rendered page** in SSG. With 379
pages (46 source `.astro` files × locales/dynamic routes), the
unauthenticated GitHub REST endpoint is called hundreds of times per
build, blasting past the 60 req/h anonymous rate limit. Once GitHub
returns 403 the existing `try/catch` returns `null`, `githubStars`
becomes `''`, and the badge vanishes — with no log line to indicate why.

## Fix

Cache the in-flight promise in a module-scope `Map` keyed by
`${owner}/${repo}` so every page in a single build shares one request.
Already-resolved counts stay cached, and the existing
`WEBSITE_GITHUB_STARS_OVERRIDE` env-var escape hatch still
short-circuits first.

While in the file:
- Pass an injectable `fetchImpl` so tests can stub without
`vi.spyOn(globalThis, 'fetch')`.
- Replace the implicit-`any` `data.stargazers_count ?? null` with a
narrow `readStargazerCount(data: unknown)` guard.
- In `BaseLayout.astro`, change `rawStars ? ...` to `rawStars !== null ?
...` so a hypothetical 0-star repo wouldn't be hidden (the old check
treated 0 as missing).

## Verification

- `pnpm --filter @comfyorg/website test:unit` → 89/89 pass (5 new test
cases: memoization, per-key isolation, non-2xx → null, throw → null,
override).
- `pnpm typecheck:website` → 0 errors.
- `pnpm format:check` → clean.
- `pnpm --filter @comfyorg/website build` → 379 pages built; with no
override set, output HTML contains `"github-stars":[0,"115K"]` (the live
count) on every page; with `WEBSITE_GITHUB_STARS_OVERRIDE=110000`, it
contains `"110K"` and `fetch` is never called.
- Playwright on the local preview confirms the badge renders at the
top-right of the nav with `aria-label="ComfyUI on GitHub — 110K stars"`.

## Scope

102 lines changed across 3 files (40 non-test). Deliberately leaves the
broader "snapshot fallback / build-data source" refactor to the existing
`codex/website-github-stars-once` branch — this PR just unblocks the
user-visible symptom.

## Screenshots

![Site navigation showing the 110K GitHub star badge restored next to
the DOWNLOAD LOCAL / LAUNCH CLOUD
buttons](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/d921afa7b2cb2a9088080967634aeb3e2e67ee09a8ac13f6e434c1c7589434c1/pr-images/1779918781152-ccaeab7f-9150-4d8b-ae4e-f20bbf49091b.png)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-29 03:04:41 +00:00
Alexis Rolland
dc1bc4c9f8 Update utils category to utilities (#12498)
## Summary

Update frontend only nodes categories to consolidate utility nodes into
a `utilities` category (instead of utils). Paired with changes done in
the core repo here: https://github.com/Comfy-Org/ComfyUI/pull/14145

## Changes

- **What**:
  - Rename frontend only nodes category from `utils` to `utilities`
- Move frontend only Primitive node from `utils` to
`utilities/primitive`

## Screenshots

<img width="563" height="352" alt="image"
src="https://github.com/user-attachments/assets/a768ec48-fb87-4fa3-934a-bd593bb35f3d"
/>

<img width="1181" height="773" alt="image"
src="https://github.com/user-attachments/assets/a3e09e25-3412-4d23-abe8-220948b87258"
/>
2026-05-29 02:03:44 +00:00
AustinMroz
767bd17077 Fix "open tutorial button" not working in templates (#12511)
The "open tutorial" button only existed in the DOM when the template
card as actively hovered. For reasons I can not comprehend (probably
overzealous pointer handlers somewhere), the act of clicking on the
button would fire a mouseleave event. This caused the button to
disappear for the exact moment it was clicked alike to a mischievous
dondurma vendor.

This is resolved by keeping the button always in DOM, but making it
invisible when the card isn't hovered.

The PR also removes a deeply nested `v-bind='$attrs'`. I'm assuming it
must be a mistake that attributes applied to the entire template
selector dialogue would be bound to every deeply nested tutorial button
on individual workflow cards.
2026-05-28 23:38:15 +00:00
imick-io
0d0231453a fix(website): stack role title above team and location on careers list (#12510)
## Summary
- Long role titles wrapped awkwardly next to the inline department label
on the careers list, especially on narrow viewports.
- Restructured the role link so the title sits on its own row with the
arrow icon on the right, and the department + location wrap together on
a metadata row beneath (16px gap between them).

## Test plan
- [ ] Open `/careers` on mobile width and confirm long titles (e.g.
"Senior Software Engineer, Frontend") no longer collide with the
department label.
- [ ] Confirm desktop layout still reads cleanly.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:49:53 +00:00
Luke Mino-Altherr
cc29a3d72d Add unreviewed merge detector for SOC 2 compliance (#12497)
## Summary

- Adds a GitHub Actions workflow that detects PRs merged to `main`
without an approving review
- Creates tracking issues in
[`Comfy-Org/unreviewed-merges`](https://github.com/Comfy-Org/unreviewed-merges)
(private) for SOC 2 audit purposes
- Supports inline justification via `Justification: <reason>` in PR body
or comments

## How it works

Triggers on `push` to `main`. Uses the GitHub API to find the associated
PR and check for approving reviews. If none found, creates a tracking
issue with the `unreviewed-merge` label. No code checkout required — API
calls only.

## Test plan

- [ ] Verify workflow YAML is valid
- [ ] Merge a test PR without approval and confirm issue creation in
`unreviewed-merges` repo

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-05-28 19:22:46 +00:00
AustinMroz
62430d6311 Remove unneeded overrides, add new ones (#12501)
Adds additional version overrides to handle 16 of the remaining 18
dependabot alerts.

Removes overrides which are no longer needed.
2026-05-28 19:18:54 +00:00
jaeone94
dc8471c6d3 fix: show workflow refresh loading state (#12509)
## Summary

Adds visible loading feedback to the Workflows sidebar refresh button so
users can tell when a workflow sync request is in flight.

## Changes

- **What**: Exposes `isSyncLoading` from the workflow store and binds
the Workflows sidebar refresh button to disabled, `aria-busy`, and
spinning icon states while sync is pending.
- **What**: Adds stable E2E selectors for the workflows refresh button
and covers the loading state with unit and browser tests.
- **Dependencies**: None.

## Review Focus

Please verify the refresh control behavior while
`/api/userdata?dir=workflows` is pending, especially that the button is
disabled, exposes busy state, and returns to idle after sync completes.

## Validation

- `pnpm format`
- `pnpm test:unit
src/components/sidebar/tabs/BaseWorkflowsSidebarTab.test.ts`
- `pnpm test:browser:local browser_tests/tests/sidebar/workflows.spec.ts
-g "Shows loading state while refreshing workflows"`
- `pnpm lint`
- Commit hooks: `oxfmt`, `oxlint`, `eslint`, `typecheck`,
`typecheck:browser`

## Screenshots (if applicable)


https://github.com/user-attachments/assets/e8b893ae-a91d-45c9-81ea-adaf164de227
2026-05-28 17:31:42 +00:00
Comfy Org PR Bot
c070df72d4 1.46.4 (#12499)
Patch version increment to 1.46.4

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-28 17:19:09 +00:00
jaeone94
c3dc7f45d4 Fix load3d unused exported camera types (#12505)
## Summary

This PR fixes a `knip` failure introduced by exported Load3D camera
helper interfaces that are only used inside
`src/extensions/core/load3d/interfaces.ts`.

## Problem

`knip --cache` reports these exported types as unused exports:

- `CameraQuaternion`
- `CameraRotation`
- `CameraFrustum`

They are implementation details for the local `CameraState` interface
and are not imported outside the module.

## Fix

Remove `export` from those three interfaces and keep them as file-local
types. This keeps the `CameraState` shape unchanged while avoiding an
unnecessary public type surface.

## Validation

Ran successfully:

- `pnpm exec oxfmt --write src/extensions/core/load3d/interfaces.ts`
- `pnpm typecheck`
- `pnpm knip --cache`

Notes:

- `pnpm knip --cache` still reports the existing tag hints for `flac.ts`
and `apps/website/src/utils/video.ts`, but no longer reports the Load3D
unused exported types.
2026-05-28 17:05:39 +09:00
Terry Jia
c2ef961834 feat: output model_info from Load3D node (#12494)
Expose per-object gizmo transforms (uuid, name, type, position,
rotation, quaternion, scale, up, visible, matrix) as a new `model_info`
output on the Load3D node.

`GizmoManager.getModelInfo()` reads the live target object and the
Load3D widget `serializeValue` writes it into the node payload. The
payload is a list to support multiple objects later; the viewer
currently renders a single main object, so it emits a one-element list.

Requires backend Comfy-Org/ComfyUI#14144 (adds the `LOAD3D_MODEL_INFO`
type and the output socket).
2026-05-27 23:54:46 -04:00
Terry Jia
78c16368d7 feat: expose camera intrinsics in Load3D camera_info (#12492)
## Summary
Add quaternion, rotation, fov, aspect, near, far and orthographic
frustum bounds (left/right/top/bottom) to the camera state captured by
CameraManager.getCameraState(), so the Load3D camera_info output carries
enough information for backend nodes to fully reconstruct the camera.

https://github.com/Comfy-Org/ComfyUI/pull/14143

Quaternion and Euler rotation are serialized as plain objects to avoid
THREE.js private underscore-prefixed fields leaking into the payload.

<img width="1807" height="1333" alt="image"
src="https://github.com/user-attachments/assets/8c0a6ab9-be5f-40d1-8015-7bd0d6c731a5"
/>
2026-05-27 23:09:46 -04:00
Christian Byrne
8206022982 fix(subgraph): validate URL hash and redirect to root when subgraph missing (#12169)
*PR Created by the Glary-Bot Agent*

---

## Summary

Fix FE-559: browser forward/back to a deleted subgraph used to leave the
canvas on stale state (and sometimes triggered unrelated tab navigation)
because the subgraph id in the URL hash was looked up with no validation
or fallback.

## Changes

- **What**:
- Added `src/schemas/subgraphIdSchema.ts` — `zSubgraphId =
z.string().uuid()` + `isValidSubgraphId(value)` type guard, matching how
subgraph ids are persisted in `workflowSchema.ts` and generated by
`createUuidv4()`.
- `subgraphNavigationStore.navigateToHash()` now (a) validates the hash
with `isValidSubgraphId` before any lookup, (b) redirects to the root
graph (`router.replace('#' + root.id)` + `canvas.setGraph(root)`) when
the locator is malformed, missing from `root.subgraphs`, or still
unresolved after a workflow-load attempt.
- Replaced the `console.error('subgraph poofed after load?')` dead-end
with the same redirect helper.
- Re-ordered the "already on this graph" short-circuit so a stale canvas
reference to a now-deleted subgraph doesn't suppress the redirect.

## Review Focus

- TDD: 6 new tests in `subgraphNavigationStore.navigateToHash.test.ts`
cover valid navigation, deleted-subgraph hash, malformed (non-UUID)
hash, no-op when target equals current, empty-hash root case, and
stale-canvas recovery. 15 new tests in `subgraphIdSchema.test.ts` lock
down the validator.
- `redirectToRoot()` toggles `blockHashUpdate` while calling
`router.replace`, so the new redirect doesn't re-trigger `updateHash()`
and clobber the canvas state.
- Generalized validation: the new schema lives in `src/schemas/` and can
be reused anywhere a subgraph id crosses an untrusted boundary (URL,
IPC, etc.).

## Manual Verification

Ran ComfyUI backend (`--cpu --port 8188`) + frontend dev server, then
drove Playwright through three scenarios:

| Input hash | Result | Console |
|---|---|---|
| `#11111111-2222-4333-8444-555555555555` (UUID-shaped, non-existent) |
URL replaced with `#<root-id>` | `[subgraphNavigation] subgraph not
found: 11111111-…; redirecting to root graph` |
| `#not-a-valid-uuid` (malformed) | URL replaced with `#<root-id>` |
`[subgraphNavigation] invalid subgraph id in hash: not-a-valid-uuid;
redirecting to root graph` |
| `#aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee` (UUID-shaped, non-existent) |
URL replaced with `#<root-id>` | (same redirect message) |

Screenshot below shows the redirected viewport.

Fixes FE-559

## Screenshots

![ComfyUI canvas after a hash referencing a deleted subgraph was
rewritten to the root graph
hash](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/fe7f8846b3efdc95461cd63995dd10808073dd86c561eff9d8816742eb892687/pr-images/1778562546959-43f5ead4-3e13-45de-a0ac-988c3424368b.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12169-fix-subgraph-validate-URL-hash-and-redirect-to-root-when-subgraph-missing-35e6d73d3650819f840af1475b9f44d4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-28 00:34:42 +00:00
Dante
5f2b2f2e87 fix: show cloud models in IC-LoRA Loader Model Only node (FE-838) (#12488)
## Summary

### before
<img width="1107" height="958" alt="before-buggy"
src="https://github.com/user-attachments/assets/1fcbd909-e008-4bd3-967f-87cdabb2baf6"
/>

### after
<img width="1107" height="958" alt="after-fixed"
src="https://github.com/user-attachments/assets/0d3c6f3f-36d6-4556-bd29-b3826ae20216"
/>


The **IC-LoRA Loader Model Only** node (`LTXICLoRALoaderModelOnly`, from
ComfyUI-LTXVideo) didn't show cloud models from `supported_models.json`,
while the native **Load LoRA** node did.

## Changes

- **What**: Add `['loras', 'LTXICLoRALoaderModelOnly', 'lora_name']` to
`MODEL_NODE_MAPPINGS`. Whether a combo widget swaps to the cloud asset
browser is gated by `assetService.shouldUseAssetBrowser` →
`isAssetBrowserEligible`, which only returns true for node types
registered in `MODEL_NODE_MAPPINGS` (via `modelToNodeStore`). The custom
IC-LoRA loader was absent from that list, so its `lora_name` widget fell
back to the plain combo that lists only filesystem models — never the
cloud-injected ones.
- **Breaking**: none

## Review Focus

Root cause verified live on `cloud.comfy.org` (asset API enabled, custom
node installed) via CDP:
- `LoraLoaderModelOnly` (native) → registry `lora_name`, eligible `true`
→ cloud models shown
- `LTXICLoRALoaderModelOnly` (bug) → not in registry, eligible `false` →
cloud models missing
- After registering the mapping live → eligible `true`, category `loras`
→ cloud models shown

Same class of bug as FE-492 (custom loaders missing from the mapping);
long-term, auto-detecting model-folder-backed combos would remove the
need to register each custom loader by hand.

Fixes FE-838

## Red-Green Verification

| Commit | CI | Purpose |
|--------|-----|---------|
| [`test:`
64d099f6c](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26508637513)
| 🔴 Red (failure) | Proves the test catches the bug |
| [`fix:`
6b91a570d](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26509067631)
| 🟢 Green (success) | Proves the fix resolves it |

## Test Plan

- [x] CI red on test-only commit
- [x] CI green on fix commit
- [x] Unit regression in `src/stores/modelToNodeStore.test.ts`
- [ ] E2E not applicable (custom node + cloud asset API not available in
CI)
2026-05-27 22:51:26 +00:00
Dante
a931acadd3 feat(dialog): migrate Settings dialog to Reka-UI (Phase 3) (#12182)
## Summary

Phase 3 of the dialog migration. Closes the parity gaps in the Reka
renderer (maximize affordance, headless layout mode, overlay-class
plumbing), then flips `useSettingsDialog` onto the Reka path. Public API
of `useDialogService` / `dialogStore` is unchanged.

Parent:
[FE-571](https://linear.app/comfyorg/issue/FE-571/dialog-system-migration-primevue-reka-ui-parent)
This phase:
[FE-575](https://linear.app/comfyorg/issue/FE-575/phase-3-migrate-settings-dialog-workspace-non-workspace-designer)
Predecessors: #11719 (Phase 0, merged), #12041 (Phase 1, merged), #12109
(Phase 2, **stacked PR base**)

> **Stacked on Phase 2**: this PR targets
`jaewon/dialog-reka-migration-phase-2`. Rebase onto `main` after #12109
lands.

## Changes

### Reka primitives — parity gaps closed

| File | Change |
| --- | --- |
| `src/components/ui/dialog/dialog.variants.ts` | New `maximized`
variant. `false` keeps the centered/sized layout; `true` switches to
`inset-2 top-2 left-2 size-auto max-h-none max-w-none sm:max-w-none` for
full-screen mode |
| `src/components/ui/dialog/DialogContent.vue` | Accepts `maximized`
prop, forwards to variants |
| `src/components/ui/dialog/DialogMaximize.vue` **(new)** | Icon-only
button toggling `lucide--maximize-2` / `lucide--minimize-2`; emits
`toggle`; uses `g.maximizeDialog` / `g.restoreDialog` i18n |
| `src/stores/dialogStore.ts` | Adds `overlayClass?:
HTMLAttributes['class']` to `CustomDialogComponentProps` (Reka-only;
PrimeVue path uses `pt.mask`) |
| `src/components/dialog/GlobalDialog.vue` | (a) Forwards `overlayClass`
to `DialogOverlay`; (b) passes `:maximized` to `DialogContent`; (c)
renders `DialogMaximize` in the header when `maximizable`, wired to a
local `toggleMaximize`; (d) when `headless: true`, skips the inner
`flex-1 overflow-auto px-4 py-2` wrapper so layout dialogs control their
own chrome |

### Settings flip

| File | Change |
| --- | --- |
| `src/platform/settings/composables/useSettingsDialog.ts` | Adds
`dialogComponentProps: { renderer: 'reka', size: 'full', contentClass:
'\<...\>', overlayClass }`. `contentClass` is `w-[90vw] max-w-[960px]
sm:max-w-[960px] h-[80vh] max-h-none rounded-2xl overflow-hidden` —
matches the previous `BaseModalLayout size="sm"` (960px × 80vh).
`overlayClass: 'p-8'` only when `isCloud && teamWorkspacesEnabled`
(preserves the workspace breathing-room contract) |
| `src/components/dialog/GlobalDialog.vue` | Drops the now-dead
`getDialogPt` workspace special case and the orphan
`.settings-dialog-workspace` CSS. Removes unused imports (`merge`,
`computed`, `useFeatureFlags`, `isCloud`, `DialogPassThroughOptions`) |

### Tests

- `src/platform/settings/composables/useSettingsDialog.test.ts`
**(new)** — 5 tests: renderer flip + sizing, workspace `overlayClass`
toggle, panel forwarding, `showAbout()`

## Quality gates

- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — 0 errors (3 pre-existing warnings unrelated to this
PR)
- [x] `pnpm format` — applied
- [x] `pnpm test:unit` (touched + adjacent areas):
  - `useSettingsDialog.test.ts` — 5/5
  - `dialogService.renderer.test.ts` — 5/5
  - `GlobalDialog.test.ts` — 9/9
  - All `src/components/dialog/` — 73/73
  - All `src/platform/settings/` — 75/75
  - `CustomizationDialog.test.ts` — 4/4
- [ ] CI Playwright matrix
- [ ] Manual verification on a backend

## Screenshots

End-to-end verification of the Reka flip on a local dev server:

| | |
| --- | --- |
| Settings dialog rendered via Reka (non-modal, focus stays in dialog
body) |
![Settings](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/c454af1888d5d8d88092475b498cff7d2adac1a1/temp/summaries/settings-dialog-reka.png)
|
| Keybinding panel inside the Reka Settings dialog |
![Keybinding](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/c454af1888d5d8d88092475b498cff7d2adac1a1/temp/summaries/keybinding-panel.png)
|
| Nested PrimeVue **Modify keybinding** dialog stacked on top —
`document.activeElement` is the `<input autofocus>`, proving the
focus-trap fix | ![Modify
keybinding](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/c454af1888d5d8d88092475b498cff7d2adac1a1/temp/summaries/nested-modify-keybinding.png)
|


## Public API impact

None. `useSettingsDialog().show()` keeps the same signature. Reka
primitives gain optional `maximized` prop and `overlayClass` field —
additive, non-breaking.

## Out of scope (later phases)

- Manager dialog — Phase 4 (FE-576) — will consume the new `maximizable`
affordance
- `ConfirmDialog` callers — Phase 5 (FE-577)
- Removing PrimeVue `Dialog`/`<style>` overrides in `GlobalDialog.vue` —
Phase 6 (FE-578)

## Review focus

1. **Sizing strategy** — `contentClass` overrides Reka's default content
sizing (matching the existing `BaseModalLayout size="sm"` of 960 ×
80vh). Worth a designer pass per FE-575's acceptance criteria.
2. **`overlayClass: 'p-8'` workspace mode** — Reka's `DialogContent` is
positioned with viewport coordinates, so overlay padding does not
constrain it the way the old PrimeVue `mask.p-8` did. Cosmetic gutter
only. If designer flags missing breathing room, follow-up by shrinking
`contentClass` in workspace mode.
3. **`headless: true` semantics for Reka** — now skips the inner padding
wrapper. Existing migrated dialogs (Phases 1–2) all set a header, so no
visible impact. The Reka-headless path is new with this PR.
4. **Maximize wiring** — `toggleMaximize` mutates
`item.dialogComponentProps.maximized` directly (Pinia deep-reactive
proxy). The store's `onMaximize` / `onUnmaximize` callbacks are still
wired for the PrimeVue path; not double-fired.

## Test plan

- [x] Unit: 102/102 across touched + adjacent areas
- [ ] CI: full Vitest + Playwright matrix
- [ ] Manual on a backend:
- Open Settings via gear icon / keyboard shortcut → renders through
Reka, search works, panel navigation works, ESC closes
- Open Settings → trigger a reset confirmation (stacked confirm) →
confirm renders above Settings, ESC closes only the confirm
- Cloud workspace mode: Settings opens with workspace panel;
`overlayClass` applied
- Cloud non-workspace mode: Settings opens without workspace panel; no
`overlayClass`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12182-feat-dialog-migrate-Settings-dialog-to-Reka-UI-Phase-3-35e6d73d36508144bb4af88f83c5ab20)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-27 22:08:30 +00:00
Luke Mino-Altherr
db6b7a315c chore: remediate 51 Dependabot vulnerabilities (#12345)
## Summary

Remediate 51 of 63 open Dependabot security alerts by bumping direct
dependencies, bumping parent dependencies, and adding targeted pnpm
overrides for transitive dependencies.

## Changes

- **What**: Two batches of dependency security fixes
- **Batch 1**: Bump catalog minimums for axios, dompurify, happy-dom,
vite, uuid. Fix axios header type narrowing in api.ts.
- **Batch 2**: Bump parent deps (@iconify/tailwind4, vue, knip) to pull
fixed transitive deps. Add tilde-pinned pnpm overrides for protobufjs,
flatted, defu where no parent fix is available. Unexport 6 unused types
flagged by knip upgrade.
- **Dependencies**: vue 3.5.13->3.5.34 required two type fixes
(LazyImage ClassValue, dialogStore deep instantiation)

## Review Focus

- pnpm overrides in package.json: protobufjs ~7.6.0, flatted ~3.4.2,
defu ~6.1.7
- Vue 3.5.34 type narrowing fixes in LazyImage.vue and dialogStore.ts

## Remaining (12 alerts, separate PRs)

- minimatch (4H) - 4 major version lines, needs per-consumer analysis
- picomatch (2M) - two major version lines
- brace-expansion (2M) - multiple major version lines
- astro (2: 1L+1M) - major version bump 5->6
- postcss 8.5.8 (1M) - dev-only, from @vue/compiler-sfc@3.5.28 via
storybook/devtools
- yaml 1.10.2 (1M) - from cosmiconfig->nx, no upstream fix in yaml v1
- lodash/lodash-es (4: 2H+2M) - dev-only, upstream still uses 4.17.x
- @babel/plugin-transform-modules-systemjs (1H) - dev-only via nx
- fast-uri (2H) - dev-only via ajv->nx/stylelint

Fixes #FE-762

---------

Co-authored-by: Austin Mroz <austin@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-27 14:07:34 -07:00
AustinMroz
b89940134f Better preview grid tiling (#12463)
The previous image preview tiling code was less than ideal. It had fixed
breakpoints based on the number of images. Outputs with many images
would become comically long.

This PR instead tiles images to fill the available space.
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/e793ce65-8efc-44ca-b049-98f066a65b7d"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/ca891ce2-335f-42ce-aeec-a99579f669c8"
/>|
2026-05-27 20:26:44 +00:00
Nav Singh
c957913c71 fix(telemetry): call posthog.reset(true) on logout to prevent session bleeding
Closes MAR-234.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:14:36 -07:00
204 changed files with 12687 additions and 3162 deletions

View File

@@ -0,0 +1,24 @@
name: Detect Unreviewed Merge
# SOC 2 compliance — reusable workflow lives in Comfy-Org/github-workflows,
# tracking issues are filed in Comfy-Org/unreviewed-merges.
on:
push:
branches: [main, master]
concurrency:
group: detect-unreviewed-merge-${{ github.sha }}
cancel-in-progress: false
permissions:
contents: read
pull-requests: read
jobs:
detect:
uses: Comfy-Org/github-workflows/.github/workflows/detect-unreviewed-merge.yml@4d9cb6b87f953bb7cd69954280e1465fb9bd2040 # v1
with:
approval-mode: latest-per-reviewer
secrets:
UNREVIEWED_MERGES_TOKEN: ${{ secrets.UNREVIEWED_MERGES_TOKEN }}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<rect width="48" height="48" rx="12" fill="#211927"/>
<path fill="#F2FF59" d="M31.0126 30.48C31.0576 30.3278 31.0822 30.1674 31.0822 29.9987C31.0822 29.0651 30.3294 28.3083 29.4006 28.3083H21.8643C21.4592 28.3124 21.1278 27.9834 21.1278 27.5762C21.1278 27.5022 21.1401 27.4323 21.1565 27.3665L23.1858 20.2593C23.2718 19.9467 23.5581 19.7164 23.8936 19.7164L31.4586 19.7082C33.0542 19.7082 34.4001 18.6264 34.8054 17.1499L35.9429 13.1891C35.9794 13.0493 36 12.8971 36 12.7449C36 11.8154 35.2513 11.0627 34.3268 11.0627H25.1742C23.5868 11.0627 22.2448 12.1362 21.8316 13.5963L21.0624 16.2985C20.9724 16.607 20.6901 16.8332 20.3546 16.8332H18.1575C16.5823 16.8332 15.2526 17.8861 14.8271 19.3298L12.0614 29.0404C12.0205 29.1844 12 29.3407 12 29.4969C12 30.4306 12.7528 31.1874 13.6816 31.1874H15.8418C16.2469 31.1874 16.5783 31.5164 16.5783 31.9277C16.5783 31.9976 16.5701 32.0675 16.5496 32.1334L15.7845 34.8109C15.7477 34.9549 15.7231 35.1029 15.7231 35.255C15.7231 36.1846 16.4719 36.9374 17.3965 36.9374L26.553 36.929C28.1446 36.929 29.4865 35.8513 29.8957 34.3833L31.0085 30.4841L31.0126 30.48Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,23 @@
{
"name": "Comfy",
"short_name": "Comfy",
"id": "/",
"start_url": "/",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"theme_color": "#211927",
"background_color": "#211927",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -87,8 +87,8 @@ function scrollToDepartment(deptKey: string) {
<template>
<section class="px-6 py-20 md:px-20 md:py-32" data-testid="careers-roles">
<div class="mx-auto max-w-6xl">
<div class="flex flex-col gap-12 md:flex-row md:gap-20">
<div class="shrink-0 md:w-48">
<div class="flex flex-col gap-12 lg:flex-row lg:gap-20">
<div class="shrink-0 lg:min-w-48">
<div
class="bg-primary-comfy-ink sticky top-20 z-10 py-4 md:top-28 md:py-0"
>
@@ -133,30 +133,41 @@ function scrollToDepartment(deptKey: string) {
:href="role.jobUrl"
target="_blank"
rel="noopener noreferrer"
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
class="border-primary-warm-gray/20 hover:border-primary-comfy-canvas group flex items-center gap-4 border-b py-5 transition-colors duration-200"
data-testid="careers-role-link"
>
<div class="min-w-0">
<div
class="flex min-w-0 flex-1 flex-col md:flex-row md:items-baseline md:gap-x-4"
>
<span
class="text-primary-comfy-canvas text-base font-medium md:text-lg"
>
{{ role.title }}
</span>
<span class="text-primary-warm-gray ml-3 text-sm">
{{ role.department }}
</span>
<div
class="text-primary-warm-gray mt-1 flex flex-wrap gap-x-4 gap-y-1 text-sm md:mt-0 md:contents"
>
<span>{{ role.department }}</span>
<span class="md:hidden">{{ role.location }}</span>
</div>
</div>
<div class="ml-4 flex shrink-0 items-center gap-3">
<span class="text-primary-warm-gray text-sm">
{{ role.location }}
</span>
<img
src="/icons/arrow-up-right.svg"
alt=""
class="size-5"
<span
class="text-primary-warm-gray hidden shrink-0 text-sm md:inline"
>
{{ role.location }}
</span>
<span
class="bg-primary-comfy-yellow/0 group-hover:bg-primary-comfy-yellow relative grid size-7 shrink-0 place-items-center rounded-sm transition-colors duration-300 ease-out"
>
<span
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors duration-300 ease-out"
style="
mask: url('/icons/arrow-up-right.svg') center / contain
no-repeat;
"
aria-hidden="true"
/>
</div>
</span>
</a>
</div>
</div>

View File

@@ -18,7 +18,7 @@ const emit = defineEmits<{
<template>
<nav
class="scrollbar-none flex items-center gap-3 overflow-x-auto lg:flex-col lg:overflow-x-hidden"
class="flex w-full scrollbar-none items-center gap-3 overflow-x-auto lg:flex-col lg:overflow-x-hidden"
aria-label="Category filter"
>
<button

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
export type NavDropdownItem = {
type NavDropdownItem = {
label: string
href: string
badge?: string

View File

@@ -37,7 +37,7 @@ const allCards: (ReturnType<typeof cardDef> & { product: Product })[] = [
cardDef('local', routes.download, 'bg-primary-warm-gray'),
cardDef('cloud', routes.cloud, 'bg-secondary-mauve'),
cardDef('api', routes.api, 'bg-primary-comfy-plum'),
cardDef('enterprise', routes.cloudEnterprise, 'bg-illustration-forest')
cardDef('enterprise', routes.cloudEnterprise, 'bg-secondary-cool-gray')
]
const cards = excludeProduct

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import type { GalleryItem } from './GallerySection.vue'
import GalleryItemAttribution from './GalleryItemAttribution.vue'
const {

View File

@@ -10,13 +10,13 @@ import {
watch
} from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import GalleryItemAttribution from './GalleryItemAttribution.vue'
import type { GalleryItem } from './GallerySection.vue'
const {
items,
@@ -251,7 +251,7 @@ onUnmounted(() => {
<!-- Thumbnail strip -->
<div
class="scrollbar-none mx-auto mt-6 h-16 max-w-full overflow-x-auto px-6 lg:h-30"
class="mx-auto mt-6 h-16 max-w-full scrollbar-none overflow-x-auto px-6 lg:h-30"
>
<div class="flex items-end gap-3">
<button

View File

@@ -1,8 +1,7 @@
<script setup lang="ts">
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import type { GalleryItem } from './GallerySection.vue'
const {
item,

View File

@@ -2,6 +2,8 @@
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import { visibleGalleryItems as items } from '../../data/gallery'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import GalleryCard from './GalleryCard.vue'
import GalleryDetailModal from './GalleryDetailModal.vue'
@@ -16,166 +18,6 @@ function openDetail(index: number) {
modalOpen.value = true
}
export interface GalleryItem {
image?: string
video?: string
title: string
userAlias: string
teamAlias: string
tool: string
href?: string
}
const items: GalleryItem[] = [
{
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
title: 'Until Our Eye Interlink harajuku',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'ComfyUI',
href: 'https://www.thinkdiffusion.com/studio#success-stories-anta'
},
{
video: 'https://media.comfy.org/videos/compressed_512/kyrie.webm',
title: 'Origins - Kyrie Irving',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'ComfyUI',
href: 'https://vimeo.com/1021360563'
},
{
video: 'https://media.comfy.org/videos/compressed_512/arcade.webm',
title: 'Neon Nights',
userAlias: 'ShaneF Motion Design',
teamAlias: 'DOGSTUDIO/DEPT®',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C1kG1oErzUV/'
},
{
video: 'https://media.comfy.org/videos/compressed_512/dusk_mountains.webm',
title: 'Untitled',
userAlias: 'MidJourney man',
teamAlias: 'DOGSTUDIO/DEPT®',
tool: 'ComfyUI',
href: 'https://www.instagram.com/midjourney.man/?hl=fr'
},
{
video: 'https://media.comfy.org/videos/compressed_512/cigarette.webm',
title: 'Autopoiesis',
userAlias: 'Yogo',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.instagram.com/visualfrisson/?hl=en'
},
{
video:
'https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm',
title: 'Eat It - Dance',
userAlias: 'Johana Lyu',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.joannalyu.com/'
},
{
video: 'https://media.comfy.org/videos/compressed_512/flower.webm',
title: 'Fall',
userAlias: 'Nathan Shipley',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C3k9t_6vH5F/'
},
{
video: 'https://media.comfy.org/videos/compressed_512/buildings.webm',
title: 'Untitled',
userAlias: 'Nathan Shipley',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C6rEuJ4p9xU/'
},
{
video:
'https://media.comfy.org/videos/compressed_512/origami_shortened.webm',
title: 'Origami world',
userAlias: 'Karen X',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/karenxcheng/'
},
{
video: 'https://media.comfy.org/videos/compressed_512/biking.webm',
title: 'Shot on InstaX',
userAlias: 'Karen X',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/karenxcheng/'
},
{
video: 'https://media.comfy.org/videos/compressed_512/clouds.webm',
title: "It's gonna be a good good summer",
userAlias: 'Paul Trillo',
teamAlias: '',
tool: 'CogvideoX',
href: 'https://vimeo.com/1019685900'
},
{
video: 'https://media.comfy.org/videos/compressed_512/dududu.webm',
title: 'DDU-DU DDU-DU',
userAlias: 'Purz',
teamAlias: 'Andidea',
tool: 'Animatediff',
href: 'https://vimeo.com/1019924290'
},
{
video: 'https://media.comfy.org/videos/compressed_512/paul_trillo.webm',
title: 'Cuco - A Love Letter To LA',
userAlias: 'Paul Trillo',
teamAlias: 'CoffeeVectors',
tool: 'ComfyUI',
href: 'https://vimeo.com/1062859798'
},
{
video:
'https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm',
title: 'Show you my garden',
userAlias: 'Paul Trillo',
teamAlias: '',
tool: 'CogvideoX',
href: 'https://vimeo.com/1019685479'
},
{
video: 'https://media.comfy.org/videos/compressed_512/swings.webm',
title: 'Goodbye Beijing',
userAlias: 'Rui',
teamAlias: 'makeitrad',
tool: 'Animatediff',
href: 'https://x.com/rui40000'
},
{
video: 'https://media.comfy.org/videos/compressed_512/clouds_statue.webm',
title: 'Animation Reel',
userAlias: 'Andidea',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.youtube.com/watch?v=qu3eIQ1uln8'
},
{
image: 'https://media.comfy.org/website/gallery/gallery.webp',
title: 'Amber Astronaut',
userAlias: 'Yogo',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
},
{
image: 'https://media.comfy.org/website/gallery/desert.webp',
title: 'Desert Landing',
userAlias: 'Yogo',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
}
]
/**
* Desktop layout pattern (repeating):
* Row A: full-width (1 item)

View File

@@ -0,0 +1,189 @@
export interface GalleryItem {
id: string
image?: string
video?: string
title: string
userAlias: string
teamAlias: string
tool: string
href?: string
/** Defaults to true. Set to false to hide this item from rendered lists. */
visible?: boolean
}
const galleryItems: GalleryItem[] = [
{
id: 'until-our-eye-interlink-harajuku',
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
title: 'Until Our Eye Interlink harajuku',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'ComfyUI',
href: 'https://www.thinkdiffusion.com/studio#success-stories-anta'
},
{
id: 'origins-kyrie-irving',
video: 'https://media.comfy.org/videos/compressed_512/kyrie.webm',
title: 'Origins - Kyrie Irving',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'ComfyUI',
href: 'https://vimeo.com/1021360563'
},
{
id: 'neon-nights',
video: 'https://media.comfy.org/videos/compressed_512/arcade.webm',
title: 'Neon Nights',
userAlias: 'ShaneF Motion Design',
teamAlias: 'DOGSTUDIO/DEPT®',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C1kG1oErzUV/'
},
{
id: 'untitled-dusk-mountains',
video: 'https://media.comfy.org/videos/compressed_512/dusk_mountains.webm',
title: 'Untitled',
userAlias: 'MidJourney man',
teamAlias: 'DOGSTUDIO/DEPT®',
tool: 'ComfyUI',
href: 'https://www.instagram.com/midjourney.man/?hl=fr'
},
{
id: 'autopoiesis',
video: 'https://media.comfy.org/videos/compressed_512/cigarette.webm',
title: 'Autopoiesis',
userAlias: 'Yogo',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.instagram.com/visualfrisson/?hl=en'
},
{
id: 'eat-it-dance',
video:
'https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm',
title: 'Eat It - Dance',
userAlias: 'Johana Lyu',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.joannalyu.com/'
},
{
id: 'fall',
video: 'https://media.comfy.org/videos/compressed_512/flower.webm',
title: 'Fall',
userAlias: 'Nathan Shipley',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C3k9t_6vH5F/'
},
{
id: 'untitled-buildings',
video: 'https://media.comfy.org/videos/compressed_512/buildings.webm',
title: 'Untitled',
userAlias: 'Nathan Shipley',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C6rEuJ4p9xU/'
},
{
id: 'origami-world',
video:
'https://media.comfy.org/videos/compressed_512/origami_shortened.webm',
title: 'Origami world',
userAlias: 'Karen X',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/karenxcheng/'
},
{
id: 'shot-on-instax',
video: 'https://media.comfy.org/videos/compressed_512/biking.webm',
title: 'Shot on InstaX',
userAlias: 'Karen X',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/karenxcheng/'
},
{
id: 'good-good-summer',
video: 'https://media.comfy.org/videos/compressed_512/clouds.webm',
title: "It's gonna be a good good summer",
userAlias: 'Paul Trillo',
teamAlias: '',
tool: 'CogvideoX',
href: 'https://vimeo.com/1019685900'
},
{
id: 'ddu-du-ddu-du',
video: 'https://media.comfy.org/videos/compressed_512/dududu.webm',
title: 'DDU-DU DDU-DU',
userAlias: 'Purz',
teamAlias: 'Andidea',
tool: 'Animatediff',
href: 'https://vimeo.com/1019924290'
},
{
id: 'cuco-love-letter-to-la',
video: 'https://media.comfy.org/videos/compressed_512/paul_trillo.webm',
title: 'Cuco - A Love Letter To LA',
userAlias: 'Paul Trillo',
teamAlias: 'CoffeeVectors',
tool: 'ComfyUI',
href: 'https://vimeo.com/1062859798'
},
{
id: 'show-you-my-garden',
video:
'https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm',
title: 'Show you my garden',
userAlias: 'Paul Trillo',
teamAlias: '',
tool: 'CogvideoX',
href: 'https://vimeo.com/1019685479'
},
{
id: 'goodbye-beijing',
video: 'https://media.comfy.org/videos/compressed_512/swings.webm',
title: 'Goodbye Beijing',
userAlias: 'Rui',
teamAlias: 'makeitrad',
tool: 'Animatediff',
href: 'https://x.com/rui40000'
},
{
id: 'animation-reel',
video: 'https://media.comfy.org/videos/compressed_512/clouds_statue.webm',
title: 'Animation Reel',
userAlias: 'Andidea',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.youtube.com/watch?v=qu3eIQ1uln8'
},
{
id: 'amber-astronaut',
image: 'https://media.comfy.org/website/gallery/gallery.webp',
title: 'Amber Astronaut',
userAlias: 'Yogo',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
},
{
id: 'desert-landing',
image: 'https://media.comfy.org/website/gallery/desert.webp',
title: 'Desert Landing',
userAlias: 'Yogo',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
}
]
export const visibleGalleryItems: GalleryItem[] = galleryItems.filter(
(item) => item.visible !== false
)
/** @knipIgnoreUsedByStackedPR */
export function getGalleryItemById(id: string): GalleryItem | undefined {
return galleryItems.find((item) => item.id === id)
}

View File

@@ -1458,9 +1458,9 @@ const translations = {
// ContactSection
'gallery.contact.label': { en: 'CONTACT', 'zh-CN': '联系' },
'gallery.contact.heading': {
en: 'Built something cool with ComfyUI? <a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Submit</a> your work to be featured on our website and socials and get seen by the global ComfyUI community.',
en: 'Built something cool with ComfyUI?<br> <a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Submit</a> your work to be featured on our website and socials and get seen by the global ComfyUI community.',
'zh-CN':
'用 ComfyUI 创作了很酷的作品?<a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">提交</a>你的作品,展示在我们的网站和社交媒体上,让全球 ComfyUI 社区看到。'
'用 ComfyUI 创作了很酷的作品?<br><a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">提交</a>你的作品,展示在我们的网站和社交媒体上,让全球 ComfyUI 社区看到。'
},
// AboutHeroSection

View File

@@ -71,20 +71,12 @@ const websiteJsonLd = {
{noindex && <meta name="robots" content="noindex, nofollow" />}
<title>{title}</title>
<link
rel="icon"
href="/favicon-light.svg"
type="image/svg+xml"
media="(prefers-color-scheme: light)"
/>
<link
rel="icon"
href="/favicon-dark.svg"
type="image/svg+xml"
media="(prefers-color-scheme: dark)"
/>
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#211927" />
<link rel="canonical" href={canonicalURL.href} />
<link rel="preconnect" href="https://www.googletagmanager.com" />
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />

View File

@@ -14,7 +14,7 @@ const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
export interface DroppedRole {
interface DroppedRole {
title: string
reason: string
}

View File

@@ -21,7 +21,7 @@ const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
export interface DroppedNode {
interface DroppedNode {
name: string
reason: string
}

View File

@@ -1,11 +1,16 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchGitHubStars, formatStarCount } from './github'
import {
fetchGitHubStars,
formatStarCount,
resetGitHubStarsFetcherForTests
} from './github'
describe('fetchGitHubStars', () => {
const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
afterEach(() => {
resetGitHubStarsFetcherForTests()
vi.restoreAllMocks()
if (savedOverride === undefined)
delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE
@@ -27,6 +32,67 @@ describe('fetchGitHubStars', () => {
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
)
})
it('memoizes concurrent fetches for the same repo to one network call', async () => {
const fetchImpl = vi.fn(
async () =>
new Response(JSON.stringify({ stargazers_count: 110000 }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
)
const [a, b, c] = await Promise.all([
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
])
expect(a).toBe(110000)
expect(b).toBe(110000)
expect(c).toBe(110000)
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('keys the in-flight cache by owner/repo', async () => {
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
const href = typeof url === 'string' ? url : url.toString()
const count = href.includes('other-repo') ? 42 : 110000
return new Response(JSON.stringify({ stargazers_count: count }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
})
const [comfy, other] = await Promise.all([
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
fetchGitHubStars('Comfy-Org', 'other-repo', fetchImpl as typeof fetch)
])
expect(comfy).toBe(110000)
expect(other).toBe(42)
expect(fetchImpl).toHaveBeenCalledTimes(2)
})
it('returns null when GitHub responds non-2xx', async () => {
const fetchImpl = vi.fn(
async () => new Response('rate limited', { status: 403 })
)
await expect(
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
).resolves.toBeNull()
})
it('returns null when fetch throws', async () => {
const fetchImpl = vi.fn(async () => {
throw new Error('network down')
})
await expect(
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
).resolves.toBeNull()
})
})
describe('formatStarCount', () => {

View File

@@ -1,22 +1,51 @@
const inflight = new Map<string, Promise<number | null>>()
export function resetGitHubStarsFetcherForTests(): void {
inflight.clear()
}
export async function fetchGitHubStars(
owner: string,
repo: string
repo: string,
fetchImpl: typeof fetch = fetch
): Promise<number | null> {
const override = readGitHubStarsOverride()
if (override !== undefined) return override
const key = `${owner}/${repo}`
const cached = inflight.get(key)
if (cached) return cached
const request = doFetch(owner, repo, fetchImpl)
inflight.set(key, request)
return request
}
async function doFetch(
owner: string,
repo: string,
fetchImpl: typeof fetch
): Promise<number | null> {
try {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: { Accept: 'application/vnd.github.v3+json' }
})
const res = await fetchImpl(
`https://api.github.com/repos/${owner}/${repo}`,
{ headers: { Accept: 'application/vnd.github.v3+json' } }
)
if (!res.ok) return null
const data = await res.json()
return data.stargazers_count ?? null
const data: unknown = await res.json()
return readStargazerCount(data)
} catch {
return null
}
}
function readStargazerCount(data: unknown): number | null {
if (data === null || typeof data !== 'object') return null
if (!('stargazers_count' in data)) return null
const count = data.stargazers_count
return typeof count === 'number' ? count : null
}
export function formatStarCount(count: number): string {
if (count >= 1_000_000) {
const m = count / 1_000_000

View File

@@ -66,6 +66,34 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
await this.drop(options)
}
async middleDrag(
from: Position,
to: Position,
options: Omit<DragOptions, 'button'> = {}
) {
await this.dragAndDrop(from, to, { ...options, button: 'middle' })
}
async middleDragFromCenter(
locator: Locator,
delta: { x: number; y: number },
options: Omit<DragOptions, 'button'> = {}
) {
await locator.waitFor({ state: 'visible' })
const box = await locator.boundingBox()
if (!box) throw new Error('middleDragFromCenter: bounding box not found')
const start = {
x: box.x + box.width / 2,
y: box.y + box.height / 2
}
await this.middleDrag(
start,
{ x: start.x + delta.x, y: start.y + delta.y },
options
)
}
/** @see {@link Mouse.move} */
async move(to: Position, options = ComfyMouse.defaultOptions) {
await this.mouse.move(to.x, to.y, options)

View File

@@ -1,7 +1,7 @@
import type { Locator, Page } from '@playwright/test'
import { test as base } from '@playwright/test'
export class UserSelectPage {
class UserSelectPage {
public readonly selectionUrl: string
public readonly container: Locator
public readonly newUserInput: Locator

View File

@@ -18,7 +18,7 @@ class ShortcutsTab {
}
}
export class LogsTab {
class LogsTab {
readonly tab: Locator
readonly terminalRoot: Locator
readonly terminalHost: Locator

View File

@@ -1,7 +1,7 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
export class ComfyNodeSearchFilterSelectionPanel {
class ComfyNodeSearchFilterSelectionPanel {
readonly root: Locator
readonly header: Locator

View File

@@ -8,8 +8,6 @@ import type { Position } from '@e2e/fixtures/types'
const { searchBoxV2 } = TestIds
export type { RootCategoryId }
export class ComfyNodeSearchBoxV2 {
readonly dialog: Locator
readonly input: Locator

View File

@@ -139,6 +139,7 @@ export class WorkflowsSidebarTab extends SidebarTab {
public readonly root: Locator
public readonly activeWorkflowLabel: Locator
public readonly searchInput: Locator
public readonly refreshButton: Locator
constructor(public override readonly page: Page) {
super(page, 'workflows')
@@ -147,6 +148,9 @@ export class WorkflowsSidebarTab extends SidebarTab {
'.comfyui-workflows-open .p-tree-node-selected .node-label'
)
this.searchInput = this.root.getByRole('combobox').first()
this.refreshButton = this.root.getByTestId(
TestIds.sidebar.workflowsRefreshButton
)
}
async getOpenedWorkflowNames() {

View File

@@ -2,8 +2,3 @@ export interface Position {
x: number
y: number
}
export interface Size {
width: number
height: number
}

View File

@@ -86,46 +86,6 @@ export const STABLE_LORA: Asset = createModelAsset({
updated_at: '2025-02-20T14:00:00Z'
})
export const STABLE_LORA_2: Asset = createModelAsset({
id: 'test-lora-002',
name: 'add_detail_v2.safetensors',
size: 226_492_416,
tags: ['models', 'loras'],
user_metadata: {
base_model: 'sd15',
description: 'Add Detail LoRA v2'
},
created_at: '2025-02-25T11:00:00Z',
updated_at: '2025-02-25T11:00:00Z'
})
export const STABLE_VAE: Asset = createModelAsset({
id: 'test-vae-001',
name: 'sdxl_vae.safetensors',
size: 334_641_152,
tags: ['models', 'vae'],
user_metadata: {
base_model: 'sdxl',
description: 'SDXL VAE'
},
created_at: '2025-01-18T16:00:00Z',
updated_at: '2025-01-18T16:00:00Z'
})
export const STABLE_EMBEDDING: Asset = createModelAsset({
id: 'test-embedding-001',
name: 'bad_prompt_v2.pt',
size: 32_768,
mime_type: 'application/x-pytorch',
tags: ['models', 'embeddings'],
user_metadata: {
base_model: 'sd15',
description: 'Negative Embedding: Bad Prompt v2'
},
created_at: '2025-02-01T09:30:00Z',
updated_at: '2025-02-01T09:30:00Z'
})
export const STABLE_INPUT_IMAGE: Asset = createInputAsset({
id: 'test-input-001',
name: 'reference_photo.png',
@@ -136,26 +96,6 @@ export const STABLE_INPUT_IMAGE: Asset = createInputAsset({
updated_at: '2025-03-01T09:00:00Z'
})
export const STABLE_INPUT_IMAGE_2: Asset = createInputAsset({
id: 'test-input-002',
name: 'mask_layer.png',
size: 1_048_576,
mime_type: 'image/png',
tags: ['input'],
created_at: '2025-03-05T10:00:00Z',
updated_at: '2025-03-05T10:00:00Z'
})
export const STABLE_INPUT_VIDEO: Asset = createInputAsset({
id: 'test-input-003',
name: 'clip_720p.mp4',
size: 15_728_640,
mime_type: 'video/mp4',
tags: ['input'],
created_at: '2025-03-08T14:30:00Z',
updated_at: '2025-03-08T14:30:00Z'
})
export const STABLE_OUTPUT: Asset = createOutputAsset({
id: 'test-output-001',
name: 'ComfyUI_00001_.png',
@@ -166,31 +106,6 @@ export const STABLE_OUTPUT: Asset = createOutputAsset({
updated_at: '2025-03-10T12:00:00Z'
})
export const STABLE_OUTPUT_2: Asset = createOutputAsset({
id: 'test-output-002',
name: 'ComfyUI_00002_.png',
size: 3_670_016,
mime_type: 'image/png',
tags: ['output'],
created_at: '2025-03-10T12:05:00Z',
updated_at: '2025-03-10T12:05:00Z'
})
export const ALL_MODEL_FIXTURES: Asset[] = [
STABLE_CHECKPOINT,
STABLE_CHECKPOINT_2,
STABLE_LORA,
STABLE_LORA_2,
STABLE_VAE,
STABLE_EMBEDDING
]
export const ALL_INPUT_FIXTURES: Asset[] = [
STABLE_INPUT_IMAGE,
STABLE_INPUT_IMAGE_2,
STABLE_INPUT_VIDEO
]
export const ALL_OUTPUT_FIXTURES: Asset[] = [STABLE_OUTPUT, STABLE_OUTPUT_2]
const CHECKPOINT_NAMES = [
'sd_xl_base_1.0.safetensors',
'v1-5-pruned-emaonly.safetensors',

View File

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

View File

@@ -2,11 +2,6 @@ import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
const Local = TemplateIncludeOnDistributionEnum.Local
export function makeTemplate(
overrides: Partial<TemplateInfo> & Pick<TemplateInfo, 'name'>
@@ -31,33 +26,3 @@ export function mockTemplateIndex(
}
]
}
export const STABLE_CLOUD_TEMPLATE: TemplateInfo = makeTemplate({
name: 'cloud-stable',
title: 'Cloud Stable',
includeOnDistributions: [Cloud]
})
export const STABLE_DESKTOP_TEMPLATE: TemplateInfo = makeTemplate({
name: 'desktop-stable',
title: 'Desktop Stable',
includeOnDistributions: [Desktop]
})
export const STABLE_LOCAL_TEMPLATE: TemplateInfo = makeTemplate({
name: 'local-stable',
title: 'Local Stable',
includeOnDistributions: [Local]
})
export const STABLE_UNRESTRICTED_TEMPLATE: TemplateInfo = makeTemplate({
name: 'unrestricted-stable',
title: 'Unrestricted Stable'
})
export const ALL_DISTRIBUTION_TEMPLATES: TemplateInfo[] = [
STABLE_CLOUD_TEMPLATE,
STABLE_DESKTOP_TEMPLATE,
STABLE_LOCAL_TEMPLATE,
STABLE_UNRESTRICTED_TEMPLATE
]

View File

@@ -11,7 +11,7 @@ import {
generateOutputAssets
} from '@e2e/fixtures/data/assetFixtures'
export interface MutationRecord {
interface MutationRecord {
endpoint: string
method: string
url: string
@@ -23,7 +23,7 @@ interface PaginationOptions {
total: number
hasMore: boolean
}
export interface AssetConfig {
interface AssetConfig {
readonly assets: ReadonlyMap<string, Asset>
readonly pagination: PaginationOptions | null
readonly uploadResponse: Record<string, unknown> | null
@@ -33,7 +33,7 @@ function emptyConfig(): AssetConfig {
return { assets: new Map(), pagination: null, uploadResponse: null }
}
export type AssetOperator = (config: AssetConfig) => AssetConfig
type AssetOperator = (config: AssetConfig) => AssetConfig
function addAssets(config: AssetConfig, newAssets: Asset[]): AssetConfig {
const merged = new Map(config.assets)

View File

@@ -26,7 +26,7 @@ const historyRoutePattern = /\/api\/history$/
* The sidebar filter ultimately matches on the filename extension, so the
* fixture also picks an extension-appropriate filename for each kind.
*/
export type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
const DEFAULT_EXTENSION: Record<MediaKindFixture, string> = {
images: 'png',
@@ -134,16 +134,6 @@ export function createJobsWithExecutionTimes(
)
}
/** 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

@@ -11,6 +11,11 @@ import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
const PROMPT_ROUTE_PATTERN = /\/api\/prompt$/
type RunOptions = {
nodeErrors?: Record<string, NodeError>
onPromptRequest?: (requestBody: unknown) => void | Promise<void>
}
/**
* Build a `NodeError` describing a single failed input on a KSampler node.
* Shared between specs that surface validation rings via 400 responses.
@@ -70,8 +75,9 @@ export class ExecutionHelper {
* The app receives a valid PromptResponse so storeJob() fires
* and registers the job against the active workflow path.
*/
async run(): Promise<string> {
async run(options: RunOptions = {}): Promise<string> {
const jobId = `test-job-${++this.jobCounter}`
const { nodeErrors = {}, onPromptRequest } = options
let fulfilled!: () => void
const prompted = new Promise<void>((r) => {
@@ -81,12 +87,13 @@ export class ExecutionHelper {
await this.page.route(
PROMPT_ROUTE_PATTERN,
async (route) => {
await onPromptRequest?.(route.request().postDataJSON())
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
prompt_id: jobId,
node_errors: {}
node_errors: nodeErrors
})
})
fulfilled()

View File

@@ -7,7 +7,7 @@ import { TestIds } from '@e2e/fixtures/selectors'
type ReleaseNote = components['schemas']['ReleaseNote']
export type HelpMenuItemKey =
type HelpMenuItemKey =
| 'feedback'
| 'help'
| 'docs'
@@ -17,7 +17,7 @@ export type HelpMenuItemKey =
| 'update-comfyui'
| 'more'
export class HelpCenterHelper {
class HelpCenterHelper {
public readonly button: Locator
public readonly popup: Locator
public readonly backdrop: Locator

View File

@@ -7,9 +7,9 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
const MASK_CANVAS_INDEX = 2
const RGB_CANVAS_INDEX = 1
export type BrushSliderLabel = 'thickness'
type BrushSliderLabel = 'thickness'
export class MaskEditorHelper {
class MaskEditorHelper {
constructor(private comfyPage: ComfyPage) {}
private get page() {

View File

@@ -9,7 +9,7 @@ const modelFoldersRoutePattern = /\/api\/experiment\/models$/
const modelFilesRoutePattern = /\/api\/experiment\/models\/([^?]+)/
const viewMetadataRoutePattern = /\/api\/view_metadata\/([^?]+)/
export interface MockModelMetadata {
interface MockModelMetadata {
'modelspec.title'?: string
'modelspec.author'?: string
'modelspec.architecture'?: string
@@ -18,14 +18,11 @@ export interface MockModelMetadata {
'modelspec.tags'?: string
}
export function createMockModelFolders(names: string[]): ModelFolderInfo[] {
function createMockModelFolders(names: string[]): ModelFolderInfo[] {
return names.map((name) => ({ name, folders: [] }))
}
export function createMockModelFiles(
filenames: string[],
pathIndex = 0
): ModelFile[] {
function createMockModelFiles(filenames: string[], pathIndex = 0): ModelFile[] {
return filenames.map((name) => ({ name, pathIndex }))
}

View File

@@ -43,7 +43,7 @@ const DEFAULT_UPLOAD_URL_RESPONSE: HubAssetUploadUrlResponse = {
token: 'mock-upload-token'
}
export class PublishApiHelper {
class PublishApiHelper {
private routeHandlers: Array<{
pattern: string
handler: (route: Route) => Promise<void>

View File

@@ -3,7 +3,7 @@ import type { Page } from '@playwright/test'
import { SubgraphBreadcrumbPanel } from '@e2e/fixtures/components/SubgraphBreadcrumbPanel'
export class SubgraphBreadcrumbHelper {
class SubgraphBreadcrumbHelper {
readonly panel: SubgraphBreadcrumbPanel
constructor(public readonly page: Page) {

View File

@@ -4,33 +4,9 @@ import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import {
makeTemplate,
mockTemplateIndex
} from '@e2e/fixtures/data/templateFixtures'
import { mockTemplateIndex } from '@e2e/fixtures/data/templateFixtures'
/**
* Generate N deterministic templates, optionally restricted to a distribution.
*
* Lives here (not in `fixtures/data/`) because `fixtures/data/` is reserved
* for static test data with no executable fixture logic.
*/
function generateTemplates(
count: number,
distribution?: TemplateIncludeOnDistributionEnum
): TemplateInfo[] {
const slug = distribution ?? 'unrestricted'
return Array.from({ length: count }, (_, i) =>
makeTemplate({
name: `gen-${slug}-${String(i + 1).padStart(3, '0')}`,
title: `Generated ${slug} ${i + 1}`,
...(distribution ? { includeOnDistributions: [distribution] } : {})
})
)
}
export interface TemplateConfig {
interface TemplateConfig {
readonly templates: readonly TemplateInfo[]
readonly index: readonly WorkflowTemplates[] | null
}
@@ -39,7 +15,7 @@ function emptyConfig(): TemplateConfig {
return { templates: [], index: null }
}
export type TemplateOperator = (config: TemplateConfig) => TemplateConfig
type TemplateOperator = (config: TemplateConfig) => TemplateConfig
function cloneTemplates(templates: readonly TemplateInfo[]): TemplateInfo[] {
return templates.map((t) => structuredClone(t))
@@ -62,46 +38,6 @@ export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
return (config) => addTemplates(config, templates)
}
export function withTemplate(template: TemplateInfo): TemplateOperator {
return (config) => addTemplates(config, [template])
}
export function withCloudTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Cloud)
)
}
export function withDesktopTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Desktop)
)
}
export function withLocalTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Local)
)
}
export function withUnrestrictedTemplates(count: number): TemplateOperator {
return (config) => addTemplates(config, generateTemplates(count))
}
/**
* Override the index payload entirely. Useful when a test needs a custom
* `WorkflowTemplates[]` shape (e.g. multiple modules).
*/
export function withRawIndex(index: WorkflowTemplates[]): TemplateOperator {
return (config) => ({ ...config, index })
}
export class TemplateHelper {
private templates: TemplateInfo[]
private index: WorkflowTemplates[] | null

View File

@@ -121,7 +121,7 @@ export function createRouteMockJob({
}
}
export class JobsRouteMocker {
class JobsRouteMocker {
constructor(private readonly page: Page) {}
async mockJobsHistory(

View File

@@ -10,6 +10,7 @@ export const TestIds = {
nodeLibrarySearch: 'node-library-search',
nodePreviewCard: 'node-preview-card',
workflows: 'workflows-sidebar',
workflowsRefreshButton: 'workflows-refresh-button',
modeToggle: 'mode-toggle'
},
tree: {
@@ -128,7 +129,8 @@ export const TestIds = {
pinIndicator: 'node-pin-indicator',
innerWrapper: 'node-inner-wrapper',
mainImage: 'main-image',
slotConnectionDot: 'slot-connection-dot'
slotConnectionDot: 'slot-connection-dot',
imageGrid: 'image-grid'
},
selectionToolbox: {
root: 'selection-toolbox',
@@ -301,12 +303,3 @@ export const TestIds = {
typeFilter: (key: 'input' | 'output') => `search-filter-${key}`
}
} as const
export type TestId<K extends keyof typeof TestIds> = Exclude<
(typeof TestIds)[K][keyof (typeof TestIds)[K]],
(...args: never[]) => string
>
export type TestIdValue = {
[K in keyof typeof TestIds]: TestId<K>
}[keyof typeof TestIds]

View File

@@ -19,7 +19,7 @@ export const sharedWorkflowImportScenario = {
inputFileName: 'shared_imported_image.png'
} as const
export type SharedWorkflowRequestEvent =
type SharedWorkflowRequestEvent =
| 'import'
| 'input-assets-including-public-before-import'
| 'input-assets-including-public-after-import'

View File

@@ -3,9 +3,7 @@ import type { Page } from '@playwright/test'
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
import type { CanvasRect } from '@/base/common/selectionBounds'
export type { CanvasRect }
export interface MeasureResult {
interface MeasureResult {
selectionBounds: CanvasRect | null
nodeVisualBounds: Record<string, CanvasRect>
}

View File

@@ -202,7 +202,7 @@ class NodeSlotReference {
}
}
export class NodeWidgetReference {
class NodeWidgetReference {
constructor(
readonly index: number,
readonly node: NodeReference

View File

@@ -3,7 +3,7 @@ import { join } from 'path'
import type { PerfMeasurement } from '@e2e/fixtures/helpers/PerformanceHelper'
export interface PerfReport {
interface PerfReport {
timestamp: string
gitSha: string
branch: string

View File

@@ -20,9 +20,7 @@ function previewExposureToEntry(
return [exposure.sourceNodeId, exposure.sourcePreviewName]
}
export function isPromotedWidgetSource(
value: unknown
): value is PromotedWidgetSource {
function isPromotedWidgetSource(value: unknown): value is PromotedWidgetSource {
return (
!!value &&
typeof value === 'object' &&
@@ -33,7 +31,7 @@ export function isPromotedWidgetSource(
)
}
export function isNodeProperty(value: unknown): value is NodeProperty {
function isNodeProperty(value: unknown): value is NodeProperty {
if (value === null || value === undefined) return false
const t = typeof value
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'

View File

@@ -1,13 +1,13 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
export interface SlotMeasurement {
interface SlotMeasurement {
key: string
offsetX: number
offsetY: number
}
export interface NodeSlotData {
interface NodeSlotData {
nodeId: string
nodeW: number
nodeH: number

View File

@@ -22,7 +22,9 @@ export class VueNodeFixture {
public readonly root: Locator
public readonly widgets: Locator
public readonly imagePreview: Locator
public readonly imageGrid: Locator
public readonly content: Locator
public readonly resize: { bottomRight: Locator }
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
@@ -35,7 +37,10 @@ export class VueNodeFixture {
this.root = locator
this.widgets = this.locator.locator('.lg-node-widget')
this.imagePreview = locator.locator('.image-preview')
this.imageGrid = locator.getByTestId(TestIds.node.imageGrid)
this.content = locator.locator('.lg-node-content')
const bottomRight = locator.getByRole('button', { name: 'bottom-right' })
this.resize = { bottomRight }
}
async getTitle(): Promise<string> {

View File

@@ -0,0 +1,68 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { z } from 'zod'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
const shareId = 'fe828-long-name'
// Unbroken, space-free name (mimics a content-hash workflow name) that cannot
// wrap at whitespace and previously forced the dialog to scroll horizontally.
const longWorkflowName =
'c23df0133afe9cf61a9c0e3b1f5d8a7e6429bd14f0a3c8e2d9b7165430fedcba99887766554433221100ffeeddccbbaa'
const longNameWorkflowResponse: SharedWorkflowResponse = {
share_id: shareId,
workflow_id: 'fe828-long-name-workflow',
name: longWorkflowName,
listed: true,
publish_time: '2026-05-01T00:00:00Z',
workflow_json: {
version: 0.4,
last_node_id: 0,
last_link_id: 0,
nodes: [],
links: []
},
assets: []
}
async function mockLongNameSharedWorkflow(page: Page): Promise<void> {
await page.route(`**/workflows/published/${shareId}`, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(longNameWorkflowResponse)
})
})
}
const test = comfyPageFixture
test.describe('Open shared workflow dialog', { tag: '@cloud' }, () => {
test('wraps a long workflow name instead of scrolling horizontally', async ({
comfyPage
}) => {
const { page } = comfyPage
await mockLongNameSharedWorkflow(page)
await comfyPage.setup({ clearStorage: false, url: `/?share=${shareId}` })
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
await expect(
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
const heading = dialog.locator('main h2')
await expect(heading).toHaveText(longWorkflowName)
const { scrollWidth, clientWidth } = await dialog.evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth
}))
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1)
})
})

View File

@@ -1,7 +1,60 @@
import { expect } from '@playwright/test'
import { mergeTests } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import type { NodeError } from '@/schemas/apiSchema'
import {
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const VALIDATION_ERROR_NODE_ID = '1'
const VALIDATION_ERROR_MESSAGE = 'Required input is missing: source'
const PARTIAL_EXECUTION_ROOT_NODE_IDS = ['1', '4']
type PromptRequestNode = {
class_type?: string
}
type PromptRequestBody = {
prompt?: Record<string, PromptRequestNode>
}
function buildPreviewAnyValidationError(): NodeError {
return {
class_type: 'PreviewAny',
dependent_outputs: [VALIDATION_ERROR_NODE_ID],
errors: [
{
type: 'required_input_missing',
message: VALIDATION_ERROR_MESSAGE,
details: '',
extra_info: { input_name: 'source' }
}
]
}
}
function expectPartialExecutionRootNodes(requestBody: unknown): void {
const prompt = (requestBody as PromptRequestBody).prompt ?? {}
for (const nodeId of PARTIAL_EXECUTION_ROOT_NODE_IDS) {
expect(prompt[nodeId]).toMatchObject({ class_type: 'PreviewAny' })
}
}
async function getValidationErrorMessage(comfyPage: ComfyPage) {
return await comfyPage.page.evaluate(
(nodeId) =>
window.app!.extensionManager.lastNodeErrors?.[nodeId]?.errors[0]
?.message ?? null,
VALIDATION_ERROR_NODE_ID
)
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -74,3 +127,48 @@ test.describe(
})
}
)
test.describe('Execution validation errors', { tag: '@workflow' }, () => {
test('preserves validation errors when another active root starts execution', async ({
comfyPage,
getWebSocket
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
const ws = await getWebSocket()
const exec = new ExecutionHelper(comfyPage, ws)
const nodeErrors = {
[VALIDATION_ERROR_NODE_ID]: buildPreviewAnyValidationError()
}
let promptRequestBody: unknown
const jobId = await exec.run({
nodeErrors,
onPromptRequest: (requestBody) => {
promptRequestBody = requestBody
}
})
expectPartialExecutionRootNodes(promptRequestBody)
await expect
.poll(() => getValidationErrorMessage(comfyPage))
.toBe(VALIDATION_ERROR_MESSAGE)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await comfyPage.nextFrame()
exec.executionStart(jobId)
await expect
.poll(() => getValidationErrorMessage(comfyPage))
.toBe(VALIDATION_ERROR_MESSAGE)
await expect(errorOverlay).toBeVisible()
})
})

View File

@@ -76,6 +76,34 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await maskEditor.drawStrokeAndExpectPixels(dialog)
})
test(
'Middle-click drag should pan the mask editor canvas',
{ tag: ['@canvas'] },
async ({ comfyPage, comfyMouse, maskEditor }) => {
const dialog = await maskEditor.openDialog()
const pointerZone = dialog.getByTestId('pointer-zone')
const getCanvasPosition = () =>
comfyPage.page.evaluate(() => {
const container = document.querySelector('#maskEditorCanvasContainer')
if (!(container instanceof HTMLElement)) return null
return {
left: container.style.left,
top: container.style.top
}
})
const canvasPositionBefore = await getCanvasPosition()
await comfyMouse.middleDragFromCenter(
pointerZone,
{ x: 140, y: 90 },
{ steps: 10 }
)
await expect.poll(getCanvasPosition).not.toEqual(canvasPositionBefore)
}
)
test('undo reverts a brush stroke', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -9,7 +9,7 @@ test.describe(
() => {
test.beforeEach(async ({ comfyPage }) => {
// Keep the viewport well below the menu content height so overflow is guaranteed.
await comfyPage.page.setViewportSize({ width: 1280, height: 420 })
await comfyPage.page.setViewportSize({ width: 1280, height: 300 })
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')

View File

@@ -233,21 +233,21 @@ test.describe('Node search box', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await comfyPage.searchBox.addFilter('CLIP', 'Output Type')
await comfyPage.searchBox.addFilter('utils', 'Category')
await comfyPage.searchBox.addFilter('utilities', 'Category')
})
test('Can remove first filter', async ({ comfyPage }) => {
await comfyPage.searchBox.removeFilter(0)
await expectFilterChips(comfyPage, ['CLIP', 'utils'])
await expectFilterChips(comfyPage, ['CLIP', 'utilities'])
await comfyPage.searchBox.removeFilter(0)
await expectFilterChips(comfyPage, ['utils'])
await expectFilterChips(comfyPage, ['utilities'])
await comfyPage.searchBox.removeFilter(0)
await expectFilterChips(comfyPage, [])
})
test('Can remove middle filter', async ({ comfyPage }) => {
await comfyPage.searchBox.removeFilter(1)
await expectFilterChips(comfyPage, ['MODEL', 'utils'])
await expectFilterChips(comfyPage, ['MODEL', 'utilities'])
})
test('Can remove last filter', async ({ comfyPage }) => {

View File

@@ -56,6 +56,34 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
await expect(nodeRef).not.toBeCollapsed()
})
test('More Options menu does not surface duplicate LiteGraph Resize / Collapse / Expand entries', async ({
comfyPage
}) => {
const nodeRef = (
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
)[0]
await comfyPage.nodeOps.selectNodeWithPan(nodeRef)
const menu = await openMoreOptions(comfyPage)
await expect(
menu.getByText('Minimize Node', { exact: true })
).toBeVisible()
await expect(
menu.getByRole('menuitem', { name: 'Resize', exact: true })
).toHaveCount(0)
await expect(
menu.getByRole('menuitem', { name: 'Collapse', exact: true })
).toHaveCount(0)
await menu.getByText('Minimize Node', { exact: true }).click()
await openMoreOptions(comfyPage)
await expect(
menu.getByRole('menuitem', { name: 'Expand', exact: true })
).toHaveCount(0)
})
test('copy via More Options menu', async ({ comfyPage }) => {
const nodeRef = (
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')

View File

@@ -1,8 +1,10 @@
import { expect } from '@playwright/test'
import type { Route } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { openErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import type { UserDataFullInfo } from '@/schemas/apiSchema'
test.describe('Workflows sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -45,6 +47,56 @@ test.describe('Workflows sidebar', () => {
.toEqual(expect.arrayContaining(['workflow1', 'workflow2']))
})
test(
'Shows loading state while refreshing workflows',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
const workflowsSyncRoute = /\/api\/userdata\?[^#]*\bdir=workflows\b/
const emptyWorkflowList: UserDataFullInfo[] = []
let releaseSync!: () => void
const syncBlocked = new Promise<void>((resolve) => {
releaseSync = resolve
})
const syncFulfillments: Promise<void>[] = []
const holdSyncResponse = async (route: Route) => {
if (route.request().method() !== 'GET') {
await route.fallback()
return
}
const syncFulfilled = syncBlocked.then(() =>
route.fulfill({ json: emptyWorkflowList })
)
syncFulfillments.push(syncFulfilled)
await syncFulfilled
}
await comfyPage.page.route(workflowsSyncRoute, holdSyncResponse)
try {
const syncRequest = comfyPage.page.waitForRequest((request) =>
workflowsSyncRoute.test(request.url())
)
await tab.refreshButton.click()
await syncRequest
await expect(tab.refreshButton).toBeDisabled()
await expect(tab.refreshButton).toHaveAttribute('aria-busy', 'true')
} finally {
releaseSync()
await Promise.all(syncFulfillments)
await comfyPage.page.unroute(workflowsSyncRoute, holdSyncResponse)
}
await expect(tab.refreshButton).toBeEnabled()
await expect(tab.refreshButton).toHaveAttribute('aria-busy', 'false')
}
)
test('Can duplicate workflow', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await comfyPage.menu.topbar.saveWorkflow('workflow1')

View File

@@ -0,0 +1,87 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
async function waitForRootCanvasReady(page: Page) {
await expect
.poll(async () => {
const state = await page.evaluate(() => ({
rootId: window.app?.rootGraph?.id ?? '',
canvasGraphId: window.app?.canvas?.graph?.id ?? ''
}))
return state.rootId !== '' && state.canvasGraphId === state.rootId
})
.toBe(true)
}
async function expectCanvasOnRootGraph(page: Page) {
await expect
.poll(async () =>
page.evaluate(() => ({
rootId: window.app!.rootGraph.id,
canvasGraphId: window.app!.canvas.graph?.id,
hash: window.location.hash
}))
)
.toEqual({
rootId: expect.any(String),
canvasGraphId: expect.stringMatching(/.+/),
hash: expect.stringMatching(/^#.+/)
})
const state = await page.evaluate(() => ({
rootId: window.app!.rootGraph.id,
canvasGraphId: window.app!.canvas.graph?.id,
hash: window.location.hash
}))
expect(state.canvasGraphId).toBe(state.rootId)
expect(state.hash).toBe(`#${state.rootId}`)
}
test.describe(
'Subgraph hash validation (FE-559)',
{ tag: ['@subgraph'] },
() => {
test('redirects URL and canvas to root for a non-existent subgraph hash', async ({
comfyPage
}) => {
await waitForRootCanvasReady(comfyPage.page)
const rootId = await comfyPage.page.evaluate(
() => window.app!.rootGraph.id
)
const phantomId = '11111111-1111-4111-8111-111111111111'
expect(phantomId).not.toBe(rootId)
await comfyPage.page.evaluate((hash) => {
window.location.hash = hash
}, `#${phantomId}`)
await expect
.poll(() => comfyPage.page.evaluate(() => window.location.hash), {
timeout: 5000
})
.toBe(`#${rootId}`)
await expectCanvasOnRootGraph(comfyPage.page)
})
test('redirects URL and canvas to root when hash is malformed', async ({
comfyPage
}) => {
await waitForRootCanvasReady(comfyPage.page)
const rootId = await comfyPage.page.evaluate(
() => window.app!.rootGraph.id
)
await comfyPage.page.evaluate(() => {
window.location.hash = '#not-a-valid-uuid'
})
await expect
.poll(() => comfyPage.page.evaluate(() => window.location.hash), {
timeout: 5000
})
.toBe(`#${rootId}`)
await expectCanvasOnRootGraph(comfyPage.page)
})
}
)

View File

@@ -1,8 +1,7 @@
import { expect } from '@playwright/test'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -651,6 +650,12 @@ test(
await expect.poll(isConnected).toBe(true)
})
const rawClip = await comfyPage.subgraph.getInputBounds()
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
const clip = { ...rawClip, ...absolutePos }
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
const twoLinkScreenshot = await comfyPage.page.screenshot({ clip })
const stepsSlot = ksampler.getSlot('steps')
await test.step('Node -> I/O hover effect', async () => {
@@ -659,9 +664,6 @@ test(
await comfyPage.page.mouse.down()
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
const rawClip = await comfyPage.subgraph.getInputBounds()
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
const clip = { ...rawClip, ...absolutePos }
await expect(comfyPage.page).toHaveScreenshot('vue-io-highlight.png', {
clip
})
@@ -699,5 +701,18 @@ test(
'opacity',
'0'
)
await test.step('Can disconnect link by right click', async () => {
const stepsIOSlot = await comfyPage.subgraph.getInputSlot('steps')
const { x, y } = await stepsIOSlot.getPosition()
await comfyPage.page.mouse.click(x, y, { button: 'right' })
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await expect(slotParent).toHaveCSS('opacity', '0')
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
const postScreenshot = await comfyPage.page.screenshot({ clip })
expect(postScreenshot).toStrictEqual(twoLinkScreenshot)
})
}
)

View File

@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { WorkflowTemplates } from '@/platform/workflow/templates/types/template'
import { getWav } from '@e2e/fixtures/components/AudioPreview'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -450,4 +451,57 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
)
}
)
test('Can open associated tutorial', async ({ comfyPage }) => {
const tutorialUrl = 'https://comfyanonymous.github.io/ComfyUI_examples/'
await comfyPage.page.route('**/templates/index.json', async (route) => {
const response = [
{
moduleName: 'default',
title: 'Test Templates',
type: 'image',
templates: [
{
name: 'template-with-tutorial',
title: 'Template with a tutorial',
mediaType: 'audio',
mediaSubtype: 'wav',
description: 'This template has a tutorial',
tutorialUrl
}
]
}
]
await route.fulfill({
status: 200,
body: JSON.stringify(response),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.page.route('**/templates/**.wav', async (route) => {
await route.fulfill({
status: 200,
body: getWav(),
headers: {
'Content-Type': 'image/x-wav',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
const card = comfyPage.page.getByTestId(
'template-workflow-template-with-tutorial'
)
await card.hover()
const tutorialButton = card.getByRole('button', { name: 'See a tutorial' })
await expect(tutorialButton).toBeVisible()
const popupPromise = comfyPage.page.waitForEvent('popup', { timeout: 0 })
await tutorialButton.click()
const popup = await popupPromise
expect(popup.url()).toEqual(tutorialUrl)
})
})

View File

@@ -4,6 +4,29 @@ import {
} from '@e2e/fixtures/ComfyPage'
test.describe('Vue Nodes Canvas Pan', { tag: '@vue-nodes' }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test(
'Middle-click drag on a Vue node pans canvas',
{ tag: ['@canvas'] },
async ({ comfyPage, comfyMouse }) => {
const node = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const offsetBefore = await comfyPage.canvasOps.getOffset()
await comfyMouse.middleDragFromCenter(
node,
{ x: 140, y: 90 },
{ steps: 10 }
)
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBefore)
}
)
test(
'@mobile Can pan with touch',
{ tag: '@screenshot' },

View File

@@ -1,11 +1,15 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import {
getPromotedWidgetNames,
getPromotedWidgetCountByName
} from '@e2e/fixtures/utils/promotedWidgets'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
async function loadImageOnNode(comfyPage: ComfyPage) {
@@ -136,3 +140,44 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
}
)
})
async function countColumns(locator: Locator) {
return await locator.locator('img').evaluateAll((images) => {
const yOffsets = images.map((image) => image.getBoundingClientRect().y)
return yOffsets.filter((yOffset) => yOffset === yOffsets[0]).length
})
}
test.describe('Vue Nodes Batch Image Preview', { tag: '@vue-nodes' }, () => {
wstest(
'Image previews tile to fit node',
async ({ comfyMouse, comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Preview Image')
const previewImage = comfyPage.vueNodes.getNodeByTitle('Preview Image')
await expect(previewImage).toBeVisible()
})
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
await test.step('Inject multiple previews', async () => {
const file = { filename: 'example.png', type: 'input' }
const images = new Array(100).fill(file)
execution.executed('', '1', { images })
await expect(node.imageGrid.locator('img')).toHaveCount(100)
})
const { bottomRight } = node.resize
await expect.poll(() => countColumns(node.imageGrid)).toBe(10)
await comfyMouse.resizeByDragging(bottomRight, { x: 200 })
await expect.poll(() => countColumns(node.imageGrid)).toBeGreaterThan(10)
await comfyMouse.resizeByDragging(bottomRight, { x: -200, y: 200 })
await expect.poll(() => countColumns(node.imageGrid)).toBeLessThan(10)
}
)
})

View File

@@ -247,11 +247,8 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
)
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-moved-node-touch.png'
)
expect(newHeaderPos.x).toBeCloseTo(loadCheckpointHeaderPos.x + 64)
expect(newHeaderPos.y).toBeCloseTo(loadCheckpointHeaderPos.y + 64)
}
)
})

View File

@@ -5,6 +5,10 @@ import {
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
const getFirstClipNode = (comfyPage: ComfyPage) =>
comfyPage.vueNodes.getNodeByTitle('CLIP Text Encode (Prompt)').first()
@@ -54,4 +58,23 @@ test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => {
await textarea.click({ button: 'right' })
await expect(vueContextMenu).toBeVisible()
})
test(
'Middle-click drag on textarea should pan canvas',
{ tag: ['@canvas', '@widget'] },
async ({ comfyPage, comfyMouse }) => {
const textarea = getFirstMultilineStringWidget(comfyPage)
const offsetBefore = await comfyPage.canvasOps.getOffset()
await comfyMouse.middleDragFromCenter(
textarea,
{ x: 140, y: 90 },
{ steps: 10 }
)
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBefore)
}
)
})

View File

@@ -145,9 +145,7 @@ export default defineConfig([
eslintConfigPrettier,
// @ts-expect-error Type incompatibility between storybook plugin and ESLint config types
storybookConfigs['flat/recommended'],
// @ts-expect-error Type incompatibility between import-x plugin and ESLint config types
importX.flatConfigs.recommended,
// @ts-expect-error Type incompatibility between import-x plugin and ESLint config types
importX.flatConfigs.typescript,
{
plugins: {

View File

@@ -43,7 +43,6 @@ const config: KnipConfig = {
'@iconify/json',
'@primeuix/forms',
'@primeuix/styled',
'@primeuix/utils',
'@primevue/icons'
],
ignore: [
@@ -74,7 +73,7 @@ const config: KnipConfig = {
},
playwright: {
config: ['playwright?(.*).config.ts'],
entry: ['**/*.@(spec|test).?(c|m)[jt]s?(x)', 'browser_tests/**/*.ts']
entry: ['browser_tests/**/*.@(spec|test).?(c|m)[jt]s?(x)']
},
tags: [
'-knipIgnoreUnusedButUsedByCustomNodes',

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.46.3",
"version": "1.46.6",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -97,7 +97,7 @@
"axios": "catalog:",
"chart.js": "^4.5.0",
"cva": "catalog:",
"dompurify": "^3.2.5",
"dompurify": "catalog:",
"dotenv": "catalog:",
"es-toolkit": "^1.39.9",
"extendable-media-recorder": "^9.2.27",
@@ -193,7 +193,7 @@
"unplugin-icons": "catalog:",
"unplugin-typegpu": "catalog:",
"unplugin-vue-components": "catalog:",
"uuid": "^11.1.0",
"uuid": "catalog:",
"vite": "catalog:",
"vite-plugin-dts": "catalog:",
"vite-plugin-html": "catalog:",

View File

@@ -1892,3 +1892,17 @@ audio.comfy-audio.empty-audio-widget {
300% 14px;
background-attachment: local, local, scroll, scroll;
}
/*
PrimeVue overlays teleport to body. When a Reka modal dialog is open it sets
body { pointer-events: none } via DismissableLayer, which propagates to the
body-portaled overlays and makes them unclickable. PrimeVue's own Dialog
sets pointer-events inline, but Select / ColorPicker / Popover / Autocomplete
overlays do not, so they need to opt in here.
*/
.p-select-overlay,
.p-colorpicker-panel,
.p-popover,
.p-autocomplete-overlay {
pointer-events: auto;
}

2515
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,13 +13,13 @@ catalog:
'@astrojs/sitemap': ^3.7.1
'@astrojs/vue': ^5.0.0
'@comfyorg/comfyui-electron-types': 0.6.2
'@eslint/js': ^9.39.1
'@eslint/js': ^10.0.1
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
'@iconify/tailwind4': ^1.2.0
'@iconify/tailwind4': ^1.2.3
'@iconify/utils': ^3.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
'@intlify/eslint-plugin-vue-i18n': ^4.5.0
'@lobehub/i18n-cli': ^1.26.1
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.58.1
@@ -66,33 +66,33 @@ catalog:
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
astro: ^5.10.0
axios: ^1.13.5
axios: ^1.15.2
cross-env: ^10.1.0
cva: 1.0.0-beta.4
dompurify: ^3.3.1
dompurify: ^3.4.5
dotenv: ^16.4.5
eslint: ^9.39.1
eslint: ^10.4.0
eslint-config-prettier: ^10.1.8
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.1
eslint-plugin-import-x: ^4.16.2
eslint-plugin-oxlint: 1.59.0
eslint-plugin-playwright: ^2.10.1
eslint-plugin-storybook: ^10.2.10
eslint-plugin-testing-library: ^7.16.1
eslint-plugin-unused-imports: ^4.3.0
eslint-plugin-vue: ^10.6.2
eslint-plugin-unused-imports: ^4.4.1
eslint-plugin-vue: ^10.9.1
fast-check: ^4.5.3
firebase: ^11.6.0
glob: ^13.0.6
globals: ^16.5.0
gsap: ^3.14.2
happy-dom: ^20.0.11
happy-dom: ^20.8.9
husky: ^9.1.7
jiti: 2.6.1
jsdom: ^27.4.0
jsonata: ^2.1.0
knip: ^6.3.1
knip: ^6.14.1
lenis: ^1.3.21
lint-staged: ^16.2.7
markdown-table: ^3.0.4
@@ -108,28 +108,29 @@ catalog:
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5
reka-ui: ^2.5.0
reka-ui: 2.5.0
rollup-plugin-visualizer: ^6.0.4
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.3.0
three: ^0.184.0
tailwindcss-primeui: ^0.6.1
three: ^0.184.0
tsx: ^4.15.6
tw-animate-css: ^1.3.8
typegpu: ^0.8.2
typescript: ^5.9.3
typescript-eslint: ^8.49.0
typescript-eslint: ^8.60.0
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
uuid: ^11.1.1
vee-validate: ^4.15.1
vite: ^8.0.0
vite: ^8.0.13
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0
vitest: ^4.0.16
vue: ^3.5.13
vue: ^3.5.34
vue-component-type-helpers: ^3.2.1
vue-eslint-parser: ^10.4.0
vue-i18n: ^9.14.5
@@ -160,3 +161,9 @@ overrides:
vite: 'catalog:'
'@tiptap/pm': 2.27.2
'@types/eslint': '-'
#Security overrides
lodash: ^4.18.0
yaml: ^2.8.3
minimatch@^9.0.0: ^9.0.7
minimatch@^10.0.0: ^10.2.3
ajv@^8.0.0: ^8.18.0

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import {
isMiddleButtonEvent,
isMiddleButtonHeld,
isMiddlePointerInput
} from '@/base/pointerUtils'
describe('pointerUtils', () => {
describe('isMiddlePointerInput', () => {
it.for([
{
name: 'accepts a middle-button pointerdown',
event: new PointerEvent('pointerdown', { button: 1, buttons: 4 }),
expected: true
},
{
name: 'accepts strict middle-only held buttons',
event: new PointerEvent('pointermove', { buttons: 4 }),
expected: true
},
{
name: 'rejects chorded pointerdown when middle is only incidentally held',
event: new PointerEvent('pointerdown', { button: 0, buttons: 5 }),
expected: false
}
])('$name', ({ event, expected }) => {
expect(isMiddlePointerInput(event)).toBe(expected)
})
})
describe('isMiddleButtonHeld', () => {
it.for([
{
name: 'accepts the middle-button bit alone',
event: new PointerEvent('pointermove', { buttons: 4 }),
expected: true
},
{
name: 'accepts chorded moves that include the middle-button bit',
event: new PointerEvent('pointermove', { buttons: 5 }),
expected: true
},
{
name: 'accepts pointercancel when the middle-button bit is still held',
event: new PointerEvent('pointercancel', { buttons: 4 }),
expected: true
},
{
name: 'rejects primary-button-only moves',
event: new PointerEvent('pointermove', { buttons: 1 }),
expected: false
}
])('$name', ({ event, expected }) => {
expect(isMiddleButtonHeld(event)).toBe(expected)
})
})
describe('isMiddleButtonEvent', () => {
it.for([
{
name: 'accepts a middle-button pointerup',
event: new PointerEvent('pointerup', { button: 1 }),
expected: true
},
{
name: 'rejects a non-middle changed button even when middle is held',
event: new MouseEvent('auxclick', { button: 2, buttons: 4 }),
expected: false
}
])('$name', ({ event, expected }) => {
expect(isMiddleButtonEvent(event)).toBe(expected)
})
})
})

View File

@@ -2,21 +2,14 @@
* Utilities for pointer event handling
*/
/**
* Checks if a pointer or mouse event is a middle button input
* @param event - The pointer or mouse event to check
* @returns true if the event is from the middle button/wheel
*/
export function isMiddlePointerInput(
event: PointerEvent | MouseEvent
): boolean {
if ('button' in event && event.button === 1) {
return true
}
if ('buttons' in event && typeof event.buttons === 'number') {
return event.buttons === 4
}
return false
export function isMiddlePointerInput(event: MouseEvent): boolean {
return event.button === 1 || event.buttons === 4
}
export function isMiddleButtonHeld(event: MouseEvent): boolean {
return (event.buttons & 4) === 4
}
export function isMiddleButtonEvent(event: MouseEvent): boolean {
return event.button === 1
}

View File

@@ -42,7 +42,8 @@ import type { StyleValue } from 'vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useMediaCache } from '@/services/mediaCacheService'
import type { ClassValue } from '@comfyorg/tailwind-utils'
type ClassValue = string | Record<string, boolean> | ClassValue[]
const {
src,

View File

@@ -159,7 +159,7 @@
<audio
:ref="(el) => (audioRef = el as HTMLAudioElement)"
:src="audioSrc"
:src
preload="metadata"
class="hidden"
/>
@@ -192,7 +192,6 @@ const progressRef = ref<HTMLElement>()
const {
audioRef,
waveformRef,
audioSrc,
bars,
loading,
isPlaying,

View File

@@ -190,7 +190,7 @@
variant="ghost"
rounded="lg"
:data-testid="`template-workflow-${template.name}`"
class="hover:bg-base-background"
class="group/card hover:bg-base-background"
@mouseenter="hoveredTemplate = template.name"
@mouseleave="hoveredTemplate = null"
@click="onLoadWorkflow(template)"
@@ -316,11 +316,11 @@
class="flex flex-col-reverse justify-center"
>
<Button
v-if="hoveredTemplate === template.name"
v-tooltip.bottom="$t('g.seeTutorial')"
v-bind="$attrs"
:aria-label="$t('g.seeTutorial')"
variant="inverted"
size="icon"
class="not-group-hover/card:opacity-0"
@click.stop="openTutorial(template)"
>
<i class="icon-[lucide--info] size-4" />

View File

@@ -8,6 +8,10 @@ import { defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import {
onRekaFocusOutside,
onRekaPointerDownOutside
} from '@/components/dialog/rekaPrimeVueBridge'
import { useDialogStore } from '@/stores/dialogStore'
const i18n = createI18n({
@@ -190,3 +194,88 @@ describe('GlobalDialog Reka parity with PrimeVue', () => {
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
})
})
describe('shouldPreventRekaDismiss', () => {
function makeEvent(target: Element | null) {
let prevented = false
return {
detail: { originalEvent: { target } },
preventDefault: () => {
prevented = true
},
get defaultPrevented() {
return prevented
}
} as unknown as CustomEvent<{ originalEvent: PointerEvent }> & {
defaultPrevented: boolean
}
}
it.for([
'p-select-overlay',
'p-colorpicker-panel',
'p-popover',
'p-autocomplete-overlay',
'p-overlay-mask',
'p-dialog'
])('prevents dismiss when target is inside %s', (className) => {
const overlay = document.createElement('div')
overlay.className = className
const inner = document.createElement('button')
overlay.appendChild(inner)
document.body.appendChild(overlay)
const event = makeEvent(inner)
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
expect(event.defaultPrevented).toBe(true)
overlay.remove()
})
it('allows dismiss when target is outside any PrimeVue overlay', () => {
const event = makeEvent(document.body)
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
expect(event.defaultPrevented).toBe(false)
})
it('prevents dismiss when dismissableMask is false even outside an overlay', () => {
const event = makeEvent(document.body)
onRekaPointerDownOutside({ dismissableMask: false }, event)
expect(event.defaultPrevented).toBe(true)
})
it.for(['p-dialog', 'p-select-overlay'])(
'focus-outside on a sibling %s portal does not dismiss the parent',
(className) => {
const overlay = document.createElement('div')
overlay.className = className
const inner = document.createElement('button')
overlay.appendChild(inner)
document.body.appendChild(overlay)
const event = makeEvent(inner)
onRekaFocusOutside(event)
expect(event.defaultPrevented).toBe(true)
overlay.remove()
}
)
it('focus-outside still dismisses when focus moves to a non-portal element', () => {
const event = makeEvent(document.body)
onRekaFocusOutside(event)
expect(event.defaultPrevented).toBe(false)
})
it('focus-outside on a sibling Reka portal does not dismiss the parent', () => {
const portal = document.createElement('div')
portal.setAttribute('role', 'dialog')
document.body.appendChild(portal)
const event = makeEvent(portal)
onRekaFocusOutside(event)
expect(event.defaultPrevented).toBe(true)
portal.remove()
})
})

View File

@@ -8,9 +8,14 @@
@update:open="(open) => onRekaOpenChange(item.key, open)"
>
<DialogPortal>
<DialogOverlay />
<DialogOverlay
v-reka-z-index
:class="item.dialogComponentProps.overlayClass"
/>
<DialogContent
v-reka-z-index
:size="item.dialogComponentProps.size ?? 'md'"
:maximized="!!item.dialogComponentProps.maximized"
:class="item.dialogComponentProps.contentClass"
:aria-labelledby="item.key"
@escape-key-down="
@@ -19,34 +24,51 @@
e.preventDefault()
"
@pointer-down-outside="
(e) =>
item.dialogComponentProps.dismissableMask === false &&
e.preventDefault()
(e) => onRekaPointerDownOutside(item.dialogComponentProps, e)
"
@focus-outside="onRekaFocusOutside"
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
>
<DialogHeader v-if="!item.dialogComponentProps.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<DialogTitle v-else :id="item.key">
{{ item.title || ' ' }}
</DialogTitle>
<DialogClose v-if="item.dialogComponentProps.closable !== false" />
</DialogHeader>
<div class="flex-1 overflow-auto px-4 py-2">
<template v-if="item.dialogComponentProps.headless">
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</div>
<DialogFooter v-if="item.footerComponent">
<component :is="item.footerComponent" v-bind="item.footerProps" />
</DialogFooter>
</template>
<template v-else>
<DialogHeader>
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<DialogTitle v-else :id="item.key">
{{ item.title || ' ' }}
</DialogTitle>
<div class="flex items-center gap-1">
<DialogMaximize
v-if="item.dialogComponentProps.maximizable"
:maximized="!!item.dialogComponentProps.maximized"
@toggle="toggleMaximize(item)"
/>
<DialogClose
v-if="item.dialogComponentProps.closable !== false"
/>
</div>
</DialogHeader>
<div class="flex-1 overflow-auto px-4 py-2">
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</div>
<DialogFooter v-if="item.footerComponent">
<component :is="item.footerComponent" v-bind="item.footerProps" />
</DialogFooter>
</template>
</DialogContent>
</DialogPortal>
</Dialog>
@@ -55,7 +77,6 @@
v-model:visible="item.visible"
class="global-dialog"
v-bind="item.dialogComponentProps"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"
>
<template #header>
@@ -86,29 +107,25 @@
</template>
<script setup lang="ts">
import { merge } from 'es-toolkit/compat'
import PrimeDialog from 'primevue/dialog'
import type { DialogPassThroughOptions } from 'primevue/dialog'
import { computed } from 'vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogMaximize from '@/components/ui/dialog/DialogMaximize.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { DialogComponentProps, DialogInstance } from '@/stores/dialogStore'
import {
onRekaFocusOutside,
onRekaPointerDownOutside
} from '@/components/dialog/rekaPrimeVueBridge'
import { vRekaZIndex } from '@/components/dialog/vRekaZIndex'
import type { DialogInstance } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const dialogStore = useDialogStore()
function isRekaItem(item: DialogInstance) {
@@ -119,20 +136,8 @@ function onRekaOpenChange(key: string, open: boolean) {
if (!open) dialogStore.closeDialog({ key })
}
function getDialogPt(item: {
key: string
dialogComponentProps: DialogComponentProps
}): DialogPassThroughOptions {
const isWorkspaceSettingsDialog =
item.key === 'global-settings' && teamWorkspacesEnabled.value
const basePt = item.dialogComponentProps.pt || {}
if (isWorkspaceSettingsDialog) {
return merge(basePt, {
mask: { class: 'p-8' }
})
}
return basePt
function toggleMaximize(item: DialogInstance) {
item.dialogComponentProps.maximized = !item.dialogComponentProps.maximized
}
</script>
@@ -163,19 +168,6 @@ function getDialogPt(item: {
}
}
/* Workspace mode: wider settings dialog */
.settings-dialog-workspace {
width: 100%;
max-width: 1440px;
height: 100%;
}
.settings-dialog-workspace .p-dialog-content {
width: 100%;
height: 100%;
overflow-y: auto;
}
.manager-dialog {
height: 80vh;
max-width: 1724px;

View File

@@ -244,7 +244,7 @@
<ContextMenuPortal>
<ContextMenuContent
:style="keybindingOverlayContentStyle"
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
class="z-1800 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
>
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"

View File

@@ -0,0 +1,49 @@
// PrimeVue overlays (Select, ColorPicker, Popover, Autocomplete, stacked
// PrimeVue Dialogs) teleport to body. Reka treats clicks on body-portaled
// elements as outside its dialog and would auto-dismiss on the first
// interaction, tearing the overlay down mid-interaction. Treat any
// PrimeVue overlay click as inside.
const PRIMEVUE_OVERLAY_SELECTORS =
'.p-select-overlay, .p-colorpicker-panel, .p-popover, .p-autocomplete-overlay, .p-overlay, .p-overlay-mask, .p-dialog'
// Reka portals its own dialogs / popovers / menus into the body too. When a
// nested Reka layer opens on top of a non-modal parent, the parent's
// DismissableLayer sees the focus shift / pointer-down as "outside" and would
// dismiss itself. These selectors cover the portaled roots so we can treat
// interactions on them as inside.
const REKA_PORTAL_SELECTORS =
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"]'
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`
type OutsideEvent = CustomEvent<{ originalEvent: Event }>
function isInsideOverlay(target: EventTarget | null): boolean {
return (
target instanceof Element &&
target.closest(OUTSIDE_LAYER_SELECTORS) !== null
)
}
export function onRekaPointerDownOutside(
options: { dismissableMask?: boolean },
event: OutsideEvent
) {
if (isInsideOverlay(event.detail.originalEvent.target)) {
event.preventDefault()
return
}
if (options.dismissableMask === false) {
event.preventDefault()
}
}
// Focus / interact-outside fires when focus moves to a sibling portal (a
// nested Reka or PrimeVue dialog teleported to body). Without this guard a
// non-modal Reka dialog would dismiss itself the moment a nested dialog
// receives focus.
export function onRekaFocusOutside(event: OutsideEvent) {
if (isInsideOverlay(event.detail.originalEvent.target)) {
event.preventDefault()
}
}

View File

@@ -0,0 +1,17 @@
import { ZIndex } from '@primeuix/utils/zindex'
import type { Directive } from 'vue'
// Both Reka and PrimeVue dialogs can appear at any depth in dialogStack, in
// any order. PrimeVue auto-increments a per-key z-index counter so later
// dialogs always cover earlier ones; Reka uses a static z-1700 class which
// can lose to an already-open PrimeVue dialog. Registering Reka's content
// element with the same ZIndex counter (key 'modal', base 1700) makes both
// renderers share one stacking sequence: whichever dialog opens last wins.
export const vRekaZIndex: Directive<HTMLElement> = {
mounted(el) {
ZIndex.set('modal', el, 1700)
},
beforeUnmount(el) {
ZIndex.clear(el)
}
}

View File

@@ -63,9 +63,9 @@
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
:canvas="comfyApp.canvas"
@wheel.capture="canvasInteractions.forwardEventToCanvas"
@pointerdown.capture="forwardPanEvent"
@pointerup.capture="forwardPanEvent"
@pointermove.capture="forwardPanEvent"
@pointerdown.capture="forwardPointerDownPanEvent"
@pointerup.capture="forwardPointerUpPanEvent"
@pointermove.capture="forwardPointerMovePanEvent"
>
<!-- Vue nodes rendered based on graph nodes -->
<LGraphNode
@@ -120,7 +120,11 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import {
isMiddleButtonEvent,
isMiddleButtonHeld,
isMiddlePointerInput
} from '@/base/pointerUtils'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import TopMenuSection from '@/components/TopMenuSection.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
@@ -593,8 +597,23 @@ onUnmounted(() => {
cleanupErrorHooks = null
vueNodeLifecycle.cleanup()
})
function forwardPanEvent(e: PointerEvent) {
if (!isMiddlePointerInput(e)) return
function forwardPointerDownPanEvent(e: PointerEvent) {
forwardPanEvent(e, isMiddlePointerInput)
}
function forwardPointerMovePanEvent(e: PointerEvent) {
forwardPanEvent(e, isMiddleButtonHeld)
}
function forwardPointerUpPanEvent(e: PointerEvent) {
forwardPanEvent(e, isMiddleButtonEvent)
}
function forwardPanEvent(
e: PointerEvent,
isMiddleInput: (event: PointerEvent) => boolean
) {
if (!isMiddleInput(e)) return
if (shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target)
return

View File

@@ -472,7 +472,9 @@ describe('SelectionToolbox', () => {
const forwardEventToCanvasSpy = vi.fn()
mockCanvasInteractions.mockReturnValue({
handleWheel: vi.fn(),
handlePointer: vi.fn(),
handlePointerDown: vi.fn(),
handlePointerMove: vi.fn(),
handlePointerUp: vi.fn(),
forwardEventToCanvas: forwardEventToCanvasSpy,
shouldHandleNodePointerEvents: { value: true } as ReturnType<
typeof useCanvasInteractions

View File

@@ -167,7 +167,10 @@ describe('TabErrors.vue', () => {
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('#10')).toBeInTheDocument()
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
expect(screen.getByText('Execution failed')).toBeInTheDocument()
expect(
screen.getByText('Node threw an error during execution.')
).toBeInTheDocument()
expect(screen.getByText(/Line 1/)).toBeInTheDocument()
})
@@ -246,9 +249,9 @@ describe('TabErrors.vue', () => {
})
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
expect(screen.getByText('Execution failed')).toBeInTheDocument()
expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument()
expect(screen.getAllByText('RuntimeError: Out of memory')).toHaveLength(1)
expect(screen.getAllByText('Execution failed')).toHaveLength(1)
})
it('shows missing model Refresh in the section header when no model is downloadable', async () => {

View File

@@ -46,7 +46,22 @@ vi.mock('@/i18n', () => {
'errorCatalog.promptErrors.prompt_no_outputs.title':
'Prompt has no outputs',
'errorCatalog.promptErrors.prompt_no_outputs.desc':
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.',
'errorCatalog.runtimeErrors.execution_failed.title': 'Execution failed',
'errorCatalog.runtimeErrors.execution_failed.message':
'Node threw an error during execution.',
'errorCatalog.runtimeErrors.execution_failed.itemLabel': '{nodeName}',
'errorCatalog.runtimeErrors.execution_failed.toastTitle':
'{nodeName} failed',
'errorCatalog.runtimeErrors.execution_failed.toastMessage':
'This node threw an error during execution. Check its inputs or try a different configuration.',
'errorCatalog.runtimeErrors.out_of_memory.title': 'Generation failed',
'errorCatalog.runtimeErrors.out_of_memory.message':
'Not enough GPU memory. Try reducing image resolution or batch size and run again.',
'errorCatalog.runtimeErrors.out_of_memory.itemLabel': '{nodeName}',
'errorCatalog.runtimeErrors.out_of_memory.toastTitle': 'Generation failed',
'errorCatalog.runtimeErrors.out_of_memory.toastMessage':
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
}
const interpolate = (
@@ -158,6 +173,7 @@ function createErrorGroups() {
describe('useErrorGroups', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockIsCloud.value = false
})
describe('missingPackGroups', () => {
@@ -421,7 +437,8 @@ describe('useErrorGroups', () => {
)
})
it('includes execution error from runtime errors', async () => {
it('uses general execution_failed display fields for unrecognized runtime execution errors', async () => {
mockIsCloud.value = true
const { store, groups } = createErrorGroups()
store.lastExecutionError = {
prompt_id: 'test-prompt',
@@ -430,7 +447,7 @@ describe('useErrorGroups', () => {
node_type: 'KSampler',
executed: [],
exception_type: 'RuntimeError',
exception_message: 'CUDA out of memory',
exception_message: 'mat1 and mat2 shapes cannot be multiplied',
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
@@ -443,15 +460,52 @@ describe('useErrorGroups', () => {
expect(execGroups.length).toBeGreaterThan(0)
if (execGroups[0].type !== 'execution') return
expect(execGroups[0].cards[0].errors[0]).toMatchObject({
message: 'RuntimeError: CUDA out of memory',
message: 'RuntimeError: mat1 and mat2 shapes cannot be multiplied',
details: 'line 1\nline 2',
isRuntimeError: true,
exceptionType: 'RuntimeError'
exceptionType: 'RuntimeError',
catalogId: 'execution_failed',
displayTitle: 'Execution failed',
displayMessage: 'Node threw an error during execution.',
displayItemLabel: 'KSampler',
toastTitle: 'KSampler failed',
toastMessage:
'This node threw an error during execution. Check its inputs or try a different configuration.'
})
// TODO(FE-816 overlay-redesign): Runtime execution errors intentionally
// bypass catalog display fields until targeted runtime handling lands.
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBeUndefined()
expect(execGroups[0].cards[0].errors[0].toastTitle).toBeUndefined()
})
it('adds display fields for targeted runtime execution errors', async () => {
mockIsCloud.value = true
const { store, groups } = createErrorGroups()
store.lastExecutionError = {
prompt_id: 'test-prompt',
timestamp: Date.now(),
node_id: 5,
node_type: 'KSampler',
executed: [],
exception_type: 'torch.OutOfMemoryError',
exception_message:
'Allocation on device 0 failed.\nThis error means you ran out of memory on your GPU.',
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.type).toBe('execution')
if (execGroup?.type !== 'execution') return
const error = execGroup.cards[0].errors[0]
expect(error.message).toContain('torch.OutOfMemoryError:')
expect(error.catalogId).toBe('out_of_memory')
expect(error.displayMessage).toBe(
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
)
expect(error.displayItemLabel).toBe('KSampler')
expect(error.toastTitle).toBe('Generation failed')
})
it('includes prompt error when present', async () => {

View File

@@ -427,7 +427,13 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true,
exceptionType: e.exception_type
exceptionType: e.exception_type,
...resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName:
resolveNodeInfo(String(e.node_id)).title || e.node_type
})
}
],
filterBySelection

View File

@@ -1,6 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { render } from '@testing-library/vue'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { nextTick, reactive, ref, watchEffect } from 'vue'
@@ -34,6 +35,7 @@ const {
bookmarkedWorkflows: [] as ComfyWorkflow[],
openWorkflows: [] as ComfyWorkflow[],
activeWorkflow: null as ComfyWorkflow | null,
isSyncLoading: false,
syncWorkflows: vi.fn().mockResolvedValue(undefined)
}
@@ -232,6 +234,7 @@ describe('BaseWorkflowsSidebarTab', () => {
mockWorkflowStore.bookmarkedWorkflows = []
mockWorkflowStore.openWorkflows = []
mockWorkflowStore.activeWorkflow = null
mockWorkflowStore.isSyncLoading = false
})
const renderComponent = () =>
@@ -279,6 +282,36 @@ describe('BaseWorkflowsSidebarTab', () => {
expect(getLeafPaths(getSearchRoot())).toEqual(['workflows/test-alpha.json'])
})
it('refreshes when idle and exposes busy state while workflows are syncing', async () => {
const user = userEvent.setup()
renderComponent()
const refreshButton = screen.getByRole('button', { name: 'g.refresh' })
expect(refreshButton).toBeEnabled()
expect(refreshButton).toHaveAttribute('aria-busy', 'false')
await user.click(refreshButton)
expect(mockWorkflowStore.syncWorkflows).toHaveBeenCalledTimes(1)
mockWorkflowStore.isSyncLoading = true
await nextTick()
expect(refreshButton).toBeDisabled()
expect(refreshButton).toHaveAttribute('aria-busy', 'true')
mockWorkflowStore.isSyncLoading = false
await nextTick()
expect(refreshButton).toBeEnabled()
expect(refreshButton).toHaveAttribute('aria-busy', 'false')
await user.click(refreshButton)
expect(mockWorkflowStore.syncWorkflows).toHaveBeenCalledTimes(2)
})
it('reactively updates filtered workflows when a workflow is removed', async () => {
mockWorkflowStore.workflows = [
createMockWorkflow('workflows/test-alpha.json'),

View File

@@ -11,12 +11,24 @@
<template #tool-buttons>
<Button
v-tooltip.bottom="$t('g.refresh')"
data-testid="workflows-refresh-button"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.refresh')"
:aria-busy="workflowStore.isSyncLoading"
:disabled="workflowStore.isSyncLoading"
@click="workflowStore.syncWorkflows()"
>
<i class="icon-[lucide--refresh-cw] size-4" />
<i
aria-hidden="true"
data-testid="workflows-refresh-icon"
:class="
cn(
'icon-[lucide--refresh-cw] size-4',
workflowStore.isSyncLoading && 'animate-spin'
)
"
/>
</Button>
</template>
<template #header>
@@ -170,6 +182,7 @@ import {
getWorkflowSuffix
} from '@/utils/formatUtil'
import { buildTree, sortedTree } from '@/utils/treeUtil'
import { cn } from '@comfyorg/tailwind-utils'
const { title, filter, searchSubject, dataTestid, hideLeafIcon } = defineProps<{
title: string

View File

@@ -10,11 +10,13 @@ import { dialogContentVariants } from './dialog.variants'
const {
size,
maximized = false,
class: customClass = '',
...restProps
} = defineProps<
DialogContentProps & {
size?: DialogContentSize
maximized?: boolean
class?: HTMLAttributes['class']
}
>()
@@ -26,7 +28,7 @@ const forwarded = useForwardPropsEmits(restProps, emits)
<template>
<DialogContent
v-bind="forwarded"
:class="cn(dialogContentVariants({ size }), customClass)"
:class="cn(dialogContentVariants({ size, maximized }), customClass)"
>
<slot />
</DialogContent>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
const { maximized = false } = defineProps<{ maximized?: boolean }>()
const emit = defineEmits<{ toggle: [] }>()
const { t } = useI18n()
</script>
<template>
<Button
:aria-label="maximized ? t('g.restoreDialog') : t('g.maximizeDialog')"
size="icon"
variant="muted-textonly"
@click="emit('toggle')"
>
<i
:class="
maximized ? 'icon-[lucide--minimize-2]' : 'icon-[lucide--maximize-2]'
"
/>
</Button>
</template>

View File

@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const dialogContentVariants = cva({
base: 'fixed top-1/2 left-1/2 z-1700 flex max-h-[85vh] w-[calc(100vw-1rem)] -translate-x-1/2 -translate-y-1/2 flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
base: 'fixed z-1700 flex flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
variants: {
size: {
sm: 'sm:max-w-sm',
@@ -10,14 +10,19 @@ export const dialogContentVariants = cva({
lg: 'sm:max-w-3xl',
xl: 'sm:max-w-5xl',
full: 'sm:max-w-[calc(100vw-1rem)]'
},
maximized: {
true: 'inset-2 top-2 left-2 size-auto max-h-none max-w-none sm:max-w-none',
false: 'top-1/2 left-1/2 max-h-[85vh] w-[calc(100vw-1rem)] -translate-1/2'
}
},
defaultVariants: {
size: 'md'
size: 'md',
maximized: false
}
})
export type DialogContentVariants = VariantProps<typeof dialogContentVariants>
type DialogContentVariants = VariantProps<typeof dialogContentVariants>
export type DialogContentSize = NonNullable<DialogContentVariants['size']>

View File

@@ -1,5 +1,7 @@
import { describe, it, expect } from 'vitest'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { MenuOption } from './useMoreOptionsMenu'
import {
buildStructuredMenu,
@@ -175,6 +177,28 @@ describe('contextMenuConverter', () => {
expect(result.find((opt) => opt.label === 'Properties')).toBeUndefined()
})
it.for([
{ label: 'Resize', callback: LGraphCanvas.onMenuResizeNode },
{ label: 'Collapse', callback: LGraphCanvas.onMenuNodeCollapse },
{ label: 'Expand', callback: LGraphCanvas.onMenuNodeCollapse }
])(
'should skip built-in LiteGraph $label entry by callback identity',
({ label, callback }) => {
const items = [{ content: label, callback }]
const result = convertContextMenuToOptions(items, undefined, false)
expect(result.find((opt) => opt.label === label)).toBeUndefined()
}
)
it.for(['Resize', 'Collapse', 'Expand'])(
'should keep extension-provided %s entries (different callback identity)',
(label) => {
const items = [{ content: label, callback: () => {} }]
const result = convertContextMenuToOptions(items, undefined, false)
expect(result.find((opt) => opt.label === label)).toBeDefined()
}
)
it('should convert basic menu items with content', () => {
const items = [{ content: 'Test Item', callback: () => {} }]
const result = convertContextMenuToOptions(items, undefined, false)

View File

@@ -1,6 +1,6 @@
import { default as DOMPurify } from 'dompurify'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type {
IContextMenuValue,
LGraphNode,
@@ -24,6 +24,17 @@ const HARD_BLACKLIST = new Set([
'Copy (Clipspace)'
])
/**
* Callbacks of built-in LiteGraph node menu items that are superseded by
* the Vue node menu (Minimize Node / Expand Node) or have no working
* Vue-side equivalent. Matched by callback identity so that extensions
* providing their own items with the same labels are not affected.
*/
const SUPPRESSED_LITEGRAPH_CALLBACKS = new Set<unknown>([
LGraphCanvas.onMenuResizeNode,
LGraphCanvas.onMenuNodeCollapse
])
/**
* Core menu items - items that should appear in the main menu, not under Extensions
* Includes both LiteGraph base menu items and ComfyUI built-in functionality
@@ -46,11 +57,9 @@ const CORE_MENU_ITEMS = new Set([
'Frame selection',
'Frame Nodes',
'Minimize Node',
'Expand',
'Collapse',
'Expand Node',
// Info and adjustments
'Node Info',
'Resize',
'Title',
'Properties Panel',
'Adjust Size',
@@ -231,9 +240,7 @@ const MENU_ORDER: string[] = [
'Frame selection',
'Frame Nodes',
'Minimize Node',
'Expand',
'Collapse',
'Resize',
'Expand Node',
'Clone',
// Section 4: Node properties
'Node Info',
@@ -301,14 +308,14 @@ export function buildStructuredMenu(options: MenuOption[]): MenuOption[] {
// Section boundaries based on MENU_ORDER indices
// Section 1: 0-2 (Rename, Copy, Duplicate)
// Section 2: 3-8 (Run Branch, Pin, Unpin, Bypass, Remove Bypass, Mute)
// Section 3: 9-15 (Convert to Subgraph, Frame selection, Minimize Node, Expand, Collapse, Resize, Clone)
// Section 4: 16-17 (Node Info, Color)
// Section 5: 18+ (Image operations and fallback items)
// Section 3: 9-14 (Convert to Subgraph, Frame selection, Frame Nodes, Minimize Node, Expand Node, Clone)
// Section 4: 15-16 (Node Info, Color)
// Section 5: 17+ (Image operations and fallback items)
const getSectionNumber = (index: number): number => {
if (index <= 2) return 1
if (index <= 8) return 2
if (index <= 15) return 3
if (index <= 17) return 4
if (index <= 14) return 3
if (index <= 16) return 4
return 5
}
@@ -389,6 +396,13 @@ export function convertContextMenuToOptions(
continue
}
// Skip built-in LiteGraph items that the Vue menu replaces.
// Matched by callback identity, not label, to avoid suppressing
// extension-provided items that happen to share a label.
if (item.callback && SUPPRESSED_LITEGRAPH_CALLBACKS.has(item.callback)) {
continue
}
// Skip if a similar item already exists in results
if (isDuplicateItem(item.content, result)) {
continue

View File

@@ -33,7 +33,6 @@ describe('Shift+Click Drawing Logic', () => {
// Segment 2: B -> C
const result2 = resampleSegment([pB, pC], spacing, remainder)
outputPoints.push(...result2.points)
remainder = result2.remainder
// Verify final state
// Start offset 2. Points at 2, 6, 10 (relative to B).

View File

@@ -260,7 +260,9 @@ export function useMaskEditorLoader() {
const filename = urlObj.searchParams.get('filename')
if (!filename) {
throw new Error('Image URL missing filename parameter')
throw new Error('Image URL missing filename parameter', {
cause: error
})
}
return {
@@ -269,7 +271,7 @@ export function useMaskEditorLoader() {
type: urlObj.searchParams.get('type') || undefined
}
} catch (e) {
throw new Error(`Invalid image URL: ${url}`)
throw new Error(`Invalid image URL: ${url}`, { cause: e })
}
}
}

View File

@@ -16,7 +16,7 @@ type MockStore = {
isPanning: boolean
}
const mockStore: MockStore = reactive({
const mockStore = reactive<MockStore>({
currentTool: Tools.MaskPen,
activeLayer: 'mask',
pointerZone: null,
@@ -24,7 +24,7 @@ const mockStore: MockStore = reactive({
brushPreviewGradientVisible: false,
isAdjustingBrush: false,
isPanning: false
}) as MockStore
})
const mockBrushDrawing = {
startDrawing: vi.fn().mockResolvedValue(undefined),
@@ -82,7 +82,7 @@ const mockKeyboard = {
isKeyDown: vi.fn().mockReturnValue(false),
addListeners: vi.fn(),
removeListeners: vi.fn()
}
} satisfies Parameters<typeof useToolManager>[0]
const mockPanZoom = {
initializeCanvasPanZoom: vi.fn(),
@@ -96,36 +96,43 @@ const mockPanZoom = {
invalidatePanZoom: vi.fn(),
addPenPointerId: vi.fn(),
removePenPointerId: vi.fn()
} satisfies Parameters<typeof useToolManager>[1]
type TestPointerEventInit = PointerEventInit & {
offsetX?: number
offsetY?: number
type?: string
}
const pointerEvent = (
init: Partial<PointerEvent> & { pointerType?: string }
): PointerEvent => {
return {
preventDefault: vi.fn(),
const pointerEvent = ({
offsetX = 0,
offsetY = 0,
type = 'pointerdown',
...init
}: TestPointerEventInit = {}): PointerEvent => {
const event = new PointerEvent(type, {
pointerId: 1,
pointerType: 'mouse',
button: 0,
buttons: 0,
clientX: 0,
clientY: 0,
offsetX: 0,
offsetY: 0,
altKey: false,
...init
} as unknown as PointerEvent
})
vi.spyOn(event, 'preventDefault')
Object.defineProperties(event, {
offsetX: { value: offsetX },
offsetY: { value: offsetY }
})
return event
}
let scope: EffectScope | null = null
const setup = (): ReturnType<typeof useToolManager> => {
scope = effectScope()
return scope.run(() =>
useToolManager(
mockKeyboard as unknown as Parameters<typeof useToolManager>[0],
mockPanZoom as unknown as Parameters<typeof useToolManager>[1]
)
)!
return scope.run(() => useToolManager(mockKeyboard, mockPanZoom))!
}
describe('useToolManager', () => {
@@ -307,7 +314,9 @@ describe('useToolManager', () => {
it('should start panning on middle mouse button (buttons===4)', async () => {
const tm = setup()
await tm.handlePointerDown(pointerEvent({ buttons: 4 }))
await tm.handlePointerDown(
pointerEvent({ type: 'pointerdown', buttons: 4 })
)
expect(mockPanZoom.handlePanStart).toHaveBeenCalled()
expect(mockStore.brushVisible).toBe(false)
@@ -434,7 +443,19 @@ describe('useToolManager', () => {
it('should pan on middle button drag', async () => {
const tm = setup()
await tm.handlePointerMove(pointerEvent({ buttons: 4 }))
await tm.handlePointerMove(
pointerEvent({ type: 'pointermove', buttons: 4 })
)
expect(mockPanZoom.handlePanMove).toHaveBeenCalled()
expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled()
})
it('should keep panning when middle button is held with another button', async () => {
const tm = setup()
await tm.handlePointerMove(
pointerEvent({ type: 'pointermove', buttons: 5 })
)
expect(mockPanZoom.handlePanMove).toHaveBeenCalled()
expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled()

View File

@@ -1,17 +1,19 @@
import { ref, watch } from 'vue'
import { isMiddleButtonHeld, isMiddlePointerInput } from '@/base/pointerUtils'
import type {
Point,
ImageLayer,
ToolInternalSettings
} from '@/extensions/core/maskeditor/types'
import { Tools } from '@/extensions/core/maskeditor/types'
import { app } from '@/scripts/app'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useBrushDrawing } from './useBrushDrawing'
import { useCanvasTools } from './useCanvasTools'
import { useCoordinateTransform } from './useCoordinateTransform'
import type { useKeyboard } from './useKeyboard'
import type { usePanAndZoom } from './usePanAndZoom'
import { app } from '@/scripts/app'
export function useToolManager(
keyboard: ReturnType<typeof useKeyboard>,
@@ -118,9 +120,7 @@ export function useToolManager(
panZoom.addPenPointerId(event.pointerId)
}
const isSpacePressed = keyboard.isKeyDown(' ')
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
if (shouldStartPan(event)) {
panZoom.handlePanStart(event)
store.brushVisible = false
@@ -177,9 +177,7 @@ export function useToolManager(
const newCursorPoint = { x: event.clientX, y: event.clientY }
panZoom.updateCursorPosition(newCursorPoint)
const isSpacePressed = keyboard.isKeyDown(' ')
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
if (shouldContinuePan(event)) {
await panZoom.handlePanMove(event)
return
}
@@ -223,6 +221,18 @@ export function useToolManager(
mouseDownPoint.value = null
}
function isLeftButtonSpacePan(event: PointerEvent) {
return event.buttons === 1 && keyboard.isKeyDown(' ')
}
function shouldStartPan(event: PointerEvent) {
return isMiddlePointerInput(event) || isLeftButtonSpacePan(event)
}
function shouldContinuePan(event: PointerEvent) {
return isMiddleButtonHeld(event) || isLeftButtonSpacePan(event)
}
return {
switchTool,
setActiveLayer,

View File

@@ -6,7 +6,9 @@ import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/comp
const { canvasInteractionsMock } = vi.hoisted(() => ({
canvasInteractionsMock: {
handleWheel: vi.fn(),
handlePointer: vi.fn(),
handlePointerDown: vi.fn(),
handlePointerMove: vi.fn(),
handlePointerUp: vi.fn(),
forwardEventToCanvas: vi.fn()
}
}))
@@ -41,16 +43,18 @@ describe('useNodeAnimatedImage', () => {
element.dispatchEvent(new PointerEvent('pointerdown', { button: 0 }))
expect(canvasInteractionsMock.handleWheel).toHaveBeenCalledTimes(1)
expect(canvasInteractionsMock.handlePointer).toHaveBeenCalledTimes(3)
expect(canvasInteractionsMock.handlePointerMove).toHaveBeenCalledTimes(1)
expect(canvasInteractionsMock.handlePointerUp).toHaveBeenCalledTimes(1)
expect(canvasInteractionsMock.handlePointerDown).toHaveBeenCalledTimes(1)
expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled()
})
it('routes right-click pointerdown through forwardEventToCanvas, not handlePointer', () => {
it('routes right-click pointerdown through forwardEventToCanvas, not handlePointerDown', () => {
const { element } = setup()
element.dispatchEvent(new PointerEvent('pointerdown', { button: 2 }))
expect(canvasInteractionsMock.forwardEventToCanvas).toHaveBeenCalledTimes(1)
expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled()
expect(canvasInteractionsMock.handlePointerDown).not.toHaveBeenCalled()
})
it('detaches every listener when the preview is removed', () => {
@@ -64,7 +68,9 @@ describe('useNodeAnimatedImage', () => {
element.dispatchEvent(new PointerEvent('pointerdown', { button: 2 }))
expect(canvasInteractionsMock.handleWheel).not.toHaveBeenCalled()
expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled()
expect(canvasInteractionsMock.handlePointerMove).not.toHaveBeenCalled()
expect(canvasInteractionsMock.handlePointerUp).not.toHaveBeenCalled()
expect(canvasInteractionsMock.handlePointerDown).not.toHaveBeenCalled()
expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled()
})
})

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