Compare commits

..

56 Commits

Author SHA1 Message Date
Kelly Yang
409b1d43ae Merge branch 'main' into fix/setting-store-deep-equality 2026-05-11 15:06:07 -07:00
Christian Byrne
74caeb0b0b fix: detect V1/V2 draft storage keys in new-user check (#11728)
## Problem

`checkIsNewUser()` in `useNewUserService` only checked legacy pre-V1
localStorage keys (`workflow`, `Comfy.PreviousWorkflow`) to determine if
a user had prior workflow history. A returning user who had only ever
used the V1 or V2 draft persistence system would have neither of those
keys set, causing `isNewUser()` to return `true` and the getting-started
tab to appear in the workflow templates dialog after a settings reset.

## Solution

Extend the check to also cover:
- **V1 draft store keys**: `Comfy.Workflow.Drafts`,
`Comfy.Workflow.DraftOrder`
- **V2 draft index key**: `Comfy.Workflow.DraftIndex.v2:personal`

The `personal` scope is hardcoded for the V2 check because at the time
`checkIsNewUser()` runs, the cloud workspace ID (stored in
sessionStorage) may not be set yet. This is fine — any genuine new user
will have no personal workspace index regardless.

The original legacy keys are preserved for users who may still have them
from older installs.

## Tests

Added three new test cases covering V1 draft store keys, V1 draft order
key, and V2 draft index key.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11728-fix-detect-V1-V2-draft-storage-keys-in-new-user-check-3506d73d3650819ca4cfc8e83d95c258)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-11 20:16:04 +00:00
Alexander Brown
ced7c93e63 testing: Improve custom checks in .coderabbit.yaml (#12141)
Update coderabbit end-to-end check logic.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12141-testing-Improve-custom-checks-in-coderabbit-yaml-35d6d73d3650818e8be2f0b7d403683b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-11 19:05:48 +00:00
Christian Byrne
759ed3d4e2 feat(website): add community-workflows demo page (#11942)
*PR Created by the Glary-Bot Agent*

---

Adds a new interactive demo page at
`comfy.org/demos/community-workflows` for the [Explore and Use a
Community Workflow from the
Hub](https://app.arcade.software/flows/mqZh17oWDuWIyhK0xwEV/view) Arcade
walkthrough.

Built on top of the demo infrastructure merged in #11436.

## Changes

- `apps/website/src/config/demos.ts` — register the new demo
- `apps/website/src/i18n/translations.ts` — add en + zh-CN strings
(title, description, transcript)
- `apps/website/public/images/demos/community-workflows-og.png` —
1200×630 OG image so email/social previews render correctly
- `apps/website/public/images/demos/community-workflows-thumb.webp` —
1280×720 WebP thumbnail
- `apps/website/e2e/demos.spec.ts` — refactored to iterate `demos` from
config so every demo (current + future) is exercised in both en and
zh-CN, and the iframe `src` is asserted to contain the correct Arcade ID

Adding a new demo only requires editing `demos.ts` + `translations.ts`
going forward; the e2e refactor is a one-time generalization that gives
future demos coverage automatically.

## Verification

- `pnpm typecheck:website`: 0 errors, 0 warnings, 0 hints
- Pre-commit hook ran `pnpm typecheck`, `oxfmt`, `oxlint`, `eslint` —
all clean on staged files
- `npx astro build`: 53 pages built; `/demos/community-workflows/` and
`/zh-CN/demos/community-workflows/` generated and present in
`sitemap-0.xml`
- Page rendered in Playwright preview: hero (title, GETTING STARTED,
BEGINNER, ~2 min), Arcade embed loads, transcript section present,
"What's Next" links to `image-to-video`
- zh-CN page shows localized title (探索并使用社区工作流), description, badges,
and "What's Next" heading
- OG meta tag references the new 1200×630 PNG

## Screenshots

![English demo page at /demos/community-workflows showing hero, embedded
Arcade walkthrough, transcript section, and What's Next
navigation](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9e9c345d23c5dd7674edc13e935c656681feb2a09b0928339594232502fa74db/pr-images/1777945965156-23d92854-3b6a-4470-92fb-86facb28a915.png)

![Chinese localized demo page at /zh-CN/demos/community-workflows with
translated title, category, badges, and
navigation](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9e9c345d23c5dd7674edc13e935c656681feb2a09b0928339594232502fa74db/pr-images/1777945965557-f981dfb3-4080-41e5-b93c-30801b4e1e0c.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11942-feat-website-add-community-workflows-demo-page-3576d73d36508139b647c774b1d39323)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-11 12:21:58 -07:00
pythongosssss
5d53e75d23 test: add tests for deprecated & api node badge visibility settings (#11681)
## Summary

Adds coverage for untested node badges

## Changes

- **What**: 
- add shared addNode helper - will follow up to standardize across tests
- add deprecated & api node badge tests in LiteGraph & Vue nodes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11681-test-add-tests-for-deprecated-api-node-badge-visibility-settings-34f6d73d365081569129ecffa608122e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-05-11 18:51:52 +00:00
Comfy Org PR Bot
d23e86d9a4 [chore] Update Ingest API types from cloud@0a03f3a (#12043)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: 0a03f3a
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

---------

Co-authored-by: MillerMedia <7741082+MillerMedia@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-11 18:47:18 +00:00
Yourz
d901c63a0b feat: convert careers CategoryNav to scroll-spy locator (#12110)
*PR Created by the Glary-Bot Agent*

---

Converts the `CategoryNav` in the careers `RolesSection` from a
click-to-filter button into a scroll-spy section locator, matching the
pattern already used by `ContentSection.vue` (customer story details,
TOS, privacy policy).

## Changes

- **`apps/website/src/components/careers/RolesSection.vue`**
- Replaced category-based filtering with anchor navigation: clicking a
department in the sidebar smooth-scrolls (via existing Lenis/GSAP
`scrollTo` helper) to that department's section with a `-144px` header
offset.
- Removed the `ALL` button — every department is always rendered as its
own scroll target with `id="careers-dept-{key}"`.
- Added `useIntersectionObserver` (rootMargin `-20% 0px -60% 0px`) that
updates the active nav item as the user scrolls. An `isScrolling` guard
prevents the observer from fighting click-jumps mid-animation.
- Added a viewport-entry fade/slide-up animation on each department
section, gated by `motion-safe:` so users with `prefers-reduced-motion`
see content immediately. The reveal state is sticky (one-way) so
sections don't disappear once revealed.
- Active state is driven by raw department keys; both the nav model and
the observer's id-to-key mapping use a single consistent identifier.

- **`apps/website/e2e/careers.spec.ts`**
- Replaced the obsolete "ENGINEERING filter narrows the list" test with
one that validates locator behavior: clicking the department button
scrolls the section into the viewport, sets `aria-pressed="true"`, and
keeps the full role list rendered.

## Verification

- `pnpm --filter @comfyorg/website typecheck` — clean.
- `pnpm exec oxfmt` / `pnpm exec eslint` / `pnpm exec oxlint` — clean.
- Pre-commit lint-staged hooks (stylelint, oxfmt, oxlint, eslint,
typecheck) — passing.
- Manual smoke test via Playwright on `astro dev`: careers page renders
all departments stacked vertically, active department in the sidebar
highlights based on viewport position (DESIGN active on initial scroll),
nav items reflect each department instead of including an `ALL` button.

## Screenshots

![Careers page with new locator: sidebar shows all 4 departments as
anchor buttons (DESIGN highlighted as active), all roles rendered
without
filtering](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/7f5c6359393f3f1f03c628d10074b13924f36c44bbce73e6a2729dd8279d9304/pr-images/1778336774660-dae22a86-d4f3-4509-bc66-eb3d00ec308c.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12110-feat-convert-careers-CategoryNav-to-scroll-spy-locator-35b6d73d3650818a9226e5dcb1244756)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary <glary@bot.local>
2026-05-11 18:42:25 +00:00
Yourz
5ca9f3e7e6 feat(website): remove left-edge fade-out from local hero illustration (#12137)
*PR Created by the Glary-Bot Agent*

---

Removes the left-edge fade-out gradient overlay on the hexagonal hero
illustration on the `/download` (local) page. The hex cluster now reads
fully edge-to-edge instead of being blended into the page background on
the left side.

Tracked in [FE-650: Remove fade-out effect on local page hero
illustration](https://linear.app/comfyorg/issue/FE-650/remove-fade-out-effect-on-local-page-hero-illustration).

## Change

Drops the `<!-- Left-edge fade -->` `<rect>` and its
`<defs><linearGradient id="localHeroFadeLeft">…</linearGradient></defs>`
from `apps/website/src/components/product/local/HeroSection.vue`.
Animation logic (panel expansion + hex ring rotation) is untouched.

## Verification

- `pnpm nx typecheck website` — pass (0 errors)
- `pnpm nx build website` — pass (51 pages built)
- `pnpm exec eslint
apps/website/src/components/product/local/HeroSection.vue` — clean
- `pnpm format:check
apps/website/src/components/product/local/HeroSection.vue` — clean
- Manual: `pnpm dev` + visited `/download` at 390×844 (mobile),
1280×800, and 1440×900. Mobile screenshots clearly show the fade is
gone; the leftmost hexagons are now fully visible.

## Screenshots

Mobile (390×844), before — note the dark fade obscuring the left side of
the hex cluster:

## Screenshots

![Mobile /download hero before — fade visible on left edge of hex
illustration](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/105d446470b90b36fa6b9a79fa686c98d107b8a1a7bc8cd5ace92e73fe590e94/pr-images/1778512980323-c51be60d-d64c-4f62-8b07-916206e7704d.png)

![Mobile /download hero after — fade removed, leftmost hexagons fully
visible](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/105d446470b90b36fa6b9a79fa686c98d107b8a1a7bc8cd5ace92e73fe590e94/pr-images/1778512980688-6848a1ca-f24b-4821-be57-1704bf5ceae7.png)

![Desktop /download hero after at
1280×800](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/105d446470b90b36fa6b9a79fa686c98d107b8a1a7bc8cd5ace92e73fe590e94/pr-images/1778512981012-02a221c7-0c9c-4b2c-a6e0-97d32f3fa306.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12137-feat-website-remove-left-edge-fade-out-from-local-hero-illustration-35d6d73d365081cba690ed7d46a19882)
by [Unito](https://www.unito.io)

Co-authored-by: Glary Bot <bot@glary.dev>
2026-05-11 18:37:31 +00:00
Yourz
6d5fa743b3 fix: seamless SocialProofBar marquee loop (#12139)
*PR Created by the Glary-Bot Agent*

---

## Summary

The partner-logo marquee on the homepage `SocialProofBar` glitched on
every loop restart — a visible jump where the strip snapped back to the
start.

## Root cause

The previous implementation rendered all logos as siblings of a single
flex container and animated the track from `translateX(0)` to
`translateX(-50%)`. Because the `gap` utility inserts spacing between
every adjacent pair of items (including the seam between the two
duplicated halves), `-50%` of the total width does not equal the
distance from one half-start to the next half-start. The mismatch (`gap
/ 2`) is exactly what the eye sees as a jump.

## Fix

- Wrap each duplicated half in its own flex group.
- Place the two groups as siblings of an outer `flex w-max gap-X` track
with a gap that matches the inner gap.
- Animate each group by `translateX(calc(-100% - var(--marquee-gap)))`,
where `--marquee-gap` is set inline to the same value as the Tailwind
gap class.
- Scope the `animation` declaration to `@media (prefers-reduced-motion:
no-preference)` so reduced-motion users get a stable, non-animated
client list instead of the global "snap to 0.01ms" jump.

At `t = end`, the second group sits at `x = 0` — exactly where the first
group started — so the next animation cycle is visually
indistinguishable from the previous frame. The duplicate carries
`aria-hidden="true"` so screen readers don't read the client list twice.

## Verification

- `pnpm typecheck`, `pnpm format`, `npx eslint` on changed files: clean.
- Geometry verified at runtime on desktop (1440×900) and mobile
(390×844): copy widths match, second copy lands at `x = 0` at animation
end.
- New Playwright regression tests
(`apps/website/e2e/responsive.spec.ts`) pause the CSS animation, sample
bounding rects at `t=0` and `t≈duration`, and assert the seam invariant
— covering desktop forward, mobile forward, and mobile reverse marquees.
All 5 SocialProofBar tests pass on both `desktop` and `mobile` projects.
- Reduced-motion behavior verified in the browser: `animationName:
none`, `transform: none`, tracks at their natural positions.

Fixes FE-649

## Screenshots

![Desktop SocialProofBar marquee — continuous
strip](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/46e0ea18461e4d9b4410ff1bda9be669b44aeee095b2ac5a976280e1df3867dc/pr-images/1778515303716-158840bf-9182-4928-b095-c38f5284419b.png)

![Desktop marquee at the loop boundary — seamless, no visible
jump](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/46e0ea18461e4d9b4410ff1bda9be669b44aeee095b2ac5a976280e1df3867dc/pr-images/1778515304106-c46ab421-7214-4123-a4d0-e819af2e1b49.png)

![Mobile SocialProofBar — two stacked marquee
rows](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/46e0ea18461e4d9b4410ff1bda9be669b44aeee095b2ac5a976280e1df3867dc/pr-images/1778515304521-b0fe828f-90ba-4cf4-bb63-65be4d28f627.png)

![Reduced-motion fallback — stable static client
list](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/46e0ea18461e4d9b4410ff1bda9be669b44aeee095b2ac5a976280e1df3867dc/pr-images/1778515304824-6f3262ab-127c-46e4-bc04-6e7b8850545b.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12139-fix-seamless-SocialProofBar-marquee-loop-35d6d73d36508141b6ccf0167016b8c8)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-11 18:36:40 +00:00
Henry Lee
603dd3eb4e fix(assets): recognise m4v and mkv as video extensions (#12088)
## Summary

`.m4v` and `.mkv` files render as a generic file icon in the assets
sidebar instead of a video preview because `VIDEO_EXTENSIONS` doesn't
list them, even though both formats are widely produced by ComfyUI
custom nodes and are browser-playable when written with common codecs.

## Changes

- **What**: Add `m4v` and `mkv` to `VIDEO_EXTENSIONS` in
`packages/shared-frontend-utils/src/formatUtil.ts` and extend the
existing test cases. Aligns with ComfyUI core's canonical video
extension list
(`tests-unit/folder_paths_test/filter_by_content_types_test.py:13`),
which includes both. The frontend's format registry at
`src/platform/workflow/core/types/formats.ts` also lists `.m4v` with
mime `video/x-m4v` — `formatUtil.ts` was the inconsistent surface.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

`m4v` is Apple's MP4 container variant; `mkv` is the Matroska container.
ComfyUI custom nodes most commonly produce both with H.264/VP9 codecs,
which play natively in modern browsers via `<video>`. Adding the
extensions routes those files through the existing `MediaVideoTop`
component without any new rendering logic.

If a user's `.mkv` happens to use an exotic codec the browser can't play
(e.g. H.265/HEVC in Chrome), they get the same controllable failure mode
as today's `.avi` entries — a `<video>` element with the browser's
native unsupported-source UI. That is no worse than the current "show a
generic file icon" behavior, and strictly better in the common case.

## Screenshots (if applicable)

> **Note**: Screenshots taken from the OSS *input* assets sidebar with
[#12086](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12086) also
applied locally. That PR fixes a separate regression where OSS input
filenames carry a `[input]` annotation suffix that breaks all
extension-based media detection — without it, `m4v`/`mkv` files (and
every other file in that sidebar) still render as the generic icon. This
PR alone is sufficient for cloud assets and OSS output history; the
input-sidebar previews require both PRs.

Before:
<img width="1197" height="714" alt="2026-05-09-031123_hyprshot"
src="https://github.com/user-attachments/assets/5c6ebc2d-aac2-411f-a2e4-51a111033184"
/>

After:
<img width="1042" height="723" alt="2026-05-09-031005_hyprshot"
src="https://github.com/user-attachments/assets/f0acc2cf-8571-4fd0-b0cd-2b8b87ff9b74"
/>
2026-05-11 12:03:35 -07:00
Yourz
d767a325a2 FE-604: fix(website): activate last section badge when scrolled to bottom (#12057)
*PR Created by the Glary-Bot Agent*

---

## Summary

Fixes the bug where the last badge in `ContentSection`'s sticky sidebar
nav stays unhighlighted when the user scrolls to the very bottom of the
page on tall viewports (reported on a 14" MacBook M4 Pro at 3024×1964 /
2016×1310 logical, both Chrome and Safari).

## Root cause

The scroll-spy uses an IntersectionObserver with `rootMargin: '-20% 0px
-60% 0px'`, which makes only a 20%–40% horizontal band from the viewport
top "active". When multiple intersecting entries are reported, the
callback picks the one whose `boundingClientRect.top` is smallest
(highest up on screen).

On tall viewports, when the page is scrolled to the absolute bottom, the
last *and* the second-to-last sections frequently both sit inside that
20%–40% band at the same time. The "smallest top" tiebreak then selects
the second-to-last section, leaving the last badge inactive even though
the user has reached the end of the page.

## Fix

`apps/website/src/components/common/ContentSection.vue`:

1. Add `isAtBottom()` — true when the viewport bottom has reached the
document bottom (within 4px to absorb sub-pixel rounding).
2. The IntersectionObserver callback bails out when `isAtBottom()` so it
cannot overwrite the choice below.
3. A passive `scroll` listener (and a one-shot `onMounted` call) sets
`activeSection` to the last section whenever the page is at the bottom —
including when the component mounts already at the bottom (e.g. hash
navigation to a trailing anchor, restored scroll position, or a page
shorter than the viewport).
4. Both the scroll handler and the IO callback honor the existing
`isScrolling` flag, so click-driven smooth scroll-to-section behavior is
unchanged.

## Verification

Reproduced the bug at viewport 2016×1310 (14" M4 Pro "More Space" mode)
on `/privacy-policy`:

- Before fix: at absolute bottom, IntersectionObserver picks
`australian-privacy` (second-to-last) — bug confirmed via DOM inspection
that showed multiple sections intersecting the active band, with the
second-to-last winning the "smallest top" tiebreak.
- After fix:
  - Scrolled to bottom → last badge `CONTACT` is active.
  - Scrolled to top → first badge `INTRO` is active.
  - Scrolled mid-page → correct mid-section is active.
  - Click on a badge → smooth scrolls and that badge becomes active.
- Initial render at bottom (loaded `/privacy-policy#contact`, browser
scrolls to the bottom on mount) → `CONTACT` active immediately.

`pnpm typecheck` and `pnpm typecheck:website` pass; `pnpm lint` reports
0 errors; existing website unit tests pass.

Note: The website app currently has no Vue component test setup
(`vitest.config.ts` is configured for `node` env, no DOM). Adding
component tests for this scroll-spy interaction would require setting up
`happy-dom` and `@testing-library/vue` for the website app, which is out
of scope for this bug fix.

Fixes FE-604

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12057-FE-604-fix-website-activate-last-section-badge-when-scrolled-to-bottom-3596d73d365081faa243f4dd8e6ee54a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-11 12:00:43 -07:00
Christian Byrne
39b2bb5eab [chore] Update Comfy Registry API types from comfy-api@dfcca37 (#12087)
## Automated API Type Update

This PR updates the Comfy Registry API types from the latest comfy-api
OpenAPI specification.

- API commit: dfcca37
- Generated on: 2026-05-07T19:11:35Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12087-chore-Update-Comfy-Registry-API-types-from-comfy-api-dfcca37-35a6d73d365081f49beec09f418d7c1a)
by [Unito](https://www.unito.io)

Co-authored-by: coderfromthenorth93 <213232275+coderfromthenorth93@users.noreply.github.com>
2026-05-11 11:47:43 -07:00
pythongosssss
c643438601 fix: hide image buttons if load failed (#12136)
## Summary

When an image fails to load in the image preview, the context buttons
are still visible - clicking these does not work (Mask editor opens and
closes, download does nothing) - this hides the buttons if load fails.

## Changes

- **What**: 
- hide buttons if load failed
- tests

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)
Current:
<img width="622" height="857" alt="image"
src="https://github.com/user-attachments/assets/26e391a0-5538-4c6c-ac8a-b6f2b6acabae"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12136-fix-hide-image-buttons-if-load-failed-35d6d73d365081579c71f1849b9ab1bd)
by [Unito](https://www.unito.io)
2026-05-11 17:33:34 +00:00
Dante
02e1ba2968 fix: Load Image preview retains deleted asset (FE-230) (#11493)
## Summary

After deleting an asset, the Load Image node kept displaying the deleted
thumbnail — both in the node body and in the picker dropdown (All /
Imported / Generated tabs), even after a workflow reload.

- Fixes FE-230
- Source: Slack
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776715727656809

## Root Cause

Three distinct paths kept the deleted asset visible:

1. **Node-body preview cache** — `useMediaAssetActions.deleteAssets`
never cleared `node.imgs` / `node.videoContainer` / the
`nodeOutputStore` Vue ref, so the canvas renderer kept its cached frame.
2. **Live-delete dropdown gap** — the picker reads from
`outputMediaAssets.media` (the asset list) and from
`missingMediaStore.missingMediaCandidates` (verified-missing names). On
live delete, neither was updated for the deleted asset, so the dropdown
filter had nothing to drop.
3. **Synthetic "selected" placeholder** —
`useWidgetSelectItems.missingValueItem` rebuilt any orphaned
`modelValue` as a fake item with a `/api/view?filename=...` preview URL.
Browsers had cached that URL pre-delete, so the deleted thumbnail still
rendered with a blue checkmark even after the filter dropped the real
asset entry.

A subtler issue compounded #2/#3: candidate names stored in
`missingMediaStore` are raw widget values (e.g. `sub/foo.png [output]`),
but the dropdown computed comparison keys differently per source (asset
list uses bare `asset.name`, widget option list uses bare filename).
Names with a subfolder prefix slipped through the filter.

## Fix

- **`clearNodePreviewCacheForFilenames`** (existing helper, refactored):
exports `findNodesReferencingFilenames` +
`extractFilenameFromWidgetValue`. Uses
`nodeOutputStore.removeNodeOutputs` so the **reactive** Pinia ref
updates, not just the legacy `app.nodeOutputs` mirror. Also clears
`node.videoContainer` for Load Video.
- **`markDeletedAssetsAsMissingMedia`** (new): on successful deletion,
surfaces the affected widgets through `missingMediaStore` immediately so
the dropdown filter has something to drop without waiting for
verification.
- **`useMissingMediaPreviewSync`** (new): watches `missingMediaStore`
and clears `node.imgs` / `node.videoContainer` / Vue preview source for
nodes referencing confirmed-missing media on workflow load — covers the
post-reload case.
- **`useWidgetSelectItems`**: normalizes both sides of the missing-media
filter via `extractFilenameFromWidgetValue` (strips
`[input|output|temp]` annotation + subfolder prefix), and suppresses
`missingValueItem` when the value is in the missing-media store so the
cached-thumbnail "selected" placeholder doesn't appear.

## Red-Green Verification

| Commit | CI Status | Run |
|--------|-----------|-----|
| `test: FE-230 add failing test for Load Image preview cache clearing`
| 🔴 Failure — test caught the bug |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/24700188700 |
| `fix: FE-230 clear Load Image preview cache when asset is deleted` |
🟢 Success |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/24700265884 |

## Test Plan

- [x] Unit coverage: 78 tests across 5 files (preview-cache helper,
mark-deleted-as-missing, missing-media-preview-sync, widget-select-items
missing-media filter incl. subfolder-prefix case, useMediaAssetActions
integration)
- [x] Live delete: Load Image node preview clears, dropdown drops the
asset across All / Imported / Generated, no synthetic "selected"
placeholder
- [x] Post-reload: missing-media verification →
`useMissingMediaPreviewSync` clears the preview, dropdown drops the
asset
- [x] Linear FE-230 auto-links via the Source line

## Scope note

In-session and session-restore are both covered. If the backend/CDN
continues serving the deleted `filename`/`asset_hash` after deletion, a
cross-session reopen may still render stale bytes from cache — that's a
backend/CDN concern tracked separately.


## demo

### before



https://github.com/user-attachments/assets/e4d3a40e-0d46-43ad-985c-22ce7e0d3faf


### after



https://github.com/user-attachments/assets/fcac9387-4c07-4be2-bcdd-d1a6192fe962
2026-05-11 12:53:53 +00:00
pythongosssss
15b8771cc2 fix: clear active job on reconnect if no longer in queue (#12067)
## Summary

When a socket disconnects messages can be missed and lead to a stale UI
state, this updates the state on reconnect and clears the active job if
it is no longer running

## Changes

- **What**: 
- add call to update queue on reconnect
- clear active job if job not in queue response
- tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12067-fix-clear-active-job-on-reconnect-if-no-longer-in-queue-3596d73d365081f79d42d73966420c50)
by [Unito](https://www.unito.io)
2026-05-11 09:28:23 +00:00
Comfy Org PR Bot
e68d50e677 1.45.4 (#12118)
Patch version increment to 1.45.4

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12118-1-45-4-35d6d73d365081fcb5f5d06dec17bb59)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-11 04:43:31 +00:00
Comfy Org PR Bot
48b5e0165a 1.45.3 (#12113)
Patch version increment to 1.45.3

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12113-1-45-3-35c6d73d365081468180cefef02dca03)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-11 00:01:07 +00:00
Christian Byrne
fe1de3b254 refactor: remove dedup complexity from reportInactiveTrackerCall (#11833)
## Summary

Remove the module-level `reportedInactiveCalls: Set<string>` and the
early-return dedup check from `reportInactiveTrackerCall()` in
`src/scripts/changeTracker.ts`. Every invocation now emits
`console.warn` and (on Desktop) `Sentry.captureMessage` unconditionally.

## Why

The dedup was added in #11328 but is unnecessary:
- Every first-party call site already goes through the
`activeWorkflow?.changeTracker` guard, so flooding from in-repo code is
unlikely.
- Repeated identical alerts may actually provide more diagnostic signal
than the first-only approach suppresses.

## Changes

- Drop `reportedInactiveCalls` Set
- Drop the per-`(method, workflowPath)` early-return
- Trim the JSDoc accordingly

No behavior change for callers (`deactivate`, `captureCanvasState`);
only the reporting frequency increases.

## Verification

- `pnpm test:unit -- src/scripts/changeTracker.test.ts` — 16/16 passing
- `pnpm typecheck` — clean
- ESLint / oxfmt — clean

- Fixes #11372

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11833-refactor-remove-dedup-complexity-from-reportInactiveTrackerCall-3546d73d365081fabf57cbf1fa17051f)
by [Unito](https://www.unito.io)
2026-05-10 05:22:30 +00:00
Christian Byrne
1c2ae70343 chore(#11843): replace bare string NodeId typings in parameters tab components (#12014)
## Summary

Replace `nodeId: string` with canonical `NodeId` type in right-side
panel parameters tab components, eliminating redundant `String()`
conversions at call sites.

## Changes

- `TabNodes.vue`: `isSectionCollapsed` and `setSectionCollapsed` now
accept `NodeId` instead of `string`; callers updated to pass `node.id`
directly (removing `String()` wrapping)
- `TabNormalInputs.vue`: same pattern

## Notes

The other 6 files listed in the issue use `nodeId` parameters that carry
execution IDs (`NodeExecutionId = string`), not graph node IDs (`NodeId
= number | string`). Changing those to `NodeId` would be semantically
incorrect. The two files changed here are the clear-cut cases where
`node.id` (a `NodeId`) was being unnecessarily stringified before being
passed.

## Testing

### Automated

- `pnpm typecheck` — passes
- `pnpm lint` — passes (0 warnings, 0 errors)
- `pnpm format:check` — passes

### E2E Verification Steps

1. Open ComfyUI frontend
2. Load a workflow with multiple nodes
3. Open the right side panel (Parameters tab)
4. Verify node sections collapse/expand correctly per node
5. Verify "Collapse All" / "Expand All" toggle works correctly
6. Repeat with both TabNodes and TabNormalInputs views

Fixes #11843

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12014-chore-11843-replace-bare-string-NodeId-typings-in-parameters-tab-components-3586d73d365081ed84caf560277f0553)
by [Unito](https://www.unito.io)
2026-05-10 05:21:36 +00:00
jaeone94
8f68be5699 fix: handle annotated output media paths in missing media scan (#12069)
## Summary

This PR fixes missing-media false positives for annotated media widget
values such as:

```txt
photo.png [output]
clip.mp4 [input]
147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png [output]
clip.mp4[input]  // Cloud compact form
```

The change is intentionally scoped to the missing-media detection
pipeline for:

- `LoadImage`
- `LoadImageMask`
- `LoadVideo`
- `LoadAudio`

It preserves the raw widget value on `MissingMediaCandidate.name` for UI
display, grouping, replacement, and user-facing missing-media rows.
Normalized values are used only as comparison keys during verification.

## Diff Size

`main...HEAD` line diff is currently:

- Production/runtime code: `+478 / -37` (`515` changed lines)
- Unit test code: `+960 / -47` (`1,007` changed lines)
- Total: `+1,438 / -84` (`1,522` changed lines)

The PR looks large mostly because it locks both Cloud and OSS/Core
runtime paths with unit coverage; the production/runtime change is about
one third of the total diff.

## What Changed

- Added missing-media-scoped annotation helpers for detection-only path
normalization.
  - Core/OSS recognizes spaced suffixes like `file.png [output]`.
  - Cloud also recognizes compact suffixes like `file.png[output]`.
- User-selectable trailing `input` and `output` annotations are
normalized for matching.
- Unknown annotations and middle-of-filename annotations are left
unchanged.
- Added shared file-path helpers in `formatUtil`:
  - `joinFilePath(subfolder, filename)`
  - `getFilePathSeparatorVariants(filepath)`
- Updated media verification to compare candidates against both raw and
normalized match keys.
- Kept input candidates and generated output candidates in separate
identifier sets so an input asset cannot accidentally satisfy an output
reference with the same name.
- Moved missing-media source loading into `missingMediaAssetResolver` so
`missingMediaScan` remains focused on scan/verification orchestration.
- Updated Cloud generated-media verification to use the Cloud assets API
instead of job history:
  - Cloud input candidates use input/public assets.
  - Cloud output candidates use `output` tagged assets.
- Kept OSS/Core generated-media verification history-based, matching the
current generated-picker/widget availability model.

## Runtime Verification Paths

### Cloud

Cloud stores generated outputs as asset records. For an annotated output
value, this PR verifies against the `output` asset tag rather than job
history.

```txt
Widget value
  "147257...d6e.png [output]"
        |
        v
Detection keys
  "147257...d6e.png [output]"
  "147257...d6e.png"
        |
        v
Cloud asset sources
  input candidates  -> /api/assets?include_tags=input&include_public=true
  output candidates -> /api/assets?include_tags=output&include_public=true
        |
        v
Match against
  asset.name
  asset.asset_hash
  subfolder/asset.name
  subfolder/asset.asset_hash
  slash and backslash separator variants
```

Example:

```ts
candidate.name = 'abc123.png [output]'
asset.name = 'ComfyUI_00001_.png'
asset.asset_hash = 'abc123.png'
asset.tags = ['output']

// Result: not missing
```

### OSS / Core

Core widget options for the normal loader nodes are input-folder based.
Annotated output values are resolved by Core through
`folder_paths.get_annotated_filepath()`, but the current generated
picker path is history-backed. This PR keeps OSS generated verification
aligned with that widget availability model instead of treating the full
output folder as the source of truth.

```txt
Widget value
  "subfolder/photo.png [output]"
        |
        v
Detection keys
  "subfolder/photo.png [output]"
  "subfolder/photo.png"
        |
        v
OSS generated source
  fetchHistoryPage(...)
        |
        v
History preview_output
  filename: "photo.png"
  subfolder: "subfolder"
        |
        v
Generated match keys
  "subfolder/photo.png"
  "subfolder\\photo.png"
```

This means OSS/Core verification is about whether the generated media is
currently available through the same generated/history-backed path the
widget uses, not a full disk-level executability check across the entire
output directory.

## Why Not Consolidate All Annotated Path Parsers

There are existing annotated-path parsers in image widget, Load3D, and
path creation code. This PR does not replace them.

The helper added here is detection-only: it strips annotations to build
comparison keys for missing-media verification. Parser consolidation
across widget implementations is intentionally left out of scope to keep
this fix narrow.

## Known Follow-Ups / Out Of Scope

- FE-620 tracks the separate video drag-and-drop upload race between
upload completion and missing-media detection.
- Published/shared workflow assets are still not fully represented by
`/api/assets?include_public=true`; that remains a backend/API contract
issue.
- A future backend/API contract that answers “is this workflow media
executable?” would be preferable to stitching together runtime-specific
FE sources.
- OSS/Core full output-folder scanning via `/internal/files/output` was
considered, but that endpoint is internal, shallow (`os.scandir`), and
not the same source currently used by the generated picker flow.

## Validation

- `pnpm test:unit -- missingMediaAssetResolver missingMediaScan
mediaPathDetectionUtil formatUtil`
- touched files `oxfmt`
- touched files `oxlint --fix`
- touched files `eslint --cache --fix --no-warn-ignored`
- `pnpm typecheck`
- pre-commit `pnpm knip --cache`
- pre-push `pnpm knip --cache`

`knip` passes with the existing tag hint:

```txt
Unused tag in src/scripts/metadata/flac.ts: getFromFlacBuffer → @knipIgnoreUnusedButUsedByCustomNodes
```

## Screenshots

Before 


https://github.com/user-attachments/assets/50eab565-3160-4a57-a758-87ec2c09071e


After 


https://github.com/user-attachments/assets/08adcbbd-c3fc-43f9-b86c-327e4eb5abd8


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12069-fix-handle-annotated-output-media-paths-in-missing-media-scan-3596d73d365081f4afa3d4dd45cad3da)
by [Unito](https://www.unito.io)
2026-05-09 05:36:09 +00:00
Terry Jia
653ef1a4f0 Handle Load3D "none" model selection in frontend (#11178)
## Summary
Load3D now supports panoramic images and HDRI loading, it can serve as a
viewer for those without requiring a 3D model. Previously, the node
required a model file to execute. Rather than making the input optional
(which would break existing workflows that rely on it being required), a
"none" option is added to the combo list, allowing users to run Load3D
with no model loaded.

BE change is https://github.com/Comfy-Org/ComfyUI/pull/13379

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11178-Handle-Load3D-none-model-selection-in-frontend-3416d73d365081e589b3d89bc67f75e7)
by [Unito](https://www.unito.io)
2026-05-09 01:26:37 -04:00
Christian Byrne
c16052e2e3 feat: sort right-click context menu categories alphabetically (#12039)
*PR Created by the Glary-Bot Agent*

---

## Summary

Sort the canvas right-click "Add Node" context menu by display name
(case-insensitive, natural numeric). Previously, both category submenus
and leaf nodes appeared in node-registration order, making the menu hard
to scan for users browsing for nodes.

This change is scoped specifically to the **smaller right-click
contextual menu**. It does NOT affect the double-click search menu or
the left-side Nodes panel.

## Changes

- `src/lib/litegraph/src/LGraphCanvas.ts` — In `onMenuAdd` →
`inner_onMenuAdded`, sort the deduplicated category submenu entries and
the leaf-node entries by `content` using `localeCompare` with `{
numeric: true, sensitivity: 'base' }`. Categories still appear before
leaf nodes within a level (preserves existing UX).
- `src/lib/litegraph/src/LGraphCanvas.onMenuAdd.test.ts` — New unit
tests that mock `LiteGraph.ContextMenu` and assert: case-insensitive
sort, natural numeric ordering (`Cat1`, `Cat2`, `Cat10`), leaf-node
sorting inside a category, and category-before-leaf placement.

## Verification

- `pnpm vitest run src/lib/litegraph/src/LGraphCanvas.onMenuAdd.test.ts`
— 4/4 pass
- `pnpm typecheck` — clean (ran via pre-commit hook on initial commit)
- `oxfmt` / `oxlint` / `eslint` — clean
- Oracle review against `main` returned 0 critical / 1 warning (test
coverage) / 1 suggestion (numeric sort) — both addressed in this PR.

## Notes

- The sort is applied at the menu-build site rather than inside
`LiteGraphGlobal.getNodeTypesCategories`/`getNodeTypesInCategory` to
keep the change scoped to the menu UX and avoid changing the iteration
order seen by extensions that consume those public methods.
- Per user request, this is opening as a draft PR for self-review +
CodeRabbit feedback in a single follow-up pass; manual browser
verification (right-click screenshots) was deferred to that pass.
- Slack thread context: user reported the contextual menu is "a mess"
for discovering native nodes; alphabetical sorting addresses the
discoverability problem without touching the search-oriented menus.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12039-feat-sort-right-click-context-menu-categories-alphabetically-3596d73d36508107a87ffec1c353994e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexis Rolland <alexis@comfy.org>
2026-05-09 03:31:11 +00:00
Comfy Org PR Bot
3e94459340 1.45.2 (#12096)
Patch version increment to 1.45.2

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12096-1-45-2-35b6d73d36508193be00c1c878d42c2a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-09 02:07:47 +00:00
Henry Lee
ca54877f9d fix(assets): strip directory annotation from input filenames (#12086)
## Summary

Imported assets render as a generic check-check icon instead of a
thumbnail because the OSS `/internal/files/{type}` endpoint returns
annotated filenames (`photo.png [input]`) that the assets-sidebar mapper
passes through verbatim, which breaks extension-based media-type
detection.

## Changes

- **What**: Strip ComfyUI's trailing directory-type annotation (`
[input]`, ` [output]`, `[temp]`) in `mapInputFileToAssetItem` so `name`,
`id`, and the generated `/view?filename=…` URL all use the canonical
on-disk filename. Adds a focused unit test.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

### Root cause

ComfyUI core PR
[comfyanonymous/ComfyUI#13078](https://github.com/comfyanonymous/ComfyUI/pull/13078)
(April 2026) changed `/internal/files/{type}` to append the directory
type to each entry:

```python
# api_server/routes/internal/internal_routes.py
return web.json_response(
    [f"{entry.name} [{directory_type}]" for entry in sorted_files], status=200
)
```

The annotation is the wire format `LoadImage`-style widgets expect, so
the backend change is correct. The assets-sidebar mapper treated the
response strings as raw filenames. After
[#8914](https://github.com/Comfy-Org/ComfyUI_frontend/pull/8914) changed
`getMediaTypeFromFilename` to default unknown extensions to `'other'`,
every input asset now routes to `MediaOtherTop` and renders as
`icon-[lucide--check-check]`:

```
getMediaTypeFromFilename("photo.png [input]").split('.').pop() === "png [input]" → 'other'
```

The strip happens at data ingestion so every consumer of
`AssetItem.name` (sidebar grid, list, filter, gallery, drag-drop,
delete) gets the canonical filename automatically. OSS-only — Cloud
paths get clean names from the cloud API and are unaffected.

Reproduces locally on stock OSS ComfyUI on `main` of both repos; no
public issue tracker entry.

## Screenshots (if applicable)
Before:
<img width="1091" height="718" alt="image"
src="https://github.com/user-attachments/assets/ff1f070d-da39-4e5a-bc6d-99b7214f7da8"
/>

After:
<img width="1089" height="716" alt="image"
src="https://github.com/user-attachments/assets/7123d9bf-f7dd-4430-b6f7-f6702b70baaa"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12086-fix-assets-strip-directory-annotation-from-input-filenames-35a6d73d365081e9b9eed7d8630d6f0b)
by [Unito](https://www.unito.io)
2026-05-08 21:31:57 +00:00
Simon Pinfold
a4faaa0159 fix: disable ultralytics asset-browser registration (#12075)
*PR Created by the Glary-Bot Agent*

---

## Summary

Disable the `UltralyticsDetectorProvider` model-to-node mapping so the
node falls back to the static combo populated from `/api/object_info`,
restoring pre-#8468 behavior on cloud.

## Why

PR #8468 opted `UltralyticsDetectorProvider` into the cloud asset-widget
path, which exposed a latent mismatch in cloud asset metadata for
nested-directory model folders. The bug has two independent halves, and
a fix that addresses only one will still leave the workflow broken at
execution time:

- **Tag lookup mismatch.** Cloud stores tags as combined values like
`ultralytics/bbox`, while the asset query asks for split tags (`models`
+ `ultralytics`) with exact-match filtering — so the dropdown returns no
results.
- **Submitted value mismatch.** Cloud stores filenames as basenames, but
the node expects subdirectory-prefixed values (e.g.
`bbox/face_yolov8m.pt`) that the static combo path normally produces.

Both halves require cloud-side fixes (asset ingestion + metadata) before
the asset-browser registration can be safely re-enabled. Until then,
removing the registration restores the working static-combo behavior so
users are unblocked.

## Changes

- `src/platform/assets/mappings/modelNodeMappings.ts`: comment out the
`['ultralytics', 'UltralyticsDetectorProvider', 'model_name']` entry
with a note pointing at BE-689 and the re-enablement criteria.
- `src/stores/modelToNodeStore.test.ts`: drop the now-stale ultralytics
expectations from `EXPECTED_DEFAULT_TYPES`, `MOCK_NODE_NAMES`, and the
hierarchical-fallback `it.each` cases.

## Verification

Local quality gates:
- `pnpm typecheck` — clean
- `pnpm lint` — clean (3 pre-existing warnings, 0 errors)
- `pnpm format:check` — clean
- `pnpm knip` — clean (1 pre-existing warning unrelated to this change)
- `pnpm test:unit -- src/stores/modelToNodeStore.test.ts` — 51/51
passing

Manual runtime verification (dev server + Playwright against the live
module):
- `MODEL_NODE_MAPPINGS` no longer contains any entry where `[0] ===
'ultralytics'` or `[1] === 'UltralyticsDetectorProvider'` (84 entries
total, 0 ultralytics).
- `useModelToNodeStore().getNodeProvider('ultralytics')` returns `null`
after `registerDefaults()`, so the asset-widget path is no longer
triggered for this node.
- `getNodeProvider('ultralytics/bbox')` also returns `null`, confirming
hierarchical fallback no longer resolves to the disabled mapping.
- `getNodeProvider('checkpoints')` still resolves to
`CheckpointLoaderSimple`, confirming unrelated mappings are intact.

End-to-end cloud verification (actually exercising the asset-browser
path against cloud-seeded ultralytics metadata) is not possible in the
local sandbox, since the regression depends on the cloud's asset
ingestion data shape. The change is a single mapping-table removal that
reverts to the well-exercised static-combo path covered by the updated
unit tests.

Long-term cloud-side fix is tracked in BE-689.

- Fixes BE-689

## Screenshots

![ComfyUI dev server loaded with the patched
build](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/7282fc1868f7d89220fa1fbf9f0dcd5dbd55713288d3a3310e99d1cc5768e7d7/pr-images/1778229906648-a825191d-85d8-4a09-adc4-4fb3402d3e92.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12075-fix-disable-ultralytics-asset-browser-registration-35a6d73d36508179b394f0915e69742e)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-08 13:53:22 -07:00
Dante
8108967d49 feat(dialog): migrate Prompt + Confirmation dialogs to Reka-UI (Phase 1) (#12041)
## Summary

Phase 1 of the dialog migration kicked off in #11719. Migrates the two
simplest production dialogs — `PromptDialogContent` and
`ConfirmationDialogContent` — from PrimeVue `Dialog` onto the Reka-UI
primitives landed in Phase 0. 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-573](https://linear.app/comfyorg/issue/FE-573/phase-1-migrate-promptdialog-confirmationdialog-closes-11688)
Predecessor: #11719 (merged at `0788e7139`)

Refs #11688 (closed manually after Phase 0; the actual user-visible
max-width fix ships in this PR)

## Changes

### `src/services/dialogService.ts`
| Call site | Renderer | Size | Width override |
| --- | --- | --- | --- |
| `prompt()` | `'reka'` | `md` | — |
| `confirm()` | `'reka'` | `md` | — |
| `showBillingComingSoonDialog()` | `'reka'` | `sm` | `contentClass:
'max-w-[360px]'` |

### `src/components/dialog/content/ConfirmationDialogContent.vue`
- Drops `import Message from 'primevue/message'` — the only PrimeVue
dependency in the component
- Replaces `<Message>` with a Tailwind `role="status"` alert keeping the
`pi pi-info-circle` icon and muted-foreground severity

### `src/stores/dialogStore.ts` +
`src/components/dialog/GlobalDialog.vue`
- Adds `contentClass?: HTMLAttributes['class']` on
`CustomDialogComponentProps`
- Forwards it to `<DialogContent :class="...">` on the Reka branch
(PrimeVue path keeps using `pt`)

## Why this scope

1. **Smallest content surface** — `PromptDialogContent` is 43 LOC; the
only PrimeVue dependency in `ConfirmationDialogContent` is the
`<Message>` info banner.
2. **Closes #11688 ergonomics** — Reka's `md` size = `max-w-xl` (576px /
36rem), exactly the max-width the issue reporter asked for.
3. **Three known callers** — all in `dialogService.ts`. No other callers
needed to change.
4. **Renderer branch is already proven by Phase 0**; this PR just flips
the flag.

## Visual proof

Verified live in Storybook (`Components / Dialog / Dialog → Default` and
`… → All Sizes`) at viewport `1920×1080`. DOM inspection confirms the
rendered widths match the design intent:

| Story | size | Rendered width | Computed `max-width` |
| --- | --- | --- | --- |
| `Default` | `md` | **576 px** | **576 px (= 36rem)** |
| `All Sizes` (sm slot) | `sm` | 384 px | 384 px (= 24rem) |

The `md` measurement directly answers the #11688 reporter screenshot
(1558 px wide PrimeVue dialog → 576 px Reka dialog on the same display).
Local screenshot artifacts (not committed):
`temp/screenshots/phase1-md-576px-1920w.png`,
`temp/screenshots/phase1-md-allsizes-1920w.png`,
`temp/screenshots/phase1-sm-384px-1920w.png` — drag-drop into the PR
body before marking ready for review.

## Quality gates

- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — clean
- [x] `pnpm format` — applied (oxfmt)
- [x] `pnpm test:unit` (touched files): **26/26 passed**
- `ConfirmationDialogContent.test.ts` (9 tests, no longer needs PrimeVue
plugin)
  - `PromptDialogContent.test.ts` (5 tests, unchanged)
- `GlobalDialog.test.ts` (9 tests, Phase 0 coverage still passes after
the contentClass forwarder addition)
- `dialogService.renderer.test.ts` **new** — 3 tests asserting each call
site sets `renderer: 'reka'` (regression net)
- [ ] `pnpm test:browser:local --grep "@mobile confirm dialog"` —
**could not run locally** (no ComfyUI Python backend on `localhost:8188`
in this session); CI will gate the existing fixture, which is already
renderer-agnostic (`getByRole('dialog')` + `getByRole('button', ...)` in
`browser_tests/fixtures/components/ConfirmDialog.ts`).

## Public API impact

None. `useDialogService().prompt(...)` / `confirm(...)` /
`showBillingComingSoonDialog(...)` keep their existing signatures.
Custom-node extensions calling `app.extensionManager.dialog.*` continue
to work.

## Out of scope (later phases)

- `ErrorDialogContent`, `NodeSearchBox`, `SecretFormDialog`,
`VideoHelpDialog`, `CustomizationDialog` — Phase 2 (FE-574)
- Settings dialog — Phase 3 (FE-575)
- Manager dialog — Phase 4 (FE-576)
- `ConfirmDialog` callers (`SecretsPanel`, `BaseWorkflowsSidebarTab`) —
Phase 5 (FE-577)
- Removing PrimeVue `Dialog` imports + `<style>` cleanup in
`GlobalDialog.vue` — Phase 6 (FE-578)
- Legacy `ComfyDialog` (`src/scripts/ui/dialog.ts`)
- Deduplicating `Dialogue.vue` / `ImageLightbox.vue`


## Screenshot
<img width="865" height="497" alt="Screenshot 2026-05-08 at 4 35 45 PM"
src="https://github.com/user-attachments/assets/6aead2ad-2e0b-478a-9154-bb632a6bf3d1"
/>
<img width="1363" height="964" alt="Screenshot 2026-05-08 at 4 38 16 PM"
src="https://github.com/user-attachments/assets/10647752-a063-4901-a206-842799cc5d7a"
/>
<img width="889" height="486" alt="Screenshot 2026-05-08 at 4 46 57 PM"
src="https://github.com/user-attachments/assets/81899a81-205a-46f2-bddd-7639624607f6"
/>



## Test plan

- [x] Unit: 26/26 pass on touched files
- [ ] CI: `@mobile confirm dialog` spec on the migrated path
- [ ] Manual (post-CI on a real backend): open prompt and confirm
dialogs on 1920×1080 viewport, verify ≤ 36rem max-width, ESC closes,
backdrop click closes, Enter submits prompt, focus trap holds
- [ ] Manual: open Billing Coming Soon dialog — verify it stays at the
existing `max-w-[360px]` width
2026-05-08 12:11:06 +00:00
Dante
0ef98de8eb fix: make credits help icon a tooltip button in cloud user popover (FE-617) (#12072)
## Summary

The help icon (lucide circle-help) next to the credits balance in the
cloud user popover was a bare `<i>` with `v-tooltip` and `cursor-help`.
PrimeVue tooltip on a bare `<i>` did not fire reliably and the icon had
no focus/keyboard semantics, so users saw "no hover action and not
clickable".

Wrap the icon in `<Button variant="muted-textonly" size="icon-sm">`,
matching the existing pattern in `InfoButton.vue` and
`MissingPackGroupRow.vue`. Same change applied to
`CurrentUserPopoverLegacy.vue` and `CurrentUserPopoverWorkspace.vue`,
which shared the broken pattern.

- Fixes FE-617
-
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1778191473621829

## Red-Green CI Verification

The branch was force-pushed back to the test-only commit so CI could run
against it, then restored to the fix commit.

| Commit | CI: Tests Unit | Outcome |
|--------|---------------|---------|
| `test:` (e7c83abd0) — adds the regression test only |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25532935842 |
failed — `Unable to find an element by:
[data-testid="credits-info-button"]` |
| `fix:` (64ec4cda4) — wraps the icon in `<Button>` |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25533224195 |
passed |



<img width="434" height="364" alt="Screenshot 2026-05-08 at 5 32 47 PM"
src="https://github.com/user-attachments/assets/d3088b90-813f-4a0f-ba35-0f040fc79a6a"
/>

## Test Plan

- [x] Component test asserts the icon renders as an interactive
`<button>` with the unified-credits tooltip text as `aria-label`
- [x] Red CI failed with the expected error on the test-only commit
- [x] Green CI passed on the fix commit
- [ ] Manual verification on `pnpm dev:cloud` — hover the help icon next
to the credits balance and confirm the unification tooltip appears
2026-05-08 11:56:35 +00:00
Dante
88866fc564 fix: restore nightly publish_types build (#12073)
## Summary

The nightly `Release Draft Create` -> `publish_types / Build types` job
has been failing on every run since 1.45.1 ([failed
run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25531376650)).
Reproduced locally on `main` (`c8c0e5386`).

## Root cause

`pnpm build:types` ends with `FATAL ERROR: Reached heap limit Allocation
failed - JavaScript heap out of memory`. The `vite-plugin-dts` rolled-up
type generation now exceeds Node's default ~4GB heap on the GH runner.
The `TS2742`/`TS4082` warnings printed earlier are non-fatal diagnostics
from the plugin pre-pass — the api-extractor rollup itself completes
once the heap is large enough.

## Fix

Set `NODE_OPTIONS='--max-old-space-size=8192'` in `build:types`,
matching the existing pattern already used by `build` and `build:cloud`
(only one-line change).

## Verification

- `pnpm build:types` exits 0 locally with the change (built in ~40s).
- `dist/index.d.ts` (1.9MB) emitted with the public types intact:
`ComfyExtension`, `ComfyApi`, `ComfyApp`, `ComfyNodeDef`, `InputSpec`,
`DOMWidget`, etc.
- `dist/package.json` correctly produced by `scripts/prepare-types.js`.
- Reverting the change reproduces the OOM crash.

## Test plan

- [ ] Trigger `Release NPM Types` workflow manually (or wait for next
nightly) and confirm `Build types` step succeeds.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12073-fix-restore-nightly-publish_types-build-35a6d73d3650819e95aecfbd8a66847c)
by [Unito](https://www.unito.io)
2026-05-08 03:06:03 +00:00
Alexander Brown
1f4a4af079 docs: add subgraph promoted widgets ADR (#11997)
## Summary

Adds an ADR documenting the canonical subgraph promoted-widget model and
legacy proxy-widget ratchet.

## Changes

- **What**: Defines linked `SubgraphInput` promoted widgets, host-owned
sparse value overlays, proxy-widget repair/quarantine behavior,
primitive-node repair, and separate display-only preview exposures.
- **Breaking**: None; documentation only.
- **Dependencies**: None.

## Review Focus

- Whether the ADR cleanly separates value-owning promoted widgets from
display-only preview exposures.
- Whether the legacy ratchet, quarantine, primitive repair, and UI
identity decisions are clear enough for implementation review.

## Screenshots (if applicable)

N/A

@Coderabbitai why would a docs update need an end-to-end test?

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11997-docs-add-subgraph-promoted-widgets-ADR-3576d73d36508133bf1ee8d49282cac1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-05-07 19:51:18 -07:00
jaeone94
c8c0e53865 fix: remove asset hash verification (#12061)
## Summary

This PR removes the `/api/assets/hash/:hash` verification path from
missing media/model detection.

I decided to remove this path for two reasons:

1. The Cloud runtime implementation and the OpenAPI/generated FE
contract do not agree on the hash format that this endpoint represents.
In current Cloud data, the dominant asset_hash shape is
<64-hex>.<extension> (for example, abc123....png), while the
OpenAPI/generated FE contract expects a blake3:<hash> style value. That
makes this path either dead code that should never be reached, or, when
it is reached, a request that always returns 400 and only adds
unnecessary noise.

2. Even if the format is reconciled, the Cloud implementation is a
global deduplication-oriented lookup, not an access-aware check for
whether the current workflow can use a resource. In theory, it can
return success for another user's personal asset, so it is the wrong
primitive for missing asset detection.

Because of that, this PR makes the existing asset list/store based
checks the primary verification path and removes the hash-specific
helpers, service method, and tests.

## Known follow-ups

These are known issues that are intentionally not solved in this PR:

1. Published assets are not exposed through
`/api/assets?...include_public=true`. This is a backend issue and can
still cause mismatch between missing-asset detection and resources that
preview/run successfully.
2. Shared workflow import has an ordering issue. The API contract issue
is being hotfixed separately under FE-603.
3. Annotated media paths can still be detected incorrectly.

I will prepare follow-up PRs for these, starting with the annotated
media path issue because that is the most critical frontend-side gap.

## Validation

- `pnpm exec vitest run
src/platform/assets/services/assetService.test.ts
src/platform/missingMedia/missingMediaScan.test.ts
src/platform/missingModel/missingModelScan.test.ts`
- `pnpm lint:unstaged`
- `pnpm typecheck`
- `pnpm knip`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12061-fix-remove-asset-hash-verification-3596d73d365081a088f8dfc874724c1d)
by [Unito](https://www.unito.io)
2026-05-08 01:56:19 +00:00
Comfy Org PR Bot
c8360a092f 1.45.1 (#12070)
Patch version increment to 1.45.1

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12070-1-45-1-35a6d73d365081e9a4bffc19d791b727)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-08 01:10:40 +00:00
AustinMroz
68843967cf App Mode tests (#10633)
Adds tests for
- Mobile app mode.
- Drag and drop operations in app mode
- Basic widget interaction in app mode.
- The read only state when in builder mode.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10633-App-Mode-tests-3306d73d36508154aa25d8096119a32c)
by [Unito](https://www.unito.io)
2026-05-07 15:36:32 -07:00
Yourz
8c295e7c68 fix: remove transition delay from FeedbackSection progress bar (#12059)
*PR Created by the Glary-Bot Agent*

---

## Summary

The horizontal progress bar in `FeedbackSection.vue` lagged behind the
carousel scroll position because the bar's width was driven reactively
by `useScroll`, but the `transition-all duration-200` utility animated
each width change over 200ms. As scroll continuously emits new target
widths, the bar visibly trailed the scroll position.

Removing the transition makes the bar track scroll synchronously.

## Verification

- Reproduced the lag locally on `/customers`.
- Verified post-fix that `bar.style.width` updates in the same frame as
`scrollLeft` changes (samples at scrollLeft 0 / 944 / 1600 → width 0% /
59% / 100%, with `transitionDuration: 0s`).
- `pnpm exec eslint`, `pnpm exec oxfmt --check`, `pnpm nx typecheck
website`, and `pnpm test:unit` all pass.

## Notes

No regression test added — the customers section components have no
existing unit/E2E coverage in this repo, and standing up a new test
harness for a one-line CSS fix would be disproportionate. Worth
following up on broader carousel coverage as a separate task.

## Screenshot

After fix, scrolled to second slide — progress bar tracks scroll
position synchronously.

## Screenshots

![FeedbackSection progress bar synced with scroll after
fix](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/0b55db78805d95bbfa3c5dc0515c18344f0a9f490ce551057542fb86ec998de6/pr-images/1778167277854-e235c826-8368-4e15-939a-00bad6412625.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12059-fix-remove-transition-delay-from-FeedbackSection-progress-bar-3596d73d36508107bc80dc38ea7ab79e)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-07 19:31:46 +00:00
pythongosssss
219a574eed test: add failing test for deleting inner nodes with promoted widgets (#12058)
## Summary

Adds a failing test for a current bug with subgraphs where deleting an
inner node where the widget is promoted does not remove the outer widget

## Changes

- **What**: 
- add failing test

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12058-test-add-failing-test-for-deleting-inner-nodes-with-promoted-widgets-3596d73d3650811f9629c0ed3f4bc3c8)
by [Unito](https://www.unito.io)
2026-05-07 18:01:56 +00:00
Yourz
fef2cab31e fix(website): prevent video glitch when c-projection.webp loads on /customers (#12060)
*PR Created by the Glary-Bot Agent*

---

## Summary

Fix the video position glitch on `/customers` caused by
`c-projection.webp` loading.

## Root cause

In `HeroSection.vue` the hero `<img>` for `c-projection.webp` had no
`width`/`height` attributes, so the browser reserved no space for it.
When the image finished loading, the layout shifted ~192px, pushing the
`VideoPlayer` below it. `useHeroAnimation` registers a GSAP
`ScrollTrigger` parallax against the video in `onMounted` (before the
image is loaded), so the cached scroll geometry then went stale and the
video visibly glitched.

## Fix

- Add explicit `width="1568"` / `height="1763"` to the `<img>` (the
image's native size) so the browser reserves the correct aspect-ratio'd
space upfront.
- Add `h-auto` so the height attribute doesn't override the responsive
layout.
- Refresh `ScrollTrigger` on the image's `@load` (with `refresh(true)`
so measurements happen after layout has settled) as a defensive measure
for any sub-pixel adjustments.

## Test coverage

Added `apps/website/e2e/customers.spec.ts` with a regression guard that:
1. Asserts the hero `<img>` declares numeric `width`/`height`
attributes.
2. Asserts the unloaded image still reserves vertical space (>100px),
which is the exact property that prevents the video from jumping when
the image finishes loading.

Verified the test fails when the `width`/`height` attributes are removed
and passes with the fix applied.

## Verification

- `pnpm typecheck` clean.
- `pnpm test:unit` (website app) — 30/30 pass.
- `pnpm test:e2e customers.spec.ts --project=desktop` — passes.
- ESLint + oxfmt clean on changed files.
- Manual Playwright verification confirmed the video bounding rect stays
at `top=785, height=628` through the full image load lifecycle.
Reverting the fix in DevTools and re-loading the image reproduces the
original ~192px shift.

## Out of scope

`apps/website/src/components/contact/FormSection.vue` uses the same
`c-projection.webp` pattern (also without `width`/`height`). It runs
`useHeroAnimation` with `parallax: false`, so the symptom is much
smaller — leaving as a follow-up to keep this PR minimal.

- Fixes
[FE-607](https://linear.app/comfyorg/issue/FE-607/bug-video-on-customers-shifts-position-when-c-projectionwebp-finishes)

## Screenshots

![/customers page rendered with the fix applied — video stays in correct
position below the
hero](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/0ecf73b69b644739e2bdcce0e77bbe7eb44562f7643b82addc8903d68d25fef3/pr-images/1778167649187-841bf1fa-f3a7-4104-9dab-a024e62dcf4d.jpg)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12060-fix-website-prevent-video-glitch-when-c-projection-webp-loads-on-customers-3596d73d365081ebbcb8db25aaa5c451)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-07 15:38:39 +00:00
pythongosssss
20ee262f78 fix: prevent enter subgraph/toggle advanced when nodes were dragged (#12051)
## Summary

In Vue nodes mode, if you drag a node by the footer button (e.g. enter
subgraph) after you finish dragging, it then unexpectedly still enters
the subgraph, same applies to advanced widgets.

## Changes

- **What**: 
- store `isDraggingVueNodes` before click event and only emit if false

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12051-fix-prevent-enter-subgraph-toggle-advanced-when-nodes-were-dragged-3596d73d365081929173d8e9371b1332)
by [Unito](https://www.unito.io)
2026-05-07 14:20:09 +00:00
pythongosssss
6a8c453659 test: add missing emitModelReady to mock object (#12056)
## Summary

Unblock CI due to missing function in mock
```
⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯
TypeError: this.load3d.emitModelReady is not a function
 ❯ src/extensions/core/load3d/Load3DConfiguration.ts:313:19
    311|       }
    312| 
    313|       this.load3d.emitModelReady()
       |                   ^
    314|     }
    315|   }

This error originated in "src/extensions/core/load3d/Load3DConfiguration.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.
The latest test that might've caused the error is "prefers persisted Scene/Camera/Light Config over settings". It might mean one of the following:
- The error was thrown, while Vitest was running this test.
- If the error occurred after the test had been completed, this was the last documented test before it was thrown.
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯


 Test Files  773 passed (773)
      Tests  10417 passed | 8 skipped (10425)
     Errors  2 errors
   Start at  12:06:06
   Duration  446.79s (transform 32.89s, setup 113.26s, import 589.18s, tests 109.19s, environment 228.45s)
```

## Changes

- **What**: 
- add `emitModelReady` fn to mock

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12056-test-add-missing-emitModelReady-to-mock-object-3596d73d36508130a087f5c7713e932f)
by [Unito](https://www.unito.io)
2026-05-07 14:04:21 +00:00
Terry Jia
ea277dec4d FE-446: test(load3d): cover Load3D/Preview3D extensions and config persistence (#11969)
## Summary
Add unit tests for src/extensions/core/load3d.ts (Load3D and Preview3D
nodeCreated/onExecuted, beforeRegisterNodeDef, getNodeMenuItems, camera
matrix generation guard) and extend Load3DConfiguration tests for the
Scene/Camera/Light config persistence + settingStore fallback paths.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11969-FE-446-test-load3d-cover-Load3D-Preview3D-extensions-and-config-persistence-3576d73d365081c2a847e0dc9621a75d)
by [Unito](https://www.unito.io)
2026-05-07 07:56:07 -04:00
Terry Jia
a7aa124c10 FE-407: fix(assets): show 3D thumbnail without reopening the panel (#11972)
## Summary
After persistThumbnail uploads the preview, patch the in-memory asset by
name (the cross-API stable id) so an open Asset panel reflects the new
preview_url. Media3DTop also picks up the patched preview_url directly,
bypassing the IntersectionObserver gate.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/ba0b753f-fede-43c0-b790-5c19a82455f9


after


https://github.com/user-attachments/assets/6273ec0b-0d2e-4355-9889-68c3550f1a72

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11972-FE-407-fix-assets-show-3D-thumbnail-without-reopening-the-panel-3576d73d365081febcbfe6ae84a4a68b)
by [Unito](https://www.unito.io)
2026-05-07 07:55:30 -04:00
Terry Jia
9c62bbc74a FE-412: fix(load3d): persist SaveGLB last model so it survives tab switch (#11965)
## Summary
Mirror the Preview3D pattern: store the executed file path and folder on
node.properties and reload them in nodeCreated so the mesh re-appears
when the node is recreated (e.g. after switching workflow tabs).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11965-FE-412-fix-load3d-persist-SaveGLB-last-model-so-it-survives-tab-switch-3576d73d365081a98bcadbd9a81539d0)
by [Unito](https://www.unito.io)
2026-05-07 07:54:45 -04:00
Benjamin Lu
f0e16cdf46 ci: handle skipped e2e workflow consumers (#11575)
## Summary

Follow-up to #11568 and #11785. Keeps the E2E coverage workflow clean
when `CI: Tests E2E` is intentionally skipped and no coverage shard
artifacts are produced.

## Changes

- Detect whether downloaded E2E coverage shard artifacts contain any
`coverage.lcov` files.
- Treat missing coverage shards as an intentionally skipped coverage run
instead of running lcov, Codecov, or Pages deployment on missing files.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11575-ci-handle-skipped-e2e-workflow-consumers-34b6d73d3650813f92e1d7d6af0bf958)
by [Unito](https://www.unito.io)
2026-05-07 04:20:36 -07:00
jaeone94
0658c1ac9c refactor: align asset pagination schema (#11899)
## Summary

Align the asset list pagination schema with generated ingest-types
metadata and remove the now-unneeded missing `has_more` fallback branch.

## Changes

- **What**: Reuse `zListAssetsResponse` for `total` and `has_more`, keep
the local loose `AssetItem` shape, and simplify `getAllAssetsByTag()` to
trust the required `has_more` contract.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

This is PR 1 of 4 in the missing asset follow-up stack:

1. This PR - Asset schema / pagination cleanup
2. #11900 - Missing asset hash verification utility cleanup
3. #11901 - Browser regression coverage for public input assets
4. #11902 - TanStack Query public-input cache replacement

The key decision is intentionally narrow: pagination metadata now comes
from generated ingest-types, but asset item validation remains locally
loose to avoid changing UI/store synthetic asset shapes in this PR.
`asset_hash` nullability remains unchanged because absent-vs-null hash
semantics are still a backend/API contract follow-up.

Addresses #11894

## Screenshots (if applicable)

N/A
2026-05-07 10:27:15 +00:00
pythongosssss
997501d8fb test: add e2e test for metadata parsing on workflow load (#11522)
## Summary

Adds e2e testing to ensure workflows are correctly loaded from each of
the supported file types

## Changes

- **What**: 
- add png generation
- add mime types for missing files
- add test that loads file and ensures node is present

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11522-test-add-e2e-test-for-metadata-parsing-on-workflow-load-3496d73d36508101ad67d24af1810cec)
by [Unito](https://www.unito.io)
2026-05-07 09:58:52 +00:00
Christian Byrne
ab6e5ba094 feat: boost SaveImageAdvanced node frequency for search ranking (#11853)
*PR Created by the Glary-Bot Agent*

---

Adds an entry for the new `SaveImageAdvanced` node to
`public/assets/sorted-custom-node-map.json` with the same frequency stat
(1762) as the existing `SaveImage` node, so the new Save Image node
ranks at the top of search results when typing "save" — matching the
original node's behavior.

Context: the new Save Image node ([Notion
spec](https://www.notion.so/comfy-org/Save-Image-94a77c506ce145fc9b8c477c52091a04))
replaces/deprecates the original `SaveImage`. Search ranking uses the
static node frequency map; the new node had no entry and was therefore
ranked at frequency 0. Mirroring the original's stat is the manual-boost
approach discussed in the thread.

## Changes
- `public/assets/sorted-custom-node-map.json`: add `"SaveImageAdvanced":
1762` directly after `"SaveImage": 1762` to preserve descending sort
order.

## Verification
- `pnpm typecheck`, `pnpm lint`, and `pnpm format` all pass via
lint-staged on commit.
- JSON validated and entry placement confirmed (position 4, between
`SaveImage` and `VAEDecode`).
- Review (oracle) ran clean: 0 critical / 0 warning / 0 suggestion.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11853-feat-boost-SaveImageAdvanced-node-frequency-for-search-ranking-3546d73d36508168b058d9d750fc3c56)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-07 09:45:25 +00:00
Yourz
2322a5a497 fix: use webm video for VFX use case right asset (#12040)
*PR Created by the Glary-Bot Agent*

---

## Summary

Replaces `right1.webp` with `right1.webm` in the VFX panel of
`UseCaseSection`. `BlobMedia` already auto-detects `.webm` URLs and
mounts a `<video>` element (with the `.webp` as poster), so this single
URL swap is the only change required — matching the pattern used by the
other 4 use-case panels.

## Files changed

- `apps/website/src/components/home/UseCaseSection.vue` — swap
`right1.webp` → `right1.webm`.

## Verification

- `pnpm exec nx run website:typecheck` — clean
- `pnpm exec eslint` on changed file — clean
- `pnpm exec oxfmt --check` — clean
- pre-commit lint-staged hooks — passed

## Reviewer note (Oracle finding)

VFX is the default active panel, so the homepage's initial right-rail
asset moves from ~131 KB `.webp` to ~4.1 MB `.webm`. Behaviorally
consistent with the other 4 panels (which already use `.webm`), but
worth confirming whether `right1.webm` should be re-encoded smaller on
the CDN before promoting this PR out of draft.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12040-fix-use-webm-video-for-VFX-use-case-right-asset-3596d73d365081829976f37b733840f1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-05-07 09:32:53 +00:00
Dante
0bc951fd12 fix: clarify unsaved-changes modal buttons and fix sign-out 3-state (#11669)
## Summary

The dirtyClose modal had three buttons (`Cancel | No | Save`) and the
sign-out flow collapsed two distinct outcomes (deny vs. dismiss) into a
single early return — so today clicking "No" *cancels* sign-out instead
of signing out without saving, and clicking "Save" never actually saves
before logging out. This PR drops `Cancel` for `dirtyClose`, gives each
caller a context-specific deny label, and fixes the sign-out 3-state
handling.

- Fixes
[FE-419](https://linear.app/comfyorg/issue/FE-419/unsaved-changes-modal-uses-confusing-button-labels)

## Changes

- **What**:
- `ConfirmationDialogContent.vue`: hide `Cancel` for
`type='dirtyClose'`; add `denyLabel?: string` prop; autofocus `Save`
(preserves work on Enter).
  - `dialogService.confirm()`: accept and forward `denyLabel`.
- `useAuthActions.logout`: handle `null` (cancel) / `false` (sign out
anyway, no save) / `true` (save each modified workflow, then logout)
distinctly. Pass `denyLabel: 'Sign out anyway'`.
  - `workflowService.closeWorkflow`: pass `denyLabel: 'Close anyway'`.
- i18n: add `auth.signOut.signOutAnyway` and
`sideToolbar.workflowTab.closeAnyway`.
- **Breaking**: none. The `denyLabel` prop is optional and falls back to
`g.no`.

## Review Focus

- The "Save" branch in `useAuthActions.logout` now iterates
`workflowStore.modifiedWorkflows` and awaits
`useWorkflowService().saveWorkflow(workflow)` for each before calling
`authStore.logout()`. The close-tab path
(`workflowService.closeWorkflow`) was already correct — only the
sign-out path needed the same shape.
- `ConfirmationDialogContent` autofocus moves from `Cancel` (gone for
`dirtyClose`) to `Save`. The dialog is still dismissable via ESC /
outside-click, which routes through `dialogComponentProps.onClose →
resolve(null)` — sign-out and close-tab both treat `null` as cancel.
- Out of scope: the native browser `beforeunload` warning
(`UnloadWindowConfirmDialog.vue`) is a separate flow and never reaches
the in-app modal.

## Tests

- Unit (`useAuthActions.test.ts`, new): logout handles `null` / `false`
/ `true` / no-modified-workflows; saves *every* modified workflow before
`authStore.logout`; passes `denyLabel='Sign out anyway'`.
- Unit (`ConfirmationDialogContent.test.ts`): Cancel hidden for
`dirtyClose`; custom `denyLabel` rendered; falls back to `g.no` when
omitted.
- E2E (`workflowTabs.spec.ts`): modified-tab close shows `Close anyway`
(not `No`) and no `Cancel`; clicking `Close anyway` removes the tab; ESC
keeps the tab.

## screenshot

### AS IS 

<img width="816" height="379" alt="Screenshot 2026-04-27 at 5 40 19 PM"
src="https://github.com/user-attachments/assets/a8e39403-bf72-455a-8d86-6ceb1f94ac85"
/>

<img width="923" height="396" alt="Screenshot 2026-04-27 at 5 40 38 PM"
src="https://github.com/user-attachments/assets/08031c7c-b3a6-45d7-a4dc-5dcb4e63cfa0"
/>


### TO BE 

<img width="1661" height="872" alt="Screenshot 2026-04-27 at 5 43 40 PM"
src="https://github.com/user-attachments/assets/b89d160b-be66-450e-981e-32b1591f6841"
/>


<img width="1488" height="584" alt="Screenshot 2026-04-27 at 5 44 21 PM"
src="https://github.com/user-attachments/assets/b3a141a7-1f3b-4f25-85a9-49529229c28b"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11669-fix-clarify-unsaved-changes-modal-buttons-and-fix-sign-out-3-state-34f6d73d365081bf8afad8e146b3b990)
by [Unito](https://www.unito.io)
2026-05-07 02:49:02 -07:00
Christian Byrne
0446ca7a18 fix: route default topbar feedback button to Typeform (#11863)
*PR Created by the Glary-Bot Agent*

---

## Summary

PR #10890 routed the legacy action bar feedback button and the Help
Center feedback item to the nightly Typeform survey, but the **default
topbar feedback button** in `WorkflowTabs.vue` still called
`buildFeedbackUrl()` and opened Zendesk. Since `Comfy.UI.TabBarLayout`
defaults to `Default` (not `Legacy`), most Cloud/Nightly users were
clicking the WorkflowTabs button and never reaching the Typeform survey
— explaining the lack of survey responses.

## Changes

- Added a shared `buildFeedbackTypeformUrl(source)` helper in
`platform/support/config.ts` that tags the survey URL with:
- `distribution`: `ccloud` / `oss-nightly` / `oss` (preserves the
build-tagging the old `buildFeedbackUrl()` sent to Zendesk so responses
stay segmented)
- `source`: `topbar` / `action-bar` / `help-center` (identifies which UI
entry point launched the survey)

Tags are passed via the URL fragment (Typeform's hidden-field
convention), so they reach the survey but are never sent to the server
in the request line.
- `WorkflowTabs.vue`: replaced `buildFeedbackUrl()` with
`buildFeedbackTypeformUrl('topbar')`.
- `cloudFeedbackTopbarButton.ts` and `HelpCenterMenuContent.vue`: use
the shared builder with their respective source labels instead of inline
URL literals.
- Removed the now-unused `buildFeedbackUrl()` and
`ZENDESK_FEEDBACK_FORM_ID` (knip-clean). `buildSupportUrl()` is
preserved — `Comfy.ContactSupport` (the Help Center "Help" item) still
routes to Zendesk as before.
- Added unit tests for the builder, the WorkflowTabs feedback button,
the legacy action bar button, and the Help Center feedback item
(covering both the Cloud/Nightly Typeform path and the OSS
`Comfy.ContactSupport` fallback).

## Verification

- `pnpm format`, `pnpm lint`, `pnpm typecheck`, `pnpm knip`: clean (one
pre-existing unrelated lint warning in `useWorkspaceBilling.test.ts`)
- `pnpm test:unit` (impacted scope): 506/506 passing, including 13 new
tests

## Review Focus

- Cloud/Nightly gating in `WorkflowTabs.vue` (`v-if="isCloud ||
isNightly"`) is unchanged and matches PR #10890's gating philosophy.
- The Help Center "Help" item and `Comfy.ContactSupport` command
intentionally still route to Zendesk — feedback ≠ support.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11863-fix-route-default-topbar-feedback-button-to-Typeform-3556d73d3650815fb446dac33095d4be)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-07 02:45:05 -07:00
Alexander Brown
660655a34b Merge branch 'main' into fix/setting-store-deep-equality 2026-05-07 01:22:56 -07:00
Terry Jia
653ee48444 FE-557: fix(painter): responsive label layout + correct resize min-height (#12025)
## Summary

- WidgetPainter: stack label above widget when controls width < 350px,
side-by-side otherwise; labels are always rendered (no longer hidden
when narrow).
- useNodeResize: re-measure the node's intrinsic min content height on
every pointermove instead of capturing it once at drag start, so the
height clamp tracks widgets whose controls reflow taller as width
shrinks (e.g. painter switching to compact layout). Without this, the
node visually sticks at its current height and the user has to release
and grab the corner again to free it.
- Add unit tests for both changes.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/74889ad5-63a7-439f-b8e4-0185ed95327f


after


https://github.com/user-attachments/assets/bca77c36-2f90-4685-8603-f8f9c02abe77

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12025-FE-557-fix-painter-responsive-label-layout-correct-resize-min-height-3586d73d365081cf9036f7d52bfabe6c)
by [Unito](https://www.unito.io)
2026-05-07 04:11:51 -04:00
Yourz
81d9df61f2 fix(website): tighten GitHub ticker spacing on wide screens (#12021)
*PR Created by the Glary-Bot Agent*

---

## Summary

The GitHub star ticker reads as detached from the GitHub octocat icon on
wider viewports. The CSS gap is constant (`gap-2` = 8px), but the 28px
icon next to a 20px badge plus the fixed gap make the pair look like two
separate items at `lg+` widths instead of a single coupled unit.

## Change

`apps/website/src/components/common/GitHubStarBadge.vue`:

- Inner gap `gap-2` → `gap-1` (8px → 4px) so the badge and icon sit
closer together.
- Icon `size-7` → `size-6 shrink-0` (28px → 24px) so the icon height is
closer to the badge height (20px) and the pair reads as one unit.

The outer `gap-2` between CTA items in `SiteNav.vue` is intentionally
unchanged — it is the correct spacing between unrelated CTA elements.

## Verification

- `pnpm typecheck` — 0 errors (1 pre-existing hint in unrelated file).
- `pnpm format:check` — clean.
- `pnpm exec eslint
apps/website/src/components/common/GitHubStarBadge.vue` — clean.
- `pnpm test:unit` (apps/website) — 30/30 pass.
- Pre-commit hook (oxfmt + oxlint + eslint + typecheck +
typecheck:website) — passed.
- `/review` (Oracle) — 0 critical, 0 warnings, 0 suggestions.
- Manual visual verification at 1280px, 1920px, and 2560px viewports.
- Tested longer star strings (`999.9K`, `1.2M`) — no wrapping.

## Before / After (2560px viewport)

Before: badge and octocat appear visually detached.
After: badge and octocat read as a single tightly-coupled unit.

## Screenshots

![Before — 2560px nav: 112K badge looks detached from GitHub
icon](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5ac405b162f5db2c1f077b87e73caf4339911cb07834849a01c4b97409d844d3/pr-images/1778054509381-5426120f-a699-4633-8645-62d5b7e26493.png)

![After — 2560px nav: 112K badge and GitHub icon read as one
unit](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5ac405b162f5db2c1f077b87e73caf4339911cb07834849a01c4b97409d844d3/pr-images/1778054509735-fc6c7bbe-4cd8-4f40-a8c7-9e5b9d0ad2a2.png)

![Before — 1280px nav
baseline](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5ac405b162f5db2c1f077b87e73caf4339911cb07834849a01c4b97409d844d3/pr-images/1778054510078-3c5e0b33-b6f4-4b85-b6a6-6ca0c1651e85.png)

![After — 1280px nav: still visually
balanced](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5ac405b162f5db2c1f077b87e73caf4339911cb07834849a01c4b97409d844d3/pr-images/1778054510429-db5233f4-3f16-402e-b608-6f2230ff8ced.png)

![After — 2560px CTA close-up: badge + icon tightly
coupled](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5ac405b162f5db2c1f077b87e73caf4339911cb07834849a01c4b97409d844d3/pr-images/1778054510733-6305f2a6-408c-40b8-b3ea-3ae10cb1d171.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12021-fix-website-tighten-GitHub-ticker-spacing-on-wide-screens-3586d73d365081be8d66dfbb22b8dc2c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-07 06:03:27 +00:00
Alexander Brown
e5eecfbae7 Merge branch 'main' into fix/setting-store-deep-equality 2026-05-06 23:02:46 -07:00
Rizumu Ayaka
f4358cb161 perf: drop useMouseInElement to fix templates search lag (#12023)
## Summary

Replace `useMouseInElement` in `CompareSliderThumbnail` with a local
`@mousemove` handler to eliminate ~1s of forced layout per keystroke in
the templates dialog search.

## Changes

- **What**: Profiling the templates dialog search (4× CPU throttle,
cloud distribution) showed a single `getClientRects` block at 977ms
inside Vue's `flushPostFlushCbs → update` path on every keystroke. The
call traces back to VueUse's `useMouseInElement`, which (a) shares a
global `mousemove` listener via `useMouse`, and (b) runs
`el.getBoundingClientRect()` inside a `watch([targetRef, x, y], …, {
immediate: true })`. Every template card with `thumbnailVariant ===
'compareSlider'` mounts one instance — when search filters change and
the page's compareSlider cards re-mount, each instance's `immediate`
watch fires a rect read against freshly-inserted DOM, forcing
synchronous layout of the entire new subtree. With many cards on screen
the costs stack into the ~977ms block. Replaced with a native
`@mousemove` listener bound directly to the slider container; the rect
is read from `event.currentTarget` only when the mouse is actually over
that one card, so the work no longer scales with mounted instance count
and is gated by real pointer activity.
- **Breaking**: None
- **Dependencies**: None

## Review Focus

- UX is unchanged: `sliderPosition` still follows the mouse during hover
and keeps its last value on mouseleave (matches previous behaviour where
`if (!isHovered) return` simply stopped updates without resetting).
- The same `useMouseInElement` pattern still exists in
`src/platform/workflow/sharing/composables/useSliderFromMouse.ts` (used
by `ComfyHubThumbnailStep` in the publish dialog). That path is
single-instance and off the templates hot path, so it's left untouched
to keep this PR scoped — happy to fix it in a follow-up.
- New tests use `userEvent.pointer({ coords })` with a stubbed
`getBoundingClientRect` to lock in the native mousemove path and the
zero-width guard.

## E2E coverage

No `browser_tests/` changes. The `fix:` commit is a defensive clamp
inside `updateSliderPosition`; the overshoot it guards against comes
from subpixel rounding and stale rects observed during hover-in, neither
of which can be deterministically reproduced under Playwright. The perf
change itself is verified via the DevTools profiler (forced-layout block
disappears) — also not assertable as a stable e2e signal across CI
hardware. Both the mousemove-driven slider behavior and the clamp guard
are covered by unit tests in
`src/components/templates/thumbnails/CompareSliderThumbnail.test.ts` (8
cases, including out-of-range pointer coordinates and zero-width
containers).
2026-05-07 05:03:33 +00:00
Comfy Org PR Bot
5948002dee 1.45.0 (#12037)
Minor version increment to 1.45.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12037-1-45-0-3596d73d365081609323df8b5aac04ce)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <448862+DrJKL@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-07 04:28:55 +00:00
Kelly Yang
49cf68db19 Revert "fix(keybindings): serialize KeybindingImpl to plain object before persisting"
This reverts commit 63fff48ce6.
2026-05-05 14:32:00 -07:00
Kelly Yang
63fff48ce6 fix(keybindings): serialize KeybindingImpl to plain object before persisting
_.isEqual compares constructors, so KeybindingImpl instances are never
equal to the plain objects returned by the backend. Serialize bindings
to plain objects before passing to setMany so the deep-equality check
in settingStore can correctly skip redundant writes.
2026-05-05 14:24:27 -07:00
Kelly Yang
55a558f323 fix(settings): use deep equality to skip redundant setting writes
`applySettingLocally` compared `newValue` and `oldValue` with `===`,
but `get()` always returns a `_.cloneDeep` result, so object and array
settings always produced different references even when the values were
identical. Replace with `_.isEqual` so redundant `onChange` calls and
API writes are correctly suppressed for non-primitive settings.
2026-05-05 13:57:05 -07:00
240 changed files with 12126 additions and 3981 deletions

View File

@@ -19,15 +19,26 @@ reviews:
- name: End-to-end regression coverage for fixes
mode: error
instructions: |
Use only PR metadata already available in the review context: the PR title, commit subjects in this PR, the files changed in this PR relative to the PR base (equivalent to `base...head`), and the PR description.
Do not rely on shell commands. Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
Use only PR metadata already available in the review context:
- the PR title
- commit subjects in this PR
- The files changed in this PR relative to the PR base (equivalent to `base...head`)
- the PR description.
Do not rely on shell commands.
Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR.
If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
Pass if at least one of the following is true:
1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes at least one file under `browser_tests/`.
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
Fail if all of the following are true:
1. The PR title and/or any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes files under `src/` or `packages/` related to the main frontend application but the PR does not change at least one file under `browser_tests/`.
3. The PR description lacks a concrete explanation of why an end-to-end regression test was not added.
Do not fail if the changes are exclusively in `apps/website`, just documentation changes, or changes related to CI processes.
The goal is to make sure that fixes include End-to-End regression tests. Do not insist on tests when the PR is not fixing a bug.
Pass otherwise.
When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
- name: ADR compliance for entity/litegraph changes
mode: warning
instructions: |

View File

@@ -20,6 +20,8 @@ jobs:
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
has-coverage: ${{ steps.coverage-shards.outputs.has-coverage }}
steps:
- name: Checkout repository
@@ -37,31 +39,33 @@ jobs:
path: temp/coverage-shards
if_no_artifact_found: warn
- name: Detect shard coverage data
id: coverage-shards
run: |
if [ -d temp/coverage-shards ] && find temp/coverage-shards -name 'coverage.lcov' -type f | grep -q .; then
echo "has-coverage=true" >> "$GITHUB_OUTPUT"
else
echo "has-coverage=false" >> "$GITHUB_OUTPUT"
echo "No E2E coverage shard artifacts found; treating this run as skipped." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Install lcov
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: sudo apt-get install -y -qq lcov
- name: Merge shard coverage into single LCOV
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
mkdir -p coverage/playwright
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
if [ -z "$LCOV_FILES" ]; then
echo "No coverage.lcov files found"
touch coverage/playwright/coverage.lcov
exit 0
fi
ADD_ARGS=""
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
wc -l coverage/playwright/coverage.lcov
- name: Validate merged coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
if [ "$SHARD_COUNT" -eq 0 ]; then
echo "::notice::No shard coverage files; upstream E2E was likely skipped."
exit 0
fi
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
@@ -82,7 +86,7 @@ jobs:
done
- name: Upload merged coverage data
if: always()
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: actions/upload-artifact@v6
with:
name: e2e-coverage
@@ -91,7 +95,7 @@ jobs:
if-no-files-found: warn
- name: Upload E2E coverage to Codecov
if: always()
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/playwright/coverage.lcov
@@ -100,6 +104,7 @@ jobs:
fail_ci_if_error: false
- name: Generate HTML coverage report
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
@@ -114,6 +119,7 @@ jobs:
--precision 1
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-html
@@ -122,7 +128,9 @@ jobs:
deploy:
needs: merge
if: github.event.workflow_run.head_branch == 'main'
if: >
github.event.workflow_run.head_branch == 'main' &&
needs.merge.outputs.has-coverage == 'true'
runs-on: ubuntu-latest
permissions:
pages: write

View File

@@ -9,7 +9,6 @@ import en from '@frontend-locales/en/main.json' with { type: 'json' }
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
import { getDefaultLocale } from '@frontend-locales/localeConfig'
import { createI18n } from 'vue-i18n'
function buildLocale<
@@ -168,7 +167,7 @@ const messages: Record<string, LocaleMessages> = {
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,
locale: getDefaultLocale(),
locale: navigator.language.split('-')[0] || 'en',
fallbackLocale: 'en',
messages,
// Ignore warnings for locale options as each option is in its own language.

View File

@@ -32,16 +32,34 @@ test.describe('Careers page @smoke', () => {
}
})
test('ENGINEERING category filter narrows the role list', async ({
test('clicking a department button scrolls to and activates that section', async ({
page
}) => {
const rolesSection = page.getByTestId('careers-roles')
await rolesSection.scrollIntoViewIfNeeded()
await expect(rolesSection).toBeVisible()
const allCount = await page.getByTestId('careers-role-link').count()
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
const engineeringLocator = page.getByTestId('careers-role-link')
await expect(engineeringLocator.first()).toBeVisible()
const engineeringCount = await engineeringLocator.count()
expect(engineeringCount).toBeLessThanOrEqual(allCount)
expect(engineeringCount).toBeGreaterThan(0)
const engineeringButton = page.getByRole('button', {
name: 'ENGINEERING',
exact: true
})
// RolesSection is hydrated via `client:visible`. Once the button responds
// to a click by flipping aria-pressed, Vue is hydrated and the rest of
// the locator logic is in effect.
await expect(async () => {
await engineeringButton.click()
await expect(engineeringButton).toHaveAttribute('aria-pressed', 'true', {
timeout: 1_000
})
}).toPass({ timeout: 10_000 })
const engineeringSection = page.locator('#careers-dept-engineering')
await expect(engineeringSection).toBeInViewport()
expect(await page.getByTestId('careers-role-link').count()).toBe(allCount)
})
})

View File

@@ -0,0 +1,61 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const M4_PRO_14_INCH_VIEWPORT = { width: 2016, height: 1310 }
const LAST_SECTION_HASH = '#contact'
test.describe(
'ContentSection scroll-spy @smoke',
{
annotation: [
{
type: 'issue',
description:
'https://linear.app/comfyorg/issue/FE-604/bug-bottom-badge-not-activating-on-scroll-at-high-resolution-3024x1964'
},
{
type: 'environment',
description:
'14" MacBook M4 Pro logical viewport reported in FE-604; /privacy-policy reproduces because of its short trailing sections'
}
]
},
() => {
test.use({ viewport: M4_PRO_14_INCH_VIEWPORT })
test('activates the last badge when user scrolls to the bottom', async ({
page
}) => {
await page.goto('/privacy-policy')
const sidebarNav = page.getByRole('navigation', {
name: 'Category filter'
})
const badges = sidebarNav.getByRole('button')
const lastBadge = badges.last()
await expect(badges.first()).toHaveAttribute('aria-pressed', 'true')
await expect(lastBadge).toHaveAttribute('aria-pressed', 'false')
await page.evaluate(() =>
window.scrollTo(0, document.documentElement.scrollHeight)
)
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
})
test('activates the last badge when page mounts already at the bottom via trailing hash', async ({
page
}) => {
await page.goto(`/privacy-policy${LAST_SECTION_HASH}`)
const sidebarNav = page.getByRole('navigation', {
name: 'Category filter'
})
const lastBadge = sidebarNav.getByRole('button').last()
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
})
}
)

View File

@@ -0,0 +1,33 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Customers @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/customers')
})
test('hero image declares intrinsic dimensions so layout reserves space before load', async ({
page
}) => {
const heroImage = page.locator('img[alt="Comfy 3D logo"]')
await expect(heroImage).toBeVisible()
await expect(heroImage).toHaveAttribute('width', /^\d+$/)
await expect(heroImage).toHaveAttribute('height', /^\d+$/)
// Regression guard: an unloaded <img> without intrinsic dimensions
// collapses to ~0px, then jumps to its natural size on load and pushes
// the video below it. Reserved space must persist before bytes arrive.
const heightWhileUnloaded = await page.evaluate(() => {
const img = document.querySelector<HTMLImageElement>(
'img[alt="Comfy 3D logo"]'
)
if (!img) return null
img.removeAttribute('src')
return img.getBoundingClientRect().height
})
expect(heightWhileUnloaded).not.toBeNull()
expect(heightWhileUnloaded!).toBeGreaterThan(100)
})
})

View File

@@ -1,27 +1,71 @@
import { expect, test } from '@playwright/test'
import { demos, getNextDemo } from '../src/config/demos'
import { t } from '../src/i18n/translations'
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
test.describe('Demo pages @smoke', () => {
test('demo detail page renders hero and embed', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
await expect(page.getByRole('heading', { level: 1 })).toContainText(
'Create a Video from an Image'
)
const iframe = page.locator('iframe[title*="Interactive demo"]')
await expect(iframe).toBeAttached()
})
for (const demo of demos) {
const nextDemo = getNextDemo(demo.slug)
test('demo detail page has transcript section', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(
page.getByRole('button', { name: /demo transcript/i })
).toBeVisible()
})
test(`/demos/${demo.slug} renders hero, embed, transcript, and next-demo nav`, async ({
page
}) => {
await page.goto(`/demos/${demo.slug}`)
test('demo detail page has next demo navigation', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(page.getByText(/what's next/i)).toBeVisible()
})
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
await expect(heading).toContainText(t(demo.title, 'en'))
const ogImage = page.locator('head meta[property="og:image"]')
await expect(ogImage).toHaveAttribute(
'content',
new RegExp(`${escapeRegExp(demo.slug)}-og\\.png`)
)
const iframe = page.locator(
`iframe[title*="${t('demos.embed.label', 'en')}"]`
)
await expect(iframe).toBeAttached()
await expect(iframe).toHaveAttribute(
'src',
new RegExp(escapeRegExp(demo.arcadeId))
)
await expect(
page.getByRole('button', { name: /demo transcript/i })
).toBeVisible()
await expect(
page.getByText(t(nextDemo.title, 'en')).first()
).toBeVisible()
const nextThumb = page.locator(`img[src="${nextDemo.thumbnail}"]`).first()
await expect(nextThumb).toBeAttached()
await expect(nextThumb).toBeVisible()
const naturalWidth = await nextThumb.evaluate(
(img) => (img as HTMLImageElement).naturalWidth
)
expect(naturalWidth).toBeGreaterThan(1)
})
test(`/zh-CN/demos/${demo.slug} renders localized content`, async ({
page
}) => {
await page.goto(`/zh-CN/demos/${demo.slug}`)
await expect(page).toHaveURL(/\/zh-CN\/demos\//)
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toContainText(t(demo.title, 'zh-CN'))
await expect(heading).toContainText(/[\u4E00-\u9FFF]/)
await expect(
page.getByText(t(nextDemo.title, 'zh-CN')).first()
).toBeVisible()
})
}
test('demo library page renders', async ({ page }) => {
await page.goto('/demos')
@@ -32,13 +76,4 @@ test.describe('Demo pages @smoke', () => {
const response = await page.goto('/demos/nonexistent')
expect(response?.status()).toBe(404)
})
test('zh-CN demo page renders localized content', async ({ page }) => {
await page.goto('/zh-CN/demos/image-to-video')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
'从图片创建视频'
)
const nextDemoLink = page.locator('a[href*="/zh-CN/demos/"]').first()
await expect(nextDemoLink).toBeAttached()
})
})

View File

@@ -1,3 +1,4 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
@@ -47,4 +48,105 @@ test.describe('Mobile layout @mobile', () => {
const mobileContainer = page.getByTestId('social-proof-mobile')
await expect(mobileContainer).toBeVisible()
})
test.describe('SocialProofBar seamless marquee', () => {
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
test('mobile forward marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-mobile"] .animate-marquee'
)
expectSeamlessForwardLoop(geometry)
})
test('mobile reverse marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-mobile"] .animate-marquee-reverse'
)
expectSeamlessReverseLoop(geometry)
})
})
})
test.describe('Desktop SocialProofBar @smoke', () => {
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('desktop marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-desktop"] .animate-marquee'
)
expectSeamlessForwardLoop(geometry)
})
})
type MarqueeGeometry = {
copyWidths: number[]
startPositions: number[]
endPositions: number[]
}
async function measureMarqueeLoopGeometry(
page: Page,
selector: string
): Promise<MarqueeGeometry> {
await page.locator(selector).first().waitFor()
return page.evaluate((sel) => {
const tracks = Array.from(
document.querySelectorAll<HTMLElement>(sel)
).slice(0, 2)
const firstAnimation = tracks[0]?.getAnimations()[0]
if (!firstAnimation) {
throw new Error(`No CSS animation found on ${sel}`)
}
const duration = firstAnimation.effect?.getTiming().duration
if (typeof duration !== 'number' || duration <= 1) {
throw new Error(
`Animation on ${sel} has unusable duration: ${String(duration)}`
)
}
const setAllTimes = (time: number) => {
for (const track of tracks) {
for (const anim of track.getAnimations()) {
anim.currentTime = time
}
}
void document.body.offsetWidth
}
const readX = () => tracks.map((track) => track.getBoundingClientRect().x)
setAllTimes(0)
const startPositions = readX()
const copyWidths = tracks.map(
(track) => track.getBoundingClientRect().width
)
setAllTimes(duration - 0.1)
const endPositions = readX()
return { copyWidths, startPositions, endPositions }
}, selector)
}
function expectTwoMatchingCopies(geometry: MarqueeGeometry) {
const { copyWidths } = geometry
expect(copyWidths.length, 'expected two duplicate marquee tracks').toBe(2)
expect(copyWidths[0]).toBeGreaterThan(0)
expect(copyWidths[1]).toBeCloseTo(copyWidths[0], 0)
}
function expectSeamlessForwardLoop(geometry: MarqueeGeometry) {
expectTwoMatchingCopies(geometry)
// Copy 2 ends the cycle exactly where copy 1 started, so the restart
// (when copy 1 jumps back to its start position) is visually indistinguishable.
expect(geometry.endPositions[1]).toBeCloseTo(geometry.startPositions[0], 0)
}
function expectSeamlessReverseLoop(geometry: MarqueeGeometry) {
expectTwoMatchingCopies(geometry)
// Reverse marquee: copy 1 ends the cycle where copy 2 started.
expect(geometry.endPositions[0]).toBeCloseTo(geometry.startPositions[1], 0)
}

View File

@@ -26,8 +26,8 @@ async function assertNoOverflow(page: Page) {
}
async function navigateAndSettle(page: Page, url: string) {
await page.goto(url)
await page.waitForLoadState('networkidle')
await page.goto(url, { waitUntil: 'domcontentloaded' })
await page.waitForLoadState('load')
}
test.describe('Home', { tag: '@visual' }, () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -28,7 +28,7 @@ export default defineConfig({
? [['html'], ['json', { outputFile: 'results.json' }]]
: 'html',
expect: {
toHaveScreenshot: { maxDiffPixels: 50 }
toHaveScreenshot: { maxDiffPixels: 100 }
},
...maybeLocalOptions,
webServer: {

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useEventListener, useTemplateRefsList } from '@vueuse/core'
import { computed, onMounted, ref } from 'vue'
import type { Department } from '../../data/roles'
import type { Locale } from '../../i18n/translations'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import { t } from '../../i18n/translations'
import { scrollTo } from '../../scripts/smoothScroll'
import CategoryNav from '../common/CategoryNav.vue'
import SectionLabel from '../common/SectionLabel.vue'
@@ -13,24 +16,72 @@ const { locale = 'en', departments = [] } = defineProps<{
departments?: readonly Department[]
}>()
const activeCategory = ref('all')
const visibleDepartments = computed(() =>
departments.filter((d) => d.roles.length > 0)
)
const categories = computed(() => [
{ label: 'ALL', value: 'all' },
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
])
const filteredDepartments = computed(() =>
activeCategory.value === 'all'
? visibleDepartments.value
: visibleDepartments.value.filter((d) => d.key === activeCategory.value)
const categories = computed(() =>
visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
)
const hasRoles = computed(() => visibleDepartments.value.length > 0)
const activeCategory = ref('')
const sectionRefs = useTemplateRefsList<HTMLElement>()
let isScrolling = false
let pendingFrame = 0
const HEADER_OFFSET = -144
const ACTIVATION_OFFSET = 300
const deptElementId = (key: string) => `careers-dept-${key}`
function pickActiveSection() {
pendingFrame = 0
if (isScrolling) return
const sections = sectionRefs.value as HTMLElement[]
if (sections.length === 0) return
let active = sections[0]
for (const el of sections) {
if (el.getBoundingClientRect().top - ACTIVATION_OFFSET <= 0) {
active = el
} else {
break
}
}
activeCategory.value = active.id.replace(/^careers-dept-/, '')
}
function scheduleUpdate() {
if (pendingFrame !== 0) return
pendingFrame = requestAnimationFrame(pickActiveSection)
}
onMounted(pickActiveSection)
useEventListener('scroll', scheduleUpdate, { passive: true })
useEventListener('resize', scheduleUpdate, { passive: true })
function scrollToDepartment(deptKey: string) {
activeCategory.value = deptKey
isScrolling = true
const el = document.getElementById(deptElementId(deptKey))
if (!el) {
isScrolling = false
return
}
scrollTo(el, {
offset: HEADER_OFFSET,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: () => {
isScrolling = false
pickActiveSection()
}
})
}
</script>
<template>
@@ -48,9 +99,10 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
</h2>
<CategoryNav
v-if="hasRoles"
v-model="activeCategory"
:categories="categories"
:model-value="activeCategory"
class="mt-4"
@update:model-value="scrollToDepartment"
/>
</div>
</div>
@@ -65,9 +117,11 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
</p>
<div
v-for="dept in filteredDepartments"
v-for="dept in visibleDepartments"
:id="deptElementId(dept.key)"
:ref="sectionRefs.set"
:key="dept.key"
class="mb-12 last:mb-0"
class="mb-12 scroll-mt-24 last:mb-0 md:scroll-mt-36"
>
<SectionLabel>
{{ dept.name }}

View File

@@ -1,7 +1,11 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core'
import { computed, ref } from 'vue'
import {
useEventListener,
useIntersectionObserver,
useTemplateRefsList
} from '@vueuse/core'
import { computed, onMounted, ref } from 'vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
@@ -40,13 +44,25 @@ const activeSection = ref(sections[0]?.id ?? '')
const sectionRefs = useTemplateRefsList<HTMLElement>()
let isScrolling = false
let scrollSafetyTimer: ReturnType<typeof setTimeout> | undefined
const HEADER_OFFSET = -144
const BOTTOM_THRESHOLD_PX = 4
const SCROLL_SAFETY_MS = 1500
function clearScrollLock() {
isScrolling = false
if (scrollSafetyTimer !== undefined) {
clearTimeout(scrollSafetyTimer)
scrollSafetyTimer = undefined
}
}
useIntersectionObserver(
sectionRefs,
(entries) => {
if (isScrolling) return
if (isAtBottom()) return
let best: IntersectionObserverEntry | null = null
for (const entry of entries) {
if (!entry.isIntersecting) continue
@@ -58,22 +74,39 @@ useIntersectionObserver(
{ rootMargin: '-20% 0px -60% 0px' }
)
function isAtBottom(): boolean {
const scrollBottom = window.scrollY + window.innerHeight
return (
scrollBottom >= document.documentElement.scrollHeight - BOTTOM_THRESHOLD_PX
)
}
function activateLastIfAtBottom() {
if (isScrolling) return
if (!isAtBottom()) return
const lastId = sections[sections.length - 1]?.id
if (lastId) activeSection.value = lastId
}
onMounted(activateLastIfAtBottom)
useEventListener('scroll', activateLastIfAtBottom, { passive: true })
function scrollToSection(id: string) {
activeSection.value = id
clearScrollLock()
isScrolling = true
scrollSafetyTimer = setTimeout(clearScrollLock, SCROLL_SAFETY_MS)
const el = document.getElementById(id)
if (el) {
scrollTo(el, {
offset: HEADER_OFFSET,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: () => {
isScrolling = false
}
onComplete: clearScrollLock
})
return
}
isScrolling = false
clearScrollLock()
}
</script>

View File

@@ -13,7 +13,7 @@ const { stars } = defineProps<{
target="_blank"
rel="noopener noreferrer"
:aria-label="`ComfyUI on GitHub ${stars} stars`"
class="hidden shrink-0 items-center gap-2 lg:flex"
class="hidden shrink-0 items-center gap-1 lg:flex"
>
<NodeBadge
:segments="[{ text: stars }]"
@@ -22,7 +22,7 @@ const { stars } = defineProps<{
size-class="h-5 sm:h-5"
/>
<span
class="bg-primary-comfy-yellow block size-7"
class="bg-primary-comfy-yellow block size-6 shrink-0"
aria-hidden="true"
style="mask: url('/icons/social/github.svg') center / contain no-repeat"
/>

View File

@@ -14,23 +14,28 @@ const logos = [
'Ubisoft'
]
const desktopLogos = Array.from({ length: 4 }, () => logos).flat()
const row1 = logos.slice(0, 6)
const mobileRow1 = [...row1, ...row1]
const row2 = logos.slice(6)
const mobileRow2 = [...row2, ...row2]
const mobileRow1Logos = logos.slice(0, 6)
const mobileRow2Logos = logos.slice(6)
</script>
<template>
<section class="overflow-hidden py-12">
<!-- Single row on desktop -->
<div class="animate-marquee hidden items-center gap-2 md:flex">
<div data-testid="social-proof-desktop" class="hidden w-max gap-2 md:flex">
<div
v-for="(logo, i) in desktopLogos"
:key="`${logo}-${i}`"
class="flex h-20 w-50 shrink-0 items-center justify-center"
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-2"
style="--marquee-gap: 0.5rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
<div
v-for="logo in logos"
:key="logo"
class="flex h-20 w-50 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
</div>
@@ -39,22 +44,38 @@ const mobileRow2 = [...row2, ...row2]
data-testid="social-proof-mobile"
class="flex flex-col gap-8 md:hidden"
>
<div class="animate-marquee flex items-center gap-8">
<div class="flex w-max gap-8">
<div
v-for="(logo, i) in mobileRow1"
:key="`${logo}-${i}`"
class="flex h-14 w-40 shrink-0 items-center justify-center"
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
<div
v-for="logo in mobileRow1Logos"
:key="logo"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
</div>
<div class="animate-marquee-reverse flex items-center gap-8">
<div class="flex w-max gap-8">
<div
v-for="(logo, i) in mobileRow2"
:key="`${logo}-${i}`"
class="flex h-14 w-40 shrink-0 items-center justify-center"
v-for="copy in 2"
:key="copy"
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
<div
v-for="logo in mobileRow2Logos"
:key="logo"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
</div>
</div>

View File

@@ -75,7 +75,7 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
<!-- Progress bar -->
<div class="h-1 flex-1 rounded-full bg-white/20">
<div
class="bg-primary-comfy-yellow h-full rounded-full transition-all duration-200"
class="bg-primary-comfy-yellow h-full rounded-full"
:style="{ width: progressPercent }"
/>
</div>

View File

@@ -5,6 +5,7 @@ import { useHeroAnimation } from '../../composables/useHeroAnimation'
import SectionLabel from '../common/SectionLabel.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { ScrollTrigger } from '../../scripts/gsapSetup'
import VideoPlayer from '../common/VideoPlayer.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -22,6 +23,10 @@ useHeroAnimation({
logo: logoRef,
video: videoRef
})
function handleLogoLoad() {
ScrollTrigger.refresh(true)
}
</script>
<template>
@@ -37,7 +42,10 @@ useHeroAnimation({
<img
src="https://media.comfy.org/website/customers/c-projection.webp"
alt="Comfy 3D logo"
class="mx-auto w-full max-w-md lg:max-w-none"
width="1568"
height="1763"
class="mx-auto h-auto w-full max-w-md lg:max-w-none"
@load="handleLogoLoad"
/>
</div>

View File

@@ -8,10 +8,12 @@ import { t } from '../../i18n/translations'
const {
arcadeId,
title,
aspectRatio = 16 / 9,
locale = 'en'
} = defineProps<{
arcadeId: string
title: string
aspectRatio?: number
locale?: Locale
}>()
@@ -24,7 +26,8 @@ const loaded = ref(false)
:aria-label="t('demos.embed.label', locale)"
>
<div
class="relative mx-auto aspect-video max-w-6xl overflow-hidden rounded-4xl border border-white/10"
class="relative mx-auto max-w-6xl overflow-hidden rounded-4xl border border-white/10"
:style="{ aspectRatio }"
>
<div
v-if="!loaded"

View File

@@ -25,7 +25,7 @@ const categories: Category[] = [
{
label: t('useCase.vfx', locale),
leftSrc: 'https://media.comfy.org/website/homepage/use-case/left1.webm',
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webp'
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webm'
},
{
label: t('useCase.advertising', locale),

View File

@@ -276,29 +276,6 @@ onUnmounted(() => {
fill="#211927"
/>
</g>
<!-- Left-edge fade -->
<rect
x="300"
y="150"
width="250"
height="900"
fill="url(#localHeroFadeLeft)"
/>
<defs>
<linearGradient
id="localHeroFadeLeft"
x1="550"
y1="600"
x2="300"
y2="600"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#211927" stop-opacity="0" />
<stop offset="1" stop-color="#211927" />
</linearGradient>
</defs>
</svg>
</div>

View File

@@ -15,6 +15,14 @@ interface Demo {
readonly transcript?: TranslationKey
readonly publishedDate: string
readonly modifiedDate: string
/**
* Width / height of the Arcade demo's source recording (e.g. 1.93 for a
* landscape screencast). Sizes the embed container to match so rounded
* corners hug the content instead of empty letterbox space. Source from
* Arcade's `_serializablePublicFlow.aspectRatio` (which is height/width —
* invert it). Defaults to 16/9 if omitted.
*/
readonly aspectRatio?: number
}
export const demos: readonly Demo[] = [
@@ -32,7 +40,8 @@ export const demos: readonly Demo[] = [
difficulty: 'beginner',
tags: ['templates', 'image', 'video'],
publishedDate: '2026-04-19',
modifiedDate: '2026-04-19'
modifiedDate: '2026-04-19',
aspectRatio: 1.931
},
{
slug: 'workflow-templates',
@@ -48,7 +57,25 @@ export const demos: readonly Demo[] = [
difficulty: 'beginner',
tags: ['getting-started', 'templates', 'workflow'],
publishedDate: '2026-04-19',
modifiedDate: '2026-04-19'
modifiedDate: '2026-04-19',
aspectRatio: 1.931
},
{
slug: 'community-workflows',
arcadeId: 'mqZh17oWDuWIyhK0xwEV',
category: 'demos.category.gettingStarted',
title: 'demos.community-workflows.title',
description: 'demos.community-workflows.description',
transcript: 'demos.community-workflows.transcript',
ogImage: '/images/demos/community-workflows-og.png',
thumbnail: '/images/demos/community-workflows-thumb.webp',
estimatedTime: 'demos.duration.2min',
durationIso: 'PT2M',
difficulty: 'beginner',
tags: ['getting-started', 'community', 'workflow', 'hub'],
publishedDate: '2026-05-04',
modifiedDate: '2026-05-04',
aspectRatio: 1.931
}
]

View File

@@ -3570,6 +3570,20 @@ const translations = {
'<ol><li><strong>打开模板浏览器</strong> — 点击 ComfyUI 侧栏中的模板图标。</li><li><strong>浏览分类</strong> — 模板按任务分类:图像生成、视频、放大等。</li><li><strong>预览模板</strong> — 将鼠标悬停在模板上查看预览。</li><li><strong>加载并自定义</strong> — 点击加载模板,然后修改参数。</li></ol>'
},
'demos.community-workflows.title': {
en: 'Explore and Use a Community Workflow from the Hub',
'zh-CN': '探索并使用社区工作流'
},
'demos.community-workflows.description': {
en: 'Discover how to find and get started with popular community workflows for generative AI projects.',
'zh-CN': '了解如何查找并使用流行的社区工作流来构建生成式 AI 项目。'
},
'demos.community-workflows.transcript': {
en: '<ol><li><strong>Open the Workflow Hub</strong> — From the ComfyUI sidebar, navigate to the community Workflow Hub to browse curated and trending workflows shared by the community.</li><li><strong>Browse popular workflows</strong> — Explore featured projects sorted by popularity, recency, and category to find one that matches your goal.</li><li><strong>Preview a workflow</strong> — Click a workflow card to see example outputs, required models, and a description of what it produces.</li><li><strong>Open in ComfyUI</strong> — Use the "Get Started" action to load the selected community workflow directly onto your canvas.</li><li><strong>Run and customize</strong> — Queue the workflow to generate your first result, then tweak prompts, models, and parameters to make it your own.</li></ol>',
'zh-CN':
'<ol><li><strong>打开工作流中心</strong> — 在 ComfyUI 侧栏中,进入社区工作流中心,浏览社区分享的精选和热门工作流。</li><li><strong>浏览热门工作流</strong> — 按热度、时间和分类浏览精选项目,找到符合需求的工作流。</li><li><strong>预览工作流</strong> — 点击工作流卡片,查看示例输出、所需模型和功能描述。</li><li><strong>在 ComfyUI 中打开</strong> — 使用"开始使用"按钮,将选中的社区工作流直接加载到画布。</li><li><strong>运行并自定义</strong> — 排队执行工作流以生成首个结果,然后调整提示词、模型和参数。</li></ol>'
},
'demos.nav.nextDemo': { en: "What's Next", 'zh-CN': '下一个演示' },
'demos.nav.viewDemo': { en: 'View Demo', 'zh-CN': '查看演示' },
'demos.nav.allDemos': { en: 'All Demos', 'zh-CN': '所有演示' },

View File

@@ -121,6 +121,7 @@ const breadcrumbJsonLd = {
<ArcadeEmbed
arcadeId={demo.arcadeId}
title={title}
aspectRatio={demo.aspectRatio}
client:load
/>

View File

@@ -122,6 +122,7 @@ const breadcrumbJsonLd = {
<ArcadeEmbed
arcadeId={demo.arcadeId}
title={title}
aspectRatio={demo.aspectRatio}
locale="zh-CN"
client:load
/>

View File

@@ -101,13 +101,13 @@
transform: translateX(0);
}
100% {
transform: translateX(-50%);
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
}
}
@keyframes marquee-reverse {
0% {
transform: translateX(-50%);
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
}
100% {
transform: translateX(0);
@@ -115,11 +115,15 @@
}
@utility animate-marquee {
animation: marquee 30s linear infinite;
@media (prefers-reduced-motion: no-preference) {
animation: marquee 30s linear infinite;
}
}
@utility animate-marquee-reverse {
animation: marquee-reverse 30s linear infinite;
@media (prefers-reduced-motion: no-preference) {
animation: marquee-reverse 30s linear infinite;
}
}
@keyframes ripple-effect {

View File

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

View File

@@ -190,6 +190,9 @@ export class ComfyPage {
/** Worker index to test user ID */
public readonly userIds: string[] = []
/** Whether the current test runs in Vue Nodes mode (initialized from `@vue-nodes` tag). */
public isVueNodes = false
/** Test user ID for the current context */
get id() {
return this.userIds[comfyPageFixture.info().parallelIndex]
@@ -500,6 +503,7 @@ export const comfyPageFixture = base.extend<{
comfyPage.userIds[parallelIndex] = userId
const isVueNodes = testInfo.tags.includes('@vue-nodes')
comfyPage.isVueNodes = isVueNodes
try {
await comfyPage.setupSettings({

View File

@@ -20,6 +20,7 @@ export class ContextMenu {
async clickMenuItemExact(name: string): Promise<void> {
await this.page.getByRole('menuitem', { name, exact: true }).click()
await this.waitForHidden()
}
/**

View File

@@ -95,7 +95,6 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
public readonly allTab: Locator
public readonly blueprintsTab: Locator
public readonly sortButton: Locator
public readonly nodePreview: Locator
constructor(public override readonly page: Page) {
super(page, 'node-library')
@@ -104,7 +103,6 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
this.allTab = this.getTab('All')
this.blueprintsTab = this.getTab('Blueprints')
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
}
getTab(name: string) {

View File

@@ -82,7 +82,7 @@ export class Topbar {
}
getSaveDialog(): Locator {
return this.page.locator('.p-dialog-content input')
return this.page.getByRole('dialog').getByRole('textbox')
}
saveWorkflow(workflowName: string): Promise<void> {
@@ -116,9 +116,9 @@ export class Topbar {
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
// If so, return early to let the test handle the confirmation
const confirmationDialog = this.page.locator(
'.p-dialog:has-text("Overwrite")'
)
const confirmationDialog = this.page
.getByRole('dialog')
.filter({ hasText: 'Overwrite' })
if (await confirmationDialog.isVisible()) {
return
}

View File

@@ -0,0 +1,12 @@
import type { Locator } from '@playwright/test'
export class WidgetSelectDropdownFixture {
public readonly selection: Locator
constructor(public readonly root: Locator) {
this.selection = root.locator('button span span')
}
async selectedItem(): Promise<string> {
return await this.selection.innerText()
}
}

View File

@@ -9,13 +9,15 @@ import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
import { BuilderSelectHelper } from '@e2e/fixtures/helpers/BuilderSelectHelper'
import { BuilderStepsHelper } from '@e2e/fixtures/helpers/BuilderStepsHelper'
import { MobileAppHelper } from '@e2e/fixtures/helpers/MobileAppHelper'
export class AppModeHelper {
readonly steps: BuilderStepsHelper
readonly footer: BuilderFooterHelper
readonly mobile: MobileAppHelper
readonly saveAs: BuilderSaveAsHelper
readonly select: BuilderSelectHelper
readonly outputHistory: OutputHistoryComponent
readonly steps: BuilderStepsHelper
readonly widgets: AppModeWidgetHelper
/** The "Connect an output" popover shown when saving without outputs. */
@@ -60,13 +62,16 @@ export class AppModeHelper {
public readonly vueNodeSwitchDismissButton: Locator
/** The "Don't show again" checkbox inside the Vue Node switch popup. */
public readonly vueNodeSwitchDontShowAgainCheckbox: Locator
/** The main content area where outputs are displayed*/
public readonly centerPanel: Locator
constructor(private readonly comfyPage: ComfyPage) {
this.steps = new BuilderStepsHelper(comfyPage)
this.mobile = new MobileAppHelper(comfyPage)
this.footer = new BuilderFooterHelper(comfyPage)
this.saveAs = new BuilderSaveAsHelper(comfyPage)
this.select = new BuilderSelectHelper(comfyPage)
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
this.steps = new BuilderStepsHelper(comfyPage)
this.widgets = new AppModeWidgetHelper(comfyPage)
this.connectOutputPopover = this.page.getByTestId(
@@ -125,6 +130,7 @@ export class AppModeHelper {
this.vueNodeSwitchDontShowAgainCheckbox = this.page.getByTestId(
TestIds.appMode.vueNodeSwitchDontShowAgain
)
this.centerPanel = this.page.getByTestId(TestIds.linear.centerPanel)
}
private get page(): Page {

View File

@@ -127,9 +127,7 @@ export class BuilderSelectHelper {
await popoverTrigger.click()
await this.page.getByText('Rename', { exact: true }).click()
const dialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
const dialogInput = this.page.getByRole('dialog').getByRole('textbox')
await dialogInput.fill(newName)
await this.page.keyboard.press('Enter')
await dialogInput.waitFor({ state: 'hidden' })

View File

@@ -6,71 +6,6 @@ import type { Locator, Page } from '@playwright/test'
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
function readFilePayload(filePath: string) {
const buffer = readFileSync(filePath)
const bufferArray = [...new Uint8Array(buffer)]
const fileName = basename(filePath)
const fileType = getMimeType(fileName)
return { bufferArray, fileName, fileType }
}
async function dispatchFilePaste(
page: Page,
payload: ReturnType<typeof readFilePayload>
): Promise<void> {
await page.evaluate(({ bufferArray, fileName, fileType }) => {
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const target = document.activeElement ?? document
target.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
)
}, payload)
}
async function interceptNextFilePaste(
page: Page,
payload: ReturnType<typeof readFilePayload>
): Promise<void> {
await page.evaluate(({ bufferArray, fileName, fileType }) => {
document.addEventListener(
'paste',
(e: ClipboardEvent) => {
e.preventDefault()
e.stopImmediatePropagation()
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
document.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
)
},
{ capture: true, once: true }
)
}, payload)
}
type PasteFileOptions = {
mode?: 'keyboard' | 'direct'
}
export class ClipboardHelper {
constructor(
private readonly keyboard: KeyboardHelper,
@@ -85,20 +20,43 @@ export class ClipboardHelper {
await this.keyboard.ctrlSend('KeyV', locator ?? null)
}
async pasteFile(
filePath: string,
{ mode = 'keyboard' }: PasteFileOptions = {}
): Promise<void> {
const payload = readFilePayload(filePath)
async pasteFile(filePath: string): Promise<void> {
const buffer = readFileSync(filePath)
const bufferArray = [...new Uint8Array(buffer)]
const fileName = basename(filePath)
const fileType = getMimeType(fileName)
if (mode === 'keyboard') {
await interceptNextFilePaste(this.page, payload)
await this.paste()
return
}
// Register a one-time capturing-phase listener that intercepts the next
// paste event and injects file data onto clipboardData.
await this.page.evaluate(
({ bufferArray, fileName, fileType }) => {
document.addEventListener(
'paste',
(e: ClipboardEvent) => {
e.preventDefault()
e.stopImmediatePropagation()
// Browser clipboard APIs cannot reliably seed arbitrary files in tests.
// Dispatch the app-level paste event with file clipboardData directly.
await dispatchFilePaste(this.page, payload)
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const syntheticEvent = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
document.dispatchEvent(syntheticEvent)
},
{ capture: true, once: true }
)
},
{ bufferArray, fileName, fileType }
)
// Trigger a real Ctrl+V keystroke — the capturing listener above will
// intercept it and re-dispatch with file data attached.
await this.paste()
}
}

View File

@@ -1,4 +1,5 @@
import { readFileSync } from 'fs'
import { basename } from 'path'
import type { Page } from '@playwright/test'
@@ -13,6 +14,7 @@ export class DragDropHelper {
async dragAndDropExternalResource(
options: {
fileName?: string
filePath?: string
url?: string
dropPosition?: Position
waitForUpload?: boolean
@@ -22,13 +24,14 @@ export class DragDropHelper {
const {
dropPosition = { x: 100, y: 100 },
fileName,
filePath,
url,
waitForUpload = false,
preserveNativePropagation = false
} = options
if (!fileName && !url)
throw new Error('Must provide either fileName or url')
if (!fileName && !filePath && !url)
throw new Error('Must provide fileName, filePath, or url')
const evaluateParams: {
dropPosition: Position
@@ -39,12 +42,22 @@ export class DragDropHelper {
preserveNativePropagation: boolean
} = { dropPosition, preserveNativePropagation }
if (fileName) {
const filePath = assetPath(fileName)
const buffer = readFileSync(filePath)
if (fileName || filePath) {
const resolvedPath = filePath ?? assetPath(fileName!)
const displayName = fileName ?? basename(resolvedPath)
let buffer: Buffer
try {
buffer = readFileSync(resolvedPath)
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(
`Failed to read drag-and-drop fixture at "${resolvedPath}": ${reason}`,
{ cause: error }
)
}
evaluateParams.fileName = fileName
evaluateParams.fileType = getMimeType(fileName)
evaluateParams.fileName = displayName
evaluateParams.fileType = getMimeType(displayName)
evaluateParams.buffer = [...new Uint8Array(buffer)]
}
@@ -148,6 +161,13 @@ export class DragDropHelper {
return this.dragAndDropExternalResource({ fileName, ...options })
}
async dragAndDropFilePath(
filePath: string,
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
): Promise<void> {
return this.dragAndDropExternalResource({ filePath, ...options })
}
async dragAndDropURL(
url: string,
options: {

View File

@@ -1,6 +1,10 @@
import type { WebSocketRoute } from '@playwright/test'
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
import type {
NodeError,
NodeProgressState,
PromptResponse
} from '@/schemas/apiSchema'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
@@ -230,6 +234,16 @@ export class ExecutionHelper {
)
}
/** Send `progress_state` WS event with per-node execution state. */
progressState(jobId: string, nodes: Record<string, NodeProgressState>): void {
this.requireWs().send(
JSON.stringify({
type: 'progress_state',
data: { prompt_id: jobId, nodes }
})
)
}
/**
* Complete a job by adding it to mock history, sending execution_success,
* and triggering a history refresh via a status event.

View File

@@ -0,0 +1,33 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
export class MobileAppHelper {
private readonly page: Page
readonly contentPanel: Locator
readonly navigation: Locator
readonly navigationTabs: Locator
readonly view: Locator
readonly workflows: Locator
constructor(comfyPage: ComfyPage) {
this.page = comfyPage.page
this.view = this.page.getByTestId(TestIds.linear.mobile)
this.contentPanel = this.page.getByRole('tabpanel')
this.navigation = this.page.getByRole('tablist').filter({ hasText: 'Run' })
this.navigationTabs = this.navigation.getByRole('tab')
this.workflows = this.view.getByTestId(TestIds.linear.mobileWorkflows)
}
async switchWorkflow(workflowName: string) {
await this.workflows.click()
await this.page.getByRole('menu').getByText(workflowName).click()
}
async navigateTab(name: 'run' | 'outputs' | 'assets') {
await this.navigation.getByRole('tab', { name }).click()
}
async tap(locator: Locator, { count = 1 }: { count?: number } = {}) {
for (let i = 0; i < count; i++) await locator.tap()
}
}

View File

@@ -18,9 +18,7 @@ export class NodeOperationsHelper {
public readonly promptDialogInput: Locator
constructor(private comfyPage: ComfyPage) {
this.promptDialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
this.promptDialogInput = this.page.getByRole('dialog').getByRole('textbox')
}
private get page() {

View File

@@ -362,6 +362,9 @@ export class SubgraphHelper {
await this.comfyPage.nextFrame()
await expect.poll(async () => this.isInSubgraph()).toBe(false)
if (this.comfyPage.isVueNodes) {
await this.comfyPage.vueNodes.waitForNodes()
}
}
async countGraphPseudoPreviewEntries(): Promise<number> {

View File

@@ -8,7 +8,6 @@ export const TestIds = {
toolbar: 'side-toolbar',
nodeLibrary: 'node-library-tree',
nodeLibrarySearch: 'node-library-search',
nodePreviewCard: 'node-preview-card',
workflows: 'workflows-sidebar',
modeToggle: 'mode-toggle'
},
@@ -76,15 +75,7 @@ export const TestIds = {
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',
updatePassword: 'update-password-dialog',
cloudNotification: 'cloud-notification-dialog',
openSharedWorkflow: 'open-shared-workflow-dialog',
openSharedWorkflowTitle: 'open-shared-workflow-title',
openSharedWorkflowClose: 'open-shared-workflow-close',
openSharedWorkflowErrorClose: 'open-shared-workflow-error-close',
openSharedWorkflowCancel: 'open-shared-workflow-cancel',
openSharedWorkflowOpenWithoutImporting:
'open-shared-workflow-open-without-importing',
openSharedWorkflowConfirm: 'open-shared-workflow-confirm'
cloudNotification: 'cloud-notification-dialog'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -153,6 +144,14 @@ export const TestIds = {
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
},
linear: {
centerPanel: 'linear-center-panel',
mobile: 'linear-mobile',
mobileNavigation: 'linear-mobile-navigation',
mobileWorkflows: 'linear-mobile-workflows',
outputInfo: 'linear-output-info',
widgetContainer: 'linear-widgets'
},
builder: {
footerNav: 'builder-footer-nav',
saveButton: 'builder-save-button',

View File

@@ -1,250 +0,0 @@
import { test as base } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
Asset,
ImportPublishedAssetsRequest,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import type { z } from 'zod'
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
import type { AssetInfo } from '@/schemas/apiSchema'
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
export const sharedWorkflowImportScenario = {
shareId: 'shared-missing-media-e2e',
workflowId: 'shared-missing-media-workflow',
publishedAssetId: 'published-input-asset-1',
inputFileName: 'shared_imported_image.png'
} as const
export type SharedWorkflowRequestEvent =
| 'import'
| 'input-assets-including-public-before-import'
| 'input-assets-including-public-after-import'
export interface SharedWorkflowImportMocks {
resetAndStartRecording: () => void
getImportBody: () => ImportPublishedAssetsRequest | undefined
getRequestEvents: () => SharedWorkflowRequestEvent[]
waitForPublicInclusiveInputAssetResponseAfterImport: () => Promise<void>
}
const defaultInputFileName = '00000000000000000000000Aexample.png'
const sharedWorkflowAsset: AssetInfo = {
id: sharedWorkflowImportScenario.publishedAssetId,
name: sharedWorkflowImportScenario.inputFileName,
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
}
const defaultInputAsset: Asset = {
id: 'default-input-asset',
name: defaultInputFileName,
asset_hash: defaultInputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const importedInputAsset: Asset = {
id: 'imported-input-asset',
name: sharedWorkflowImportScenario.inputFileName,
asset_hash: sharedWorkflowImportScenario.inputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const sharedWorkflowResponse: SharedWorkflowResponse = {
share_id: sharedWorkflowImportScenario.shareId,
workflow_id: sharedWorkflowImportScenario.workflowId,
name: 'Shared Missing Media Workflow',
listed: true,
publish_time: '2026-05-01T00:00:00Z',
workflow_json: {
version: 0.4,
last_node_id: 10,
last_link_id: 0,
nodes: [
{
id: 10,
type: 'LoadImage',
pos: [50, 200],
size: [315, 314],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [
{
name: 'IMAGE',
type: 'IMAGE',
links: null
},
{
name: 'MASK',
type: 'MASK',
links: null
}
],
properties: {
'Node name for S&R': 'LoadImage'
},
widgets_values: [sharedWorkflowImportScenario.inputFileName, 'image']
}
],
links: [],
groups: [],
config: {},
extra: {
ds: {
offset: [0, 0],
scale: 1
}
}
},
assets: [sharedWorkflowAsset]
}
export const sharedWorkflowImportFixture = base.extend<{
sharedWorkflowImportMocks: SharedWorkflowImportMocks
}>({
sharedWorkflowImportMocks: async ({ page }, use) => {
const mocks = await mockSharedWorkflowImportFlow(page)
await use(mocks)
}
})
async function mockSharedWorkflowImportFlow(
page: Page
): Promise<SharedWorkflowImportMocks> {
let isRecording = false
let importEndpointCalled = false
let importBody: ImportPublishedAssetsRequest | undefined
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void = () => {}
let publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
const requestEvents: SharedWorkflowRequestEvent[] = []
function resetPublicInclusiveInputAssetResponseWaiter() {
publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
}
function recordRequestEvent(event: SharedWorkflowRequestEvent) {
if (isRecording) requestEvents.push(event)
}
await page.route(
`**/workflows/published/${sharedWorkflowImportScenario.shareId}`,
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(sharedWorkflowResponse)
})
}
)
await page.route('**/api/assets/import', async (route) => {
recordRequestEvent('import')
importBody = route.request().postDataJSON() as ImportPublishedAssetsRequest
importEndpointCalled = true
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
})
// Excludes `/api/assets/import` so the specific route above
// remains isolated from the general asset listing mock.
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
const url = new URL(route.request().url())
const includeTags = getTagParam(url, 'include_tags')
const isInputAssetRequest = includeTags.includes('input')
const includesPublicAssets =
url.searchParams.get('include_public') === 'true'
const isPublicInclusiveInputAssetRequest =
isInputAssetRequest && includesPublicAssets
const isAfterImportPublicInclusiveInputAssetRequest =
isPublicInclusiveInputAssetRequest && importEndpointCalled
if (isPublicInclusiveInputAssetRequest) {
recordRequestEvent(
importEndpointCalled
? 'input-assets-including-public-after-import'
: 'input-assets-including-public-before-import'
)
}
const allAssets = [
defaultInputAsset,
...(importEndpointCalled ? [importedInputAsset] : [])
]
const assets = includeTags.length
? allAssets.filter((asset) =>
includeTags.every((tag) => asset.tags?.includes(tag))
)
: allAssets
const response: ListAssetsResponse = {
assets,
total: assets.length,
has_more: false
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
if (isAfterImportPublicInclusiveInputAssetRequest) {
resolvePublicInclusiveInputAssetResponseAfterImport()
}
})
return {
resetAndStartRecording: () => {
isRecording = true
importEndpointCalled = false
importBody = undefined
requestEvents.length = 0
resetPublicInclusiveInputAssetResponseWaiter()
},
getImportBody: () => importBody,
getRequestEvents: () => [...requestEvents],
waitForPublicInclusiveInputAssetResponseAfterImport: () =>
publicInclusiveInputAssetResponseAfterImport
}
}
function getTagParam(url: URL, key: string): string[] {
return (
url.searchParams
.get(key)
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}

View File

@@ -7,6 +7,9 @@ export function getMimeType(fileName: string): string {
if (name.endsWith('.avif')) return 'image/avif'
if (name.endsWith('.webm')) return 'video/webm'
if (name.endsWith('.mp4')) return 'video/mp4'
if (name.endsWith('.mp3')) return 'audio/mpeg'
if (name.endsWith('.flac')) return 'audio/flac'
if (name.endsWith('.ogg') || name.endsWith('.opus')) return 'audio/ogg'
if (name.endsWith('.json')) return 'application/json'
if (name.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'

View File

@@ -1,3 +1,7 @@
export function assetPath(fileName: string): string {
return `./browser_tests/assets/${fileName}`
}
export function metadataFixturePath(fileName: string): string {
return `./src/scripts/metadata/__fixtures__/${fileName}`
}

View File

@@ -1,28 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export async function openMoreOptionsMenu(
comfyPage: ComfyPage,
nodeTitle: string
) {
const nodes = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
if (nodes.length === 0) {
throw new Error(`No "${nodeTitle}" nodes found`)
}
await nodes[0].centerOnNode()
await nodes[0].click('title')
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu).toBeVisible()
return menu
}

View File

@@ -13,6 +13,8 @@ export class VueNodeFixture {
public readonly collapseButton: Locator
public readonly collapseIcon: Locator
public readonly root: Locator
public readonly widgets: Locator
public readonly imagePreview: Locator
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
@@ -23,6 +25,8 @@ export class VueNodeFixture {
this.collapseButton = locator.getByTestId('node-collapse-button')
this.collapseIcon = this.collapseButton.locator('i')
this.root = locator
this.widgets = this.locator.locator('.lg-node-widget')
this.imagePreview = locator.locator('.image-preview')
}
async getTitle(): Promise<string> {
@@ -39,6 +43,16 @@ export class VueNodeFixture {
await this.collapseButton.click()
}
/**
* Select this node and delete it via the Delete key, waiting for the node
* element to leave the DOM before resolving.
*/
async delete(): Promise<void> {
await this.header.click()
await this.header.press('Delete')
await this.locator.waitFor({ state: 'hidden' })
}
async getCollapseIconClass(): Promise<string> {
return (await this.collapseIcon.getAttribute('class')) ?? ''
}

View File

@@ -0,0 +1,154 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
test.describe('App mode usage', () => {
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
const { centerPanel } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(centerPanel, 'Enter app mode').toBeVisible()
//an app without an image input will load the workflow
await test.step('App without an image input loads workflow', async () => {
await comfyPage.dragDrop.dragAndDropFile('workflowInMedia/workflow.webp')
await expect(centerPanel).toBeHidden()
})
//prep a load image
await test.step('Add a load image node', async () => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
const loadImage = await comfyPage.vueNodes.getNodeLocator('10')
await expect(loadImage).toBeVisible()
})
const imageInput = new WidgetSelectDropdownFixture(
comfyPage.appMode.linearWidgets.locator('.lg-node-widget')
)
await test.step('Enter app mode with image input', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
await expect(centerPanel).toBeVisible()
await expect(imageInput.root).toBeVisible()
})
await test.step('Dragging an image redirects to image input', async () => {
const initialImage = await imageInput.selectedItem()
await comfyPage.dragDrop.dragAndDropExternalResource({
fileName: 'workflow.webp',
filePath: './browser_tests/assets/workflowInMedia/workflow.webp',
preserveNativePropagation: true
})
comfyFiles.deleteAfterTest({ filename: 'workflow.webp', type: 'input' })
await expect(imageInput.selection).not.toHaveText(initialImage)
await expect(
centerPanel,
'A file with workflow should not open a new workflow'
).toBeVisible()
})
await test.step('Dragging a url redirects to image input', async () => {
const secondImage = await imageInput.selectedItem()
await comfyPage.dragDrop.dragAndDropURL('/assets/images/og-image.png', {
preserveNativePropagation: true
})
comfyFiles.deleteAfterTest({
filename: 'og-image.png',
type: 'input'
})
await expect(imageInput.selection).not.toHaveText(secondImage)
})
})
test('Widget Interaction', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([
['3', 'seed'],
['3', 'sampler_name'],
['6', 'text']
])
const seed = comfyPage.appMode.linearWidgets.getByLabel('seed', {
exact: true
})
const { input, incrementButton, decrementButton } =
comfyPage.vueNodes.getInputNumberControls(seed)
const initialValue = Number(await input.inputValue())
await seed.dragTo(incrementButton, { steps: 5 })
const intermediateValue = Number(await input.inputValue())
expect(intermediateValue).toBeGreaterThan(initialValue)
await seed.dragTo(decrementButton, { steps: 5 })
const endValue = Number(await input.inputValue())
expect(endValue).toBeLessThan(intermediateValue)
const sampler = comfyPage.appMode.linearWidgets.getByLabel('sampler_name', {
exact: true
})
await sampler.click()
await comfyPage.page.getByRole('searchbox').fill('uni')
await comfyPage.page.keyboard.press('ArrowDown')
await comfyPage.page.keyboard.press('Enter')
await expect(sampler).toHaveText('uni_pc')
//verify values are consistent with litegraph
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'steps']])
await expect(mobile.view).toBeVisible()
await expect(mobile.navigation).toBeVisible()
await mobile.navigateTab('assets')
await expect(mobile.contentPanel).toHaveAccessibleName('Assets')
const buttons = await mobile.navigationTabs.all()
await buttons[0].dragTo(buttons[2], { steps: 5 })
await expect(mobile.contentPanel).toHaveAccessibleName('Outputs')
await mobile.navigateTab('run')
await expect(comfyPage.appMode.linearWidgets).toBeInViewport({ ratio: 1 })
const steps = comfyPage.page.getByRole('spinbutton')
const initialValue = Number(await steps.inputValue())
await mobile.tap(
comfyPage.page.getByRole('button', { name: 'increment' }),
{ count: 5 }
)
await expect(steps).toHaveValue(String(initialValue + 5))
await mobile.tap(
comfyPage.page.getByRole('button', { name: 'decrement' }),
{ count: 3 }
)
await expect(steps).toHaveValue(String(initialValue + 2))
})
test('workflow selection', async ({ comfyPage }) => {
const widgetNames = ['seed', 'steps', 'denoise', 'cfg']
for (const name of widgetNames)
await comfyPage.appMode.enterAppModeWithInputs([['3', name]])
await expect(comfyPage.appMode.mobile.workflows).toBeVisible()
const widgets = comfyPage.appMode.linearWidgets
await comfyPage.appMode.mobile.navigateTab('run')
for (let i = 0; i < widgetNames.length; i++) {
await comfyPage.appMode.mobile.switchWorkflow(`(${i + 2})`)
await expect(widgets.getByText(widgetNames[i])).toBeVisible()
}
})
})
})

View File

@@ -0,0 +1,121 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
test.describe('App mode builder selection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
})
test('Can independently select inputs of same name', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const items = comfyPage.appMode.select.inputItems
await comfyPage.vueNodes.selectNodes(['6', '7'])
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
const prompts = comfyPage.vueNodes
.getNodeByTitle('New Subgraph')
.locator('.lg-node-widget')
const count = await prompts.count()
for (let i = 0; i < count; i++) {
await expect(prompts.nth(i)).toBeVisible()
await prompts.nth(i).click()
await expect(items).toHaveCount(i + 1)
}
})
test('Can select outputs', async ({ comfyPage }) => {
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToOutputs()
await comfyPage.nodeOps
.getNodeRefById('9')
.then((ref) => ref.centerOnNode())
const saveImage = await comfyPage.vueNodes.getNodeLocator('9')
await saveImage.click()
const items = comfyPage.appMode.select.inputItems
await expect(items).toHaveCount(1)
})
test('Can not select nodes with errors or notes', async ({ comfyPage }) => {
//Manually set error state on checkpoint loader
//Shouldn't be needed on ci, but has spotty reliability
await comfyPage.page.evaluate(() => (graph!.nodes[6].has_errors = true))
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const items = comfyPage.appMode.select.inputItems
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
await comfyPage.appMode.select.selectInputWidget(
'Load Checkpoint',
'ckpt_name'
)
await expect(items).toHaveCount(0)
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
await comfyPage.appMode.select.selectInputWidget('Note', 'text')
await comfyPage.appMode.select.selectInputWidget('Markdown Note', 'text')
await expect(items).toHaveCount(0)
})
test('Marks canvas readOnly', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Canvas is initially editable'
).toHaveCount(1)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Entering builder makes the canvas readonly'
).toHaveCount(0)
await comfyPage.page.keyboard.press('Space')
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Canvas remains readonly after pressing space'
).toHaveCount(0)
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
await ksampler.header.dblclick({ force: true })
await expect(
ksampler.titleEditor.input,
'Double clicking node titles will not initiate a rename'
).toBeHidden()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Canvas is no longer readonly after exiting'
).toHaveCount(1)
})
})

View File

@@ -229,9 +229,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
// The dialog appearing proves the keybinding was intercepted by the app.
await comfyPage.keyboard.press('Control+s')
// The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible()
// The Save As dialog should appear
const saveDialog = comfyPage.page.getByRole('dialog')
await expect(saveDialog).toBeVisible()
// Dismiss the dialog
await comfyPage.keyboard.press('Escape')

View File

@@ -16,9 +16,9 @@ async function saveAndOpenPublishDialog(
workflowName: string
): Promise<void> {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const overwriteDialog = comfyPage.page.locator(
'.p-dialog:has-text("Overwrite")'
)
const overwriteDialog = comfyPage.page
.getByRole('dialog')
.filter({ hasText: 'Overwrite' })
// Bounded wait: point-in-time isVisible() can miss dialogs that open
// slightly after saveWorkflow() resolves.
try {

View File

@@ -1,47 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
// Regression test for https://github.com/Comfy-Org/ComfyUI_frontend/issues/10563
//
// Pins the end-to-end cascade through createI18n + coreSettings defaultValue +
// GraphView watchEffect: when navigator.language base tag is unsupported (e.g.
// 'de-DE') and Comfy.Locale is unset (fresh-install state), sidebar labels
// must render translated strings, not literal i18n keys like
// 'sideToolbar.labels.assets'.
test.describe('i18n locale fallback', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.addInitScript(() => {
Object.defineProperty(navigator, 'language', {
value: 'de-DE',
configurable: true
})
Object.defineProperty(navigator, 'languages', {
value: ['de-DE', 'de'],
configurable: true
})
})
// Default sidebar size on small viewports hides labels; force normal so
// .side-bar-button-label is rendered for the assertion.
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.page.reload()
await comfyPage.waitForAppReady()
})
test('sidebar labels render translated strings, not raw i18n keys', async ({
comfyPage
}) => {
const { page } = comfyPage
await page.setViewportSize({ width: 1920, height: 1080 })
const labelTexts = await page
.getByTestId('side-toolbar')
.locator('.side-bar-button-label')
.allTextContents()
expect(labelTexts.length).toBeGreaterThan(0)
for (const text of labelTexts) {
expect(text).not.toContain('sideToolbar.labels')
}
})
})

View File

@@ -0,0 +1,62 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { metadataFixturePath } from '@e2e/fixtures/utils/paths'
type MetadataFixture = {
fileName: string
parser: string
}
// Each fixture embeds the same single-KSampler workflow (see
// scripts/generate-embedded-metadata-test-files.py), exercising a different
// parser in src/scripts/metadata/. Dropping the file should import that
// workflow.
const FIXTURES: readonly MetadataFixture[] = [
{ fileName: 'with_metadata.png', parser: 'png' },
{ fileName: 'with_metadata.avif', parser: 'avif' },
{ fileName: 'with_metadata.webp', parser: 'webp' },
{ fileName: 'with_metadata_exif_prefix.webp', parser: 'webp (exif prefix)' },
{ fileName: 'with_metadata.flac', parser: 'flac' },
{ fileName: 'with_metadata.mp3', parser: 'mp3' },
{ fileName: 'with_metadata.opus', parser: 'ogg' },
{ fileName: 'with_metadata.mp4', parser: 'isobmff' },
{ fileName: 'with_metadata.webm', parser: 'ebml (webm)' }
] as const
test.describe(
'Metadata drop-to-load workflow import',
{ tag: ['@workflow'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
})
for (const { fileName, parser } of FIXTURES) {
test(`loads embedded workflow from ${fileName} (${parser})`, async ({
comfyPage
}) => {
await test.step(`drop ${fileName} on canvas`, async () => {
await comfyPage.dragDrop.dragAndDropFilePath(
metadataFixturePath(fileName)
)
})
await test.step('graph contains only the embedded KSampler', async () => {
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(1)
const ksamplers =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
expect(
ksamplers,
'exactly one KSampler should have been loaded from the fixture'
).toHaveLength(1)
})
})
}
}
)

View File

@@ -8,6 +8,9 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
const DEPRECATED_NODE_TYPE = 'ImageBatch'
const API_NODE_TYPE = 'FluxProUltraImageNode'
test.describe('Node Badge', { tag: ['@screenshot', '@smoke', '@node'] }, () => {
test('Can add badge', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
@@ -141,3 +144,73 @@ test.describe(
})
}
)
for (const vueEnabled of [false, true] as const) {
const renderer = vueEnabled ? 'vue' : 'classic'
const tag = vueEnabled
? ['@vue-nodes', '@screenshot', '@node']
: ['@screenshot', '@node']
test.describe(`Node lifecycle badge (${renderer})`, { tag }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
})
for (const mode of [NodeBadgeMode.ShowAll, NodeBadgeMode.None] as const) {
test(`renders deprecated node with mode=${mode}`, async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.NodeBadge.NodeLifeCycleBadgeMode',
mode
)
await comfyPage.nodeOps.clearGraph()
await comfyPage.nodeOps.addNode(DEPRECATED_NODE_TYPE, undefined, {
x: 100,
y: 100
})
await comfyPage.canvasOps.resetView()
await expect(comfyPage.canvas).toHaveScreenshot(
`node-lifecycle-${mode}-${renderer}.png`
)
})
}
})
test.describe(`API pricing badge (${renderer})`, { tag }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
await comfyPage.page.evaluate((type) => {
const registered = window.LiteGraph!.registered_node_types[type] as {
nodeData?: { price_badge?: unknown }
}
if (!registered?.nodeData) throw new Error(`No nodeData for ${type}`)
registered.nodeData.price_badge = {
engine: 'jsonata',
expr: "{'type': 'text', 'text': '99.9 credits/Run'}",
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
}, API_NODE_TYPE)
})
for (const enabled of [true, false] as const) {
test(`renders api node with showApiPricing=${enabled}`, async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.NodeBadge.ShowApiPricing',
enabled
)
await comfyPage.nodeOps.clearGraph()
await comfyPage.nodeOps.addNode(API_NODE_TYPE, undefined, {
x: 100,
y: 100
})
await comfyPage.canvasOps.resetView()
await expect(comfyPage.canvas).toHaveScreenshot(
`api-pricing-${enabled ? 'on' : 'off'}-${renderer}.png`
)
})
}
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -1,65 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
test.describe(
'Node context menu shape submenu (FE-570)',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
async function expectShapePopoverVisible(comfyPage: ComfyPage) {
const popover = comfyPage.page
.locator('.p-popover')
.filter({ hasText: 'Default' })
await expect(popover).toBeVisible()
await expect(popover).toContainText('Box')
await expect(popover).toContainText('Card')
const popoverBox = await popover.boundingBox()
expect(popoverBox).not.toBeNull()
expect(popoverBox!.width).toBeGreaterThan(0)
expect(popoverBox!.height).toBeGreaterThan(0)
}
test('Shape popover opens when the menu fits in the viewport', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 900 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() => rootList.evaluate((el) => getComputedStyle(el).overflowY))
.toBe('visible')
await menu.getByRole('menuitem', { name: 'Shape' }).click()
await expectShapePopoverVisible(comfyPage)
})
test('Shape popover opens even when the menu must scroll', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() =>
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
)
.toBe(true)
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
await shapeItem.scrollIntoViewIfNeeded()
await shapeItem.click()
await expectShapePopoverVisible(comfyPage)
})
}
)

View File

@@ -692,19 +692,27 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
})
})
test('Controls collapse to single column in compact mode', async ({
test('Controls stack label above widget in compact mode', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const toolLabel = painterWidget.getByText('Tool', { exact: true })
const brushButton = painterWidget.getByText('Brush', { exact: true })
await expect(
toolLabel,
'tool label should be visible in two-column layout'
'tool label should be visible in wide layout'
).toBeVisible()
const wideLabelBox = await toolLabel.boundingBox()
const wideBrushBox = await brushButton.boundingBox()
expect(
wideLabelBox && wideBrushBox && wideLabelBox.x < wideBrushBox.x,
'label should sit to the left of the brush button in wide layout'
).toBe(true)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
const node = graph?._nodes_by_id?.['1']
@@ -716,8 +724,22 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect(
toolLabel,
'tool label should hide in compact single-column layout'
).toBeHidden()
'tool label should remain visible in compact layout'
).toBeVisible()
await expect
.poll(
async () => {
const labelBox = await toolLabel.boundingBox()
const brushBox = await brushButton.boundingBox()
if (!labelBox || !brushBox) return false
return labelBox.y + labelBox.height <= brushBox.y
},
{
message: 'label should stack above the brush button in compact layout'
}
)
.toBe(true)
})
test('Multiple sequential strokes at different positions all accumulate', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -54,44 +54,14 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
.toBe(initialCount - 1)
})
test('info button opens the right-side info tab in new menu mode', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
test('info button opens properties panel', async ({ comfyPage }) => {
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
const infoButton = comfyPage.page.getByTestId('info-button')
await expect(infoButton).toBeVisible()
await infoButton.click()
const panel = comfyPage.menu.propertiesPanel.root
await expect(panel).toBeVisible()
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
'aria-selected',
'true'
)
await expect(panel).toContainText('KSampler')
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
})
test('info button is hidden when the new menu is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
await expect(comfyPage.selectionToolbox).toBeVisible()
await expect(
comfyPage.selectionToolbox.getByTestId('info-button')
).toBeHidden()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
test('convert-to-subgraph button visible with multi-select', async ({

View File

@@ -2,7 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -19,19 +18,70 @@ test.describe(
await comfyPage.nextFrame()
})
const openMoreOptions = (comfyPage: ComfyPage) =>
openMoreOptionsMenu(comfyPage, 'KSampler')
const openMoreOptions = async (comfyPage: ComfyPage) => {
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')
}
test('hides Node Info from More Options menu when the new menu is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
// Drag the KSampler to the center of the screen
const nodePos = await ksamplerNodes[0].getPosition()
const viewportSize = comfyPage.page.viewportSize()
if (!viewportSize) {
throw new Error(
'Viewport size is null - page may not be properly initialized'
)
}
const centerX = viewportSize.width / 3
const centerY = viewportSize.height / 2
await comfyPage.canvasOps.dragAndDrop(
{ x: nodePos.x, y: nodePos.y },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisible = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisible) {
return
}
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisibleAfterClick = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisibleAfterClick) {
return
}
throw new Error('Could not open More Options menu - popover not showing')
}
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
await openMoreOptions(comfyPage)
const nodeInfoButton = comfyPage.page.getByRole('menuitem', {
name: 'Node Info'
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
exact: true
})
await expect(nodeInfoButton).toBeHidden()
await expect(nodeInfoButton).toBeVisible()
await nodeInfoButton.click()
await comfyPage.nextFrame()
})
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
@@ -40,14 +90,11 @@ test.describe(
)[0]
await openMoreOptions(comfyPage)
// Shape now opens via body-appended popover (FE-570); a hover no
// longer reveals the submenu — match the Color flow and click.
await comfyPage.page.getByText('Shape', { exact: true }).click()
const shapePopover = comfyPage.page
.locator('.p-popover')
.filter({ hasText: 'Default' })
await expect(shapePopover.getByText('Box', { exact: true })).toBeVisible()
await shapePopover.getByText('Box', { exact: true }).click()
await comfyPage.page.getByText('Shape', { exact: true }).hover()
await expect(
comfyPage.page.getByText('Box', { exact: true })
).toBeVisible()
await comfyPage.page.getByText('Box', { exact: true }).click()
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.getProperty<number>('shape')).toBe(1)

View File

@@ -1,148 +0,0 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
sharedWorkflowImportFixture,
sharedWorkflowImportScenario
} from '@e2e/fixtures/sharedWorkflowImportFixture'
import type { SharedWorkflowImportMocks } from '@e2e/fixtures/sharedWorkflowImportFixture'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { WorkspaceStore } from '@e2e/types/globals'
const IMPORT_ORDER_TIMEOUT_MS = 5_000
async function expectImportPrecedesPublicInclusiveInputAssetScan(
mocks: SharedWorkflowImportMocks
): Promise<void> {
await expect(async () => {
const events = mocks.getRequestEvents()
const importIndex = events.indexOf('import')
const afterImportIndex = events.indexOf(
'input-assets-including-public-after-import'
)
expect(
events,
'public-inclusive input assets must not be scanned before import'
).not.toContain('input-assets-including-public-before-import')
expect(importIndex, `events: ${events.join(',')}`).toBeGreaterThanOrEqual(0)
expect(afterImportIndex, `events: ${events.join(',')}`).toBeGreaterThan(
importIndex
)
}).toPass({ timeout: IMPORT_ORDER_TIMEOUT_MS })
}
async function getCachedMissingMediaWarningNames(
comfyPage: ComfyPage
): Promise<string[] | null> {
return await comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) return null
return (
workflow.pendingWarnings?.missingMediaCandidates?.map(
(candidate) => candidate.name
) ?? []
)
})
}
async function expectNoMissingMediaAfterPublicInclusiveAssetScan(
comfyPage: ComfyPage,
mocks: SharedWorkflowImportMocks
): Promise<void> {
await mocks.waitForPublicInclusiveInputAssetResponseAfterImport()
await comfyPage.nextFrame()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
await expect
.poll(() => getCachedMissingMediaWarningNames(comfyPage))
.toEqual([])
}
async function openPanelAndExpectNoMissingMedia(
comfyPage: ComfyPage
): Promise<void> {
const page = comfyPage.page
const errorOverlay = page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeHidden()
const panel = new PropertiesPanelHelper(page)
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
await expect(page.getByTestId(TestIds.dialogs.missingMediaGroup)).toHaveCount(
0
)
}
const test = mergeTests(comfyPageFixture, sharedWorkflowImportFixture)
test.describe('Shared workflow missing media', { tag: '@cloud' }, () => {
// Missing media only surfaces the overlay when the Errors tab is enabled
// (src/stores/executionErrorStore.ts).
test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
sharedWorkflowImportMocks.resetAndStartRecording()
await comfyPage.page.goto(
new URL(
`/?share=${sharedWorkflowImportScenario.shareId}`,
comfyPage.url
).toString()
)
await comfyPage.waitForAppReady()
})
test('imports shared media before loading workflow so missing media is not surfaced', async ({
comfyPage,
sharedWorkflowImportMocks
}) => {
const { page } = comfyPage
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
await expect(
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
await dialog.getByTestId(TestIds.dialogs.openSharedWorkflowConfirm).click()
await expect
.poll(() =>
page.evaluate(() =>
window.app!.graph.nodes.map((node) => ({
type: node.type,
value: node.widgets?.[0]?.value
}))
)
)
.toEqual([
{
type: 'LoadImage',
value: sharedWorkflowImportScenario.inputFileName
}
])
await expectImportPrecedesPublicInclusiveInputAssetScan(
sharedWorkflowImportMocks
)
await expectNoMissingMediaAfterPublicInclusiveAssetScan(
comfyPage,
sharedWorkflowImportMocks
)
expect(sharedWorkflowImportMocks.getImportBody()).toEqual({
published_asset_ids: [sharedWorkflowImportScenario.publishedAssetId],
share_id: sharedWorkflowImportScenario.shareId
})
expect(new URL(page.url()).searchParams.has('share')).toBe(false)
await openPanelAndExpectNoMissingMedia(comfyPage)
})
})

View File

@@ -120,13 +120,4 @@ test.describe('Node library sidebar V2', () => {
await expect(options.first()).toBeVisible()
await expect.poll(() => options.count()).toBeGreaterThanOrEqual(2)
})
test('Blueprint previews include description', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.blueprintsTab.click()
await tab.getNode('test blueprint').hover()
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
await expect(tab.nodePreview).toContainText('Inverts the image')
})
})

View File

@@ -558,5 +558,52 @@ test.describe(
.toBe(0)
})
})
test.fail(
'Promoted text widget is removed when source node is deleted inside the subgraph',
{ tag: '@vue-nodes' },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
const clipFixture = await comfyPage.vueNodes.getFixtureByTitle(
'CLIP Text Encode (Prompt)'
)
await comfyPage.contextMenu.openForVueNode(clipFixture.header)
await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
const subgraphNode = comfyPage.vueNodes
.getNodeByTitle('New Subgraph')
.first()
await expect(subgraphNode).toBeVisible()
const subgraphNodeId =
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
await expect
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
.toContain('text')
await expect(
subgraphNode.getByTestId(TestIds.widgets.domWidgetTextarea)
).toBeVisible()
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.vueNodes.waitForNodes()
const interiorClip = await comfyPage.vueNodes.getFixtureByTitle(
'CLIP Text Encode (Prompt)'
)
await interiorClip.delete()
await comfyPage.subgraph.exitViaBreadcrumb()
const subgraphNodeAfter =
comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(subgraphNodeAfter).toBeVisible()
await expect(
subgraphNodeAfter.getByTestId(TestIds.widgets.domWidgetTextarea)
).toBeHidden()
}
)
}
)

View File

@@ -106,54 +106,6 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(comfyPage.templates.content).toBeVisible()
})
test('dialog should not be shown when first-time user opens a shared workflow link', async ({
comfyPage
}) => {
await comfyPage.page.route(
'**/workflows/published/test-share-id',
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
share_id: 'test-share-id',
workflow_id: 'wf-1',
name: 'Shared Workflow',
listed: true,
publish_time: new Date().toISOString(),
workflow_json: {
version: 0.4,
nodes: [],
links: [],
groups: [],
config: {},
extra: {}
},
assets: []
})
})
}
)
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
await comfyPage.page.goto(`${comfyPage.url}/api/users`)
await comfyPage.page.evaluate((id) => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
}, comfyPage.id)
await comfyPage.page.goto(
new URL('/?share=test-share-id', comfyPage.url).toString()
)
await comfyPage.waitForAppReady()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
await expect(comfyPage.templates.content).toBeHidden()
})
test('Uses proper locale files for templates', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Locale', 'fr')
@@ -179,51 +131,48 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
test('Falls back to English templates when locale file not found', async ({
comfyPage
}) => {
// Pick a shipped LTR locale and simulate its template index returning 404.
// (Previously this test used 'de', but unsupported locales are now
// clamped to 'en' at boot so they never hit the template fallback path.
// 'fa' would also work but flips document.dir to rtl, which can leak
// into adjacent specs in the same worker.)
const locale = 'tr'
// Set locale to a language that doesn't have a template file
await comfyPage.settings.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists
await comfyPage.page.route(
`**/templates/index.${locale}.json`,
async (route) => {
await route.fulfill({
status: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
})
}
// Wait for the German request (expected to 404)
const germanRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.de.json'
)
await comfyPage.page.route('**/templates/index.json', (route) =>
route.continue()
)
await comfyPage.settings.setSetting('Comfy.Locale', locale)
const localeRequestPromise = comfyPage.page.waitForRequest(
`**/templates/index.${locale}.json`
)
// Wait for the fallback English request
const englishRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.json'
)
// Intercept the German file to simulate a 404
await comfyPage.page.route('**/templates/index.de.json', async (route) => {
await route.fulfill({
status: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
})
})
// Allow the English index to load normally
await comfyPage.page.route('**/templates/index.json', (route) =>
route.continue()
)
// Load the templates dialog
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
const localeRequest = await localeRequestPromise
// Verify German was requested first, then English as fallback
const germanRequest = await germanRequestPromise
const englishRequest = await englishRequestPromise
expect(localeRequest.url()).toContain(`templates/index.${locale}.json`)
expect(germanRequest.url()).toContain('templates/index.de.json')
expect(englishRequest.url()).toContain('templates/index.json')
// Assert on rendered content, not just the container — the container
// testid is present even when the dialog body is empty, which would let
// a regression where the fallback fetch succeeds but no cards render
// pass silently.
await expect(comfyPage.templates.allTemplateCards.first()).toBeVisible()
// Verify English titles are shown as fallback
await expect(
comfyPage.page.getByRole('main').getByText('All Templates')
).toBeVisible()
})
test('template cards are dynamically sized and responsive', async ({

View File

@@ -1,4 +1,5 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -188,4 +189,79 @@ test.describe('Workflow tabs', () => {
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
test.describe('Closing a modified workflow tab (FE-419)', () => {
async function modifyActiveWorkflow(page: Page, activeTab: Locator) {
await page.evaluate(() => {
const graph = window.app?.graph
const node = window.LiteGraph?.createNode('Note')
if (graph && node) graph.add(node)
})
await expect(
activeTab.getByTestId('workflow-dirty-indicator')
).toHaveCount(1)
}
test('shows "Close anyway" label and no Cancel button on dirtyClose dialog', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('button', { name: 'Close anyway' })
).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Save' })).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Cancel' })).toHaveCount(
0
)
})
test('clicking "Close anyway" closes the tab without saving', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await comfyPage.page
.getByRole('dialog')
.getByRole('button', { name: 'Close anyway' })
.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
await expect
.poll(() => topbar.getActiveTabName())
.toContain('Unsaved Workflow')
})
test('dismissing the dialog keeps the modified tab open', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(comfyPage.page.getByRole('dialog')).toBeHidden()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
})
})

View File

@@ -75,24 +75,6 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await expect(renamedNode).toBeVisible()
})
test('should open node info in the right side panel via context menu', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Node Info')
const panel = comfyPage.menu.propertiesPanel.root
await expect(panel).toBeVisible()
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
'aria-selected',
'true'
)
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
})
test('should copy and paste node via context menu', async ({
comfyPage
}) => {

View File

@@ -21,9 +21,8 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
})
const nodeId = String(loadImageNode.id)
const imagePreview = comfyPage.vueNodes
.getNodeLocator(nodeId)
.locator('.image-preview')
const { imagePreview } =
await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.locator('img')).toBeVisible({ timeout: 30_000 })
@@ -44,6 +43,25 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
})
test('hides mask and download buttons when image is missing', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'widgets/load_image_widget_missing_file'
)
const { imagePreview } =
await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.getByTestId('error-loading-image')).toBeVisible()
await imagePreview.getByRole('region').hover()
await expect(imagePreview.getByLabel('Edit or mask image')).toHaveCount(0)
await expect(imagePreview.getByLabel('Download image')).toHaveCount(0)
})
test('shows image context menu options', async ({ comfyPage }) => {
const { nodeId } = await loadImageOnNode(comfyPage)

View File

@@ -4,7 +4,6 @@ import {
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
cleanupFakeModel,
dismissErrorOverlay,
@@ -14,9 +13,7 @@ import {
ExecutionHelper,
buildKSamplerError
} from '@e2e/fixtures/helpers/ExecutionHelper'
import type { NodeError } from '@/schemas/apiSchema'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
@@ -25,61 +22,6 @@ const ERROR_CLASS = /ring-destructive-background/
const UNKNOWN_NODE_ID = '1'
const INNER_EXECUTION_ID = '2:1'
const KSAMPLER_MODEL_INPUT_NAME = 'model'
const LOAD_IMAGE_INPUT_NAME = 'image'
const LOAD_IMAGE_UPLOAD_FILE = 'test_upload_image.png'
function buildLoadImageRequiredInputError(): NodeError {
return {
class_type: 'LoadImage',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: `Required input is missing: ${LOAD_IMAGE_INPUT_NAME}`,
details: '',
extra_info: { input_name: LOAD_IMAGE_INPUT_NAME }
}
]
}
}
async function surfaceLoadImageMissingInputError(
comfyPage: ComfyPage,
loadImageId: string
): Promise<void> {
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[loadImageId]: buildLoadImageRequiredInputError()
})
await comfyPage.runButton.click()
await dismissErrorOverlay(comfyPage)
}
async function selectLoadImageNodeForPaste(
comfyPage: ComfyPage,
loadImageId: string
): Promise<void> {
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(Number(nodeId))
if (!node) throw new Error(`Load Image node ${nodeId} not found`)
window.app!.canvas.selectNode(node)
window.app!.canvas.current_node = node
}, loadImageId)
}
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const loadImageId = String(loadImageNode.id)
return {
loadImageId,
innerWrapper: comfyPage.vueNodes.getNodeInnerWrapper(loadImageId),
imageWidget: await loadImageNode.getWidgetByName(LOAD_IMAGE_INPUT_NAME)
}
}
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
test('should display error state when node is missing (node from workflow is not installed)', async ({
@@ -249,74 +191,6 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
test('clears error ring when user drops an image file onto Load Image', async ({
comfyPage
}) => {
const { loadImageId, innerWrapper, imageWidget } =
await setupLoadImageErrorScenario(comfyPage)
await test.step('queue with missing image input to surface the error', async () => {
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
await test.step('drop an image onto the Load Image node', async () => {
const dropPosition =
await comfyPage.canvasOps.getNodeCenterByTitle('Load Image')
if (!dropPosition) {
throw new Error('Load Image node center must be available for drop')
}
await comfyPage.dragDrop.dragAndDropFile(LOAD_IMAGE_UPLOAD_FILE, {
dropPosition,
waitForUpload: true
})
await expect
.poll(() => imageWidget.getValue())
.toContain(LOAD_IMAGE_UPLOAD_FILE)
})
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
test('clears error ring when user pastes an image file onto Load Image', async ({
comfyPage
}) => {
const { loadImageId, innerWrapper, imageWidget } =
await setupLoadImageErrorScenario(comfyPage)
await test.step('queue with missing image input to surface the error', async () => {
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
await test.step('paste an image while Load Image is selected', async () => {
await comfyPage.canvas.focus()
await selectLoadImageNodeForPaste(comfyPage, loadImageId)
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.canvas.current_node?.type)
)
.toBe('LoadImage')
const uploadResponse = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10_000 }
)
// File clipboard contents cannot be seeded reliably in Playwright;
// use the direct document paste mode to exercise usePaste.
await comfyPage.clipboard.pasteFile(assetPath(LOAD_IMAGE_UPLOAD_FILE), {
mode: 'direct'
})
await uploadResponse
await expect
.poll(() => imageWidget.getValue())
.toContain(LOAD_IMAGE_UPLOAD_FILE)
})
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
})
test.describe('subgraph propagation', { tag: '@subgraph' }, () => {

View File

@@ -0,0 +1,211 @@
import type { WebSocketRoute } from '@playwright/test'
import { mergeTests } from '@playwright/test'
import type { z } from 'zod'
import {
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
import type {
RawJobListItem,
zJobsListResponse
} from '@/platform/remote/comfyui/jobs/jobTypes'
type JobsListResponse = z.infer<typeof zJobsListResponse>
const test = mergeTests(comfyPageFixture, webSocketFixture)
const KSAMPLER_NODE = '3'
const EXECUTING_CLASS = /outline-node-stroke-executing/
const QUEUE_ROUTE = /\/api\/jobs\?[^/]*status=in_progress,pending/
const HISTORY_ROUTE = /\/api\/jobs\?[^/]*status=completed/
function jobsResponse(jobs: RawJobListItem[]): JobsListResponse {
return {
jobs,
pagination: { offset: 0, limit: 200, total: jobs.length, has_more: false }
}
}
async function mockJobsRoute(
comfyPage: ComfyPage,
pattern: RegExp,
body: string,
status: number = 200
): Promise<() => number> {
let count = 0
await comfyPage.page.route(pattern, async (route) => {
count += 1
await route.fulfill({
status,
contentType: 'application/json',
body
})
})
return () => count
}
const emptyJobsBody = JSON.stringify(jobsResponse([]))
type Scenario = {
name: string
/** Built per-test so it can incorporate the runtime-assigned jobId. */
queueBody: (jobId: string) => string
/** Whether the active job state should still be reflected after reconnect. */
expectsActiveAfter: boolean
}
const scenarios: Scenario[] = [
{
name: 'clears stale active job when queue is empty after reconnect',
queueBody: () => emptyJobsBody,
expectsActiveAfter: false
},
{
name: 'preserves active job when the job is still in the queue',
queueBody: (jobId) =>
JSON.stringify(
jobsResponse([
{ id: jobId, status: 'in_progress', create_time: Date.now() }
])
),
expectsActiveAfter: true
}
]
/**
* Stub the queue/history endpoints per `scenario`, close the WS, and wait
* for the auto-reconnect to issue a fresh queue fetch.
*/
async function triggerReconnect(
comfyPage: ComfyPage,
ws: WebSocketRoute,
scenario: Scenario,
jobId: string
): Promise<void> {
await mockJobsRoute(comfyPage, HISTORY_ROUTE, emptyJobsBody)
const queueFetches = await mockJobsRoute(
comfyPage,
QUEUE_ROUTE,
scenario.queueBody(jobId)
)
const fetchesBeforeClose = queueFetches()
await ws.close()
await expect.poll(queueFetches).toBeGreaterThan(fetchesBeforeClose)
}
test.describe('WebSocket reconnect with stale job', { tag: '@ui' }, () => {
test.describe('app mode skeleton', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([[KSAMPLER_NODE, 'seed']])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
})
for (const scenario of scenarios) {
test(scenario.name, async ({ comfyPage, getWebSocket }) => {
const ws = await getWebSocket()
const exec = new ExecutionHelper(comfyPage, ws)
const jobId = await exec.run()
exec.executionStart(jobId)
// Skeleton visibility is the deterministic sync point: it appears
// once both `storeJob` (HTTP) and `executionStart` (WS) have been
// processed, regardless of arrival order.
const firstSkeleton = comfyPage.appMode.outputHistory.skeletons.first()
await expect(firstSkeleton).toBeVisible()
await triggerReconnect(comfyPage, ws, scenario, jobId)
if (scenario.expectsActiveAfter) {
await expect(firstSkeleton).toBeVisible()
} else {
await expect(comfyPage.appMode.outputHistory.skeletons).toHaveCount(0)
}
})
}
test('preserves active job when the queue endpoint fails on reconnect', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const exec = new ExecutionHelper(comfyPage, ws)
const jobId = await exec.run()
exec.executionStart(jobId)
const firstSkeleton = comfyPage.appMode.outputHistory.skeletons.first()
await expect(firstSkeleton).toBeVisible()
await mockJobsRoute(comfyPage, HISTORY_ROUTE, emptyJobsBody)
// Prime queueStore.runningTasks with the active job — a WS status
// event drives GraphView.onStatus -> queueStore.update().
const primer = await mockJobsRoute(
comfyPage,
QUEUE_ROUTE,
JSON.stringify(
jobsResponse([
{ id: jobId, status: 'in_progress', create_time: Date.now() }
])
)
)
exec.status(1)
await expect.poll(primer).toBeGreaterThanOrEqual(1)
// Swap to a failing handler so the reconnect-driven fetch 500s.
// The fix should preserve runningTasks from the priming call rather
// than overwriting it with empty/error state.
await comfyPage.page.unroute(QUEUE_ROUTE)
const failed = await mockJobsRoute(comfyPage, QUEUE_ROUTE, '{}', 500)
const before = failed()
await ws.close()
await expect.poll(failed).toBeGreaterThan(before)
await expect(firstSkeleton).toBeVisible()
})
})
test.describe('vue node executing class', { tag: '@vue-nodes' }, () => {
for (const scenario of scenarios) {
test(scenario.name, async ({ comfyPage, getWebSocket }) => {
const ws = await getWebSocket()
const exec = new ExecutionHelper(comfyPage, ws)
// The executing outline lives on the outer `[data-node-id]`
// container, not the inner wrapper.
const ksamplerNode = comfyPage.vueNodes.getNodeLocator(KSAMPLER_NODE)
await expect(ksamplerNode).toBeVisible()
const jobId = await exec.run()
exec.executionStart(jobId)
exec.progressState(jobId, {
[KSAMPLER_NODE]: {
value: 0,
max: 1,
state: 'running',
node_id: KSAMPLER_NODE,
display_node_id: KSAMPLER_NODE,
prompt_id: jobId
}
})
await expect(ksamplerNode).toHaveClass(EXECUTING_CLASS)
await triggerReconnect(comfyPage, ws, scenario, jobId)
if (scenario.expectsActiveAfter) {
await expect(ksamplerNode).toHaveClass(EXECUTING_CLASS)
} else {
await expect(ksamplerNode).not.toHaveClass(EXECUTING_CLASS)
}
})
}
})
})

View File

@@ -249,6 +249,7 @@ Companion architecture documents that expand on the design in this ADR:
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |

View File

@@ -0,0 +1,328 @@
# 9. Subgraph promoted widgets use linked inputs
Date: 2026-05-05
Appendices:
- [Before/after flow diagrams](./0009-subgraph-promoted-widgets-use-linked-inputs/before-after-flows.md)
- [System comparison](./0009-subgraph-promoted-widgets-use-linked-inputs/system-comparison.md)
- [Removing `disambiguatingSourceNodeId`](./0009-subgraph-promoted-widgets-use-linked-inputs/disambiguating-source-node-id.md)
## Status
Proposed
## Context
Subgraph widget promotion historically had two overlapping representations:
1. `properties.proxyWidgets`, a serialized list of source node/widget tuples;
2. linked subgraph inputs, where an interior widget-bearing input is exposed
through the subgraph boundary.
This created ambiguous ownership. Runtime value reads could collapse to an
interior source widget, while host `widgets_values` could also carry an
exterior value. Multiple host instances of the same subgraph could therefore
stomp one another, and serialization could mutate interior widgets as a
persistence carrier for exterior values.
The ECS widget migration makes that ambiguity more expensive: widgets are
becoming entities with component state keyed by stable entity identity, and
subgraphs are modeled as graph boundary structure rather than a separate
promotion-specific entity kind.
## Decision
Promoted widgets are represented only as standard linked `SubgraphInput`
widgets. A promoted widget is a host-scoped widget entity owned by a subgraph
input on a host `SubgraphNode`. The interior source widget supplies schema,
type, options, tooltip, and default metadata, but it is not the owner of the
host value.
Display-only preview surfacing, such as `$$canvas-image-preview`, is not a
promoted widget. It is a separate preview-exposure system because it has no
host-owned widget value, does not feed prompt serialization, and often points at
virtual `serialize: false` pseudo-widgets that may not exist on the source node.
`properties.proxyWidgets` becomes a legacy load-time input only. Successful
repair consumes entries from `proxyWidgets`; canonical saves do not re-emit
those entries. The standard serialized representation is the existing subgraph
interface/input form plus host-node `widgets_values`.
Display-only preview exposures use their own host-node-scoped serialized entry,
`properties.previewExposures`, instead of `properties.proxyWidgets` and instead
of linked `SubgraphInput` widgets. Canonical preview-exposure JSON uses preview
language, not widget language:
```ts
type PreviewExposure = {
name: string
sourceNodeId: string
sourcePreviewName: string
}
```
Host-node scope preserves current behavior where different instances of the
same subgraph can choose different exposed previews.
The entry intentionally stores only host preview identity and source locator
identity. `name` is the host-scoped stable identity for this preview exposure,
analogous to `SubgraphInput.name`; it is not a display label. It is generated
with existing collision behavior, such as `nextUniqueName(...)`, when an
exposure is created. Media type, display labels, titles, image/video/audio URLs,
and other runtime preview details are derived from the current graph and output
state. Array order is the canonical display order. Preview exposures do not get
a separate persisted `label` in this slice; if a future rename UX needs one, it
should follow the same rule as subgraph inputs: `name` is identity and `label`
is display-only.
Preview exposures are persisted user choices after creation. Packing nodes into
a subgraph may auto-add recommended preview exposures for supported output
nodes, and users may explicitly add or remove additional preview exposures
afterward. Normal load/save does not re-derive previews from node type alone,
because that would make old workflows change when support for new preview node
types is added. Unresolved preview exposures remain persisted and inert;
automatic cleanup does not prune them. They are removed only by explicit user
action or by destruction/unpacking of the owning host.
Preview exposures compose through nested subgraph hosts by chaining immediate
boundaries. If an outer subgraph wants to show a preview exposed by an inner
subgraph host, the outer `previewExposures` entry points at the immediate inner
`SubgraphNode`, and `sourcePreviewName` names the inner host's preview-exposure
identity, not the deepest interior preview name. Runtime preview resolution may
then follow the inner host's own preview exposures to find media. Canonical JSON
does not persist flattened deep paths, because deep paths would couple host UI
state to private nested graph internals.
## Identity and value ownership
- UI/value identity is host-scoped: host node locator plus
`SubgraphInput.name`.
- Host-scoped identity means the host `SubgraphNode` instance within its
containing `graphScope`; the interior source node is not the state or
persistence owner.
- `SubgraphInput.name` is the stable internal identity.
- `SubgraphInput.label` / `localized_name` are display-only.
- `SubgraphInput.id` may be used for slot-instance reconciliation, not as the
persisted widget value key.
- Source node/widget identity remains metadata for diagnostics, missing-model
lookup, schema projection, and migration only.
- The host/exterior value wins over the interior/source value during repair,
persistence, and prompt serialization.
This follows the existing widget/slot convention: `name` is identity, `label`
is display.
Promoted-widget value state is a host-scoped sparse overlay over source-widget
metadata and defaults. The source widget remains the schema/default provider;
host value state is materialized only when the exterior value differs from the
effective source default or when restored from persisted host state. Canonical
save/load must not eagerly mirror source defaults or use interior widgets as
persistence carriers.
## Forward migration
Loading a workflow with legacy `proxyWidgets` runs a one-way repair:
1. Parse `properties.proxyWidgets` with the existing Zod-inferred tuple type.
2. Invalid raw `proxyWidgets` data logs `console.error`, does not throw, and is
not quarantined.
3. Build a multi-pass association map before mutation:
- normalized legacy proxy entry;
- projected legacy promoted-widget order;
- host `widgets_values` value, preserving sparse holes;
- repair strategy or failure reason;
- whether the entry is a value widget or display-only preview exposure.
4. Defer mutations until node IDs/entity IDs are stable and the subgraph graph
is configured.
5. On flush, re-resolve against current graph state, because clone/paste/load
flows may have remapped or created nodes and links.
6. If already represented by a linked `SubgraphInput`, consider the legacy
entry resolved and consume it.
7. Otherwise repair through existing subgraph input/link systems.
8. If the entry is display-only preview surfacing, migrate it into the separate
preview-exposure representation instead of creating a linked `SubgraphInput`.
9. If value-widget repair fails, write inert quarantine metadata and warn.
The repair is idempotent. Pending plans store tuple/value data and re-check the
current graph before applying mutations.
Legacy entries are classified as preview exposures when either:
- the legacy source name starts with `$$`; or
- the source node resolves to a matching pseudo-preview widget, such as a
`serialize: false` preview/video/audio UI widget.
Everything else is treated as a value-widget promotion candidate. An unresolved
preview-shaped entry remains inert at runtime and is still persisted, because
preview-capable pseudo-widgets and output media can be removed and re-added
dynamically. It is not quarantined because it has no user value to preserve. A
non-`$$` entry that cannot resolve to a source widget is a value-widget repair
failure and follows the quarantine path unless it can resolve to a
pseudo-preview widget.
## Proxy widget error quarantine
Valid legacy entries that cannot be repaired are persisted in
`properties.proxyWidgetErrorQuarantine`. Quarantined entries are inert: they do
not hydrate runtime promoted widgets, do not participate in execution, and are
not used for app-mode/favorites identity.
Quarantine entries preserve enough information to avoid data loss and support
future tooling:
```ts
type ProxyWidgetErrorQuarantineEntry = {
originalEntry: ProxyWidgetTuple
reason:
| 'missingSourceNode'
| 'missingSourceWidget'
| 'missingSubgraphInput'
| 'ambiguousSubgraphInput'
| 'unlinkedSourceWidget'
| 'primitiveBypassFailed'
hostValue?: TWidgetValue
attemptedAtVersion: 1
}
```
Unresolved legacy UI selections/favorites are dropped with `console.warn`.
Workflow-level promotion/value intent is preserved by
`proxyWidgetErrorQuarantine`, not by a second UI quarantine format.
## Primitive-node repair
Legacy `proxyWidgets` may point at `PrimitiveNode` outputs. Primitive nodes
serve nearly the same purpose as subgraph inputs: they provide a widget value to
one or more target widget inputs. The migration repairs this expected legacy
shape in the first migration rather than quarantining it by default.
Primitive repair:
- coalesces exact duplicate legacy entries during planning;
- uses the primitive node's user title as the base input name when the node was
renamed, otherwise the primitive output widget name;
- applies existing naming behavior and `nextUniqueName(...)` for collisions;
- uses the existing primitive merge/config compatibility logic;
- creates one `SubgraphInput` for the primitive fanout;
- reconnects every former primitive output target to that input in target
order, using standard connect/disconnect APIs;
- applies the host value when one exists, otherwise seeds from the source
primitive value;
- leaves the primitive node and its widget value in place, but disconnected and
inert.
Primitive repair is all-or-quarantine. If any target cannot be validated or
reconnected, the migration does not leave a partial rewrite; it quarantines the
entry with `hostValue` and logs the reason.
## Serialization
After repair/quarantine:
- `properties.proxyWidgets` is omitted for repaired entries;
- display-only preview entries are omitted from `properties.proxyWidgets` and
emitted through `properties.previewExposures`;
- `properties.proxyWidgetErrorQuarantine` carries unrepaired valid entries;
- preview exposures do not carry quarantine values because they do not own user
values; unresolved preview exposures remain inert in `previewExposures`;
- host `widgets_values` contains host-owned values only for canonical host
widgets, not source-owned defaults or interior persistence copies;
- quarantined legacy values live in `proxyWidgetErrorQuarantine.hostValue`;
- array-form `widgets_values` remains for now.
Preview exposures are display-only UI metadata. They drive host canvas/app-mode
preview rendering, but they do not create prompt inputs, do not create
`widgets_values`, do not alter node execution order, do not become executable
graph edges, and do not participate in prompt serialization. Runtime mapping
from backend `display_node`/output messages to a host preview exposure is a UI
projection only.
The old `SubgraphNode.serialize()` behavior that copied exterior promoted
values into connected interior widgets is removed. A temporary TODO should mark
that removal point until the migration is proven stable. Host values are
serialized through standard subgraph-input widgets instead.
Longer term, `widgets_values` should move from array order to an object/map
keyed by stable widget name, but that migration is out of scope for this
decision.
## App mode, builder, and favorites
The runtime migration and UI identity migration ship in the same slice. The UI
must not persist promoted selections by source node/widget identity after this
change.
Canonical UI identity is:
```ts
type PromotedWidgetUiIdentity = {
hostNodeLocator: string
subgraphInputName: string
}
```
Legacy source-identity selections are migrated when they resolve through the
standard input created or confirmed by the migration. Unresolved selections are
dropped with a warning.
Preview exposure output selections are also host-scoped and must not persist
interior source node identity. Canonical preview/output identity is:
```ts
type PreviewExposureUiIdentity = {
hostNodeLocator: string
previewName: string
}
```
The UI references the explicit preview exposure itself. This keeps subgraphs
opaque: consumers select the host boundary contract, not the interior node that
currently supplies media. Legacy output selections that refer to interior
preview source nodes may migrate if they resolve to a preview-exposure chain;
otherwise they are dropped with `console.warn`. There is no separate preview UI
quarantine.
## PromotionStore
`PromotionStore` becomes vestigial. It may remain temporarily as a derived
runtime compatibility/index layer for existing consumers, but it is not
serialized authority, must not create promotions without linked
`SubgraphInput`s, and should be removed once consumers query the standard graph
interface directly.
## Considered options
### Keep `proxyWidgets` as canonical serialized topology
Rejected. This preserves two representations for the same concept and keeps
source-widget identity in the value-ownership path.
### Preserve bare promoted widgets as degraded runtime state
Rejected. This would avoid some migration complexity, but it perpetuates the
ambiguity that caused host/source value bugs and makes ECS identity less clear.
### Quarantine primitive-node promotions by default
Rejected. Primitive-node proxy promotions are expected legacy workflows, and
quarantining them would break users unnecessarily. They are repaired by bypassing
the primitive node when the repair can be validated all-or-nothing.
### Migrate `widgets_values` to object/map form now
Rejected for this slice. Name-keyed object form is the desired long-term
direction, but combining it with the promotion migration increases blast radius
for existing workflow consumers that still assume array order.
## Consequences
- Promoted widget values become host-instance-owned and ECS-compatible.
- Source widgets remain metadata/default providers, not persistence carriers.
- Legacy workflows are repaired toward one standard representation.
- Quarantine preserves unrepaired valid legacy data without reintroducing bare
runtime promotion.
- Primitive fanout repair is more complex, but avoids breaking common existing
workflows.
- UI code must migrate with the runtime migration to avoid mixed identity states.
- `PromotionStore` has a clear removal path.

View File

@@ -0,0 +1,210 @@
# Appendix: Before and after flows
This appendix visualizes the ownership and migration flows described in
[ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md).
## Before: proxy widgets and linked inputs overlap
Historically, promoted widgets could be represented both as serialized
`properties.proxyWidgets` entries and as linked subgraph inputs. Runtime value
reads could collapse back to the interior source widget, while host
`widgets_values` could also carry an exterior value for the same promoted UI.
```mermaid
flowchart TD
workflow[Workflow JSON] --> proxyWidgets[properties.proxyWidgets]
workflow --> hostValues[host widgets_values]
proxyWidgets --> promotionStore[PromotionStore / promotion runtime]
promotionStore --> sourceWidget[Interior source widget]
linkedInput[Linked SubgraphInput] --> hostWidget[Host promoted widget]
sourceWidget --> hostWidget
hostValues --> hostWidget
hostWidget --> prompt[Prompt serialization]
hostWidget -. may copy value back .-> sourceWidget
sourceWidget -. shared by host instances .-> otherHost[Another host instance]
classDef legacy fill:#fff3cd,stroke:#a66f00,color:#332200
classDef ambiguous fill:#f8d7da,stroke:#842029,color:#330000
classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
class proxyWidgets,promotionStore legacy
class sourceWidget,hostValues ambiguous
class linkedInput,hostWidget canonical
```
Key problems in the old flow:
- `properties.proxyWidgets` and linked `SubgraphInput` widgets could describe
the same promotion.
- Interior source widgets supplied both schema metadata and, in some flows,
persisted host values.
- Multiple host instances of the same subgraph could stomp one another through
the shared interior widget value.
- Display-only previews were mixed into widget-promotion language even though
they do not own values or feed prompt serialization.
## After: linked inputs are the promoted-widget boundary
Promoted value widgets are now represented only as standard linked
`SubgraphInput` widgets. The source widget remains the schema/default provider,
but the host `SubgraphNode` owns the promoted value.
```mermaid
flowchart TD
workflow[Workflow JSON] --> subgraphInterface[Subgraph interface / inputs]
workflow --> hostValues[host widgets_values]
subgraphInterface --> subgraphInput[SubgraphInput.name]
subgraphInput --> hostWidget[Host-scoped widget entity]
hostValues --> hostWidget
sourceWidget[Interior source widget] --> schema[Schema, type, options, tooltip, default]
schema --> hostWidget
hostWidget --> prompt[Prompt serialization]
hostIdentity[Host node locator + SubgraphInput.name] --> hostWidget
sourceWidget -. metadata only .-> diagnostics[Diagnostics / lookup / migration]
sourceWidget -. no host value ownership .-> schema
classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef metadata fill:#cff4fc,stroke:#055160,color:#032830
classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529
class subgraphInterface,subgraphInput,hostWidget,hostIdentity owner
class sourceWidget,schema,diagnostics metadata
class workflow,hostValues persisted
```
Canonical ownership after the migration:
- UI/value identity is host-scoped: host node locator plus
`SubgraphInput.name`.
- `SubgraphInput.name` is stable identity; labels and localized names are
display-only.
- Host values win during repair, persistence, and prompt serialization.
- Source widgets provide metadata and defaults only.
- Canonical saves omit repaired `properties.proxyWidgets` entries.
## Legacy load migration
Loading a workflow with legacy `proxyWidgets` performs an idempotent repair. The
repair builds a plan before mutating graph state, then re-resolves against the
current graph when node IDs and links are stable.
```mermaid
flowchart TD
start[Load workflow] --> parse{Parse properties.proxyWidgets}
parse -->|invalid raw data| invalid[console.error and ignore]
parse -->|valid tuples| plan[Build repair plan]
plan --> classify{Classify entry}
classify -->|value widget| valueRepair{Already linked SubgraphInput?}
valueRepair -->|yes| consume[Consume legacy proxy entry]
valueRepair -->|no| repair[Repair through subgraph input/link systems]
repair --> repairResult{Repair succeeded?}
repairResult -->|yes| consume
repairResult -->|no| quarantine[Persist proxyWidgetErrorQuarantine]
classify -->|primitive fanout| primitive[Validate all primitive targets]
primitive --> primitiveResult{All targets reconnectable?}
primitiveResult -->|yes| primitiveRepair[Create one SubgraphInput and reconnect fanout]
primitiveRepair --> consume
primitiveResult -->|no| quarantine
classify -->|display-only preview| preview[Create / keep previewExposures entry]
preview --> consume
consume --> save[Canonical save]
quarantine --> save
save --> omit[Omit repaired entries from proxyWidgets]
save --> keepQuarantine[Persist unrepaired value intent in quarantine]
save --> keepPreview[Persist previews in previewExposures]
classDef ok fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef warn fill:#fff3cd,stroke:#a66f00,color:#332200
classDef error fill:#f8d7da,stroke:#842029,color:#330000
classDef neutral fill:#e2e3e5,stroke:#41464b,color:#212529
class consume,repair,primitiveRepair,preview,save,omit,keepPreview ok
class plan,classify,valueRepair,primitive,primitiveResult,repairResult neutral
class quarantine,keepQuarantine warn
class invalid error
```
## Preview exposures are separate from value widgets
Display-only previews, such as `$$canvas-image-preview`, are not promoted
widgets. They have host-scoped serialized identity, but they do not create
prompt inputs, do not create `widgets_values`, and do not own user values.
```mermaid
flowchart TD
hostNode[Host SubgraphNode] --> previewExposures[properties.previewExposures]
previewExposures --> exposure[PreviewExposure.name]
exposure --> sourceLocator[sourceNodeId + sourcePreviewName]
sourceLocator --> runtimePreview[Runtime preview/output state]
runtimePreview --> hostCanvas[Host canvas / app-mode preview]
exposure --> uiIdentity[hostNodeLocator + previewName]
runtimePreview -. UI projection only .-> hostCanvas
previewExposures -. no prompt input .-> noPrompt[No prompt serialization]
previewExposures -. no value widget .-> noValue[No widgets_values entry]
previewExposures -. no graph edge .-> noEdge[No executable graph edge]
classDef preview fill:#cff4fc,stroke:#055160,color:#032830
classDef noValue fill:#f8d7da,stroke:#842029,color:#330000
classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529
class previewExposures,exposure,sourceLocator,runtimePreview,hostCanvas,uiIdentity preview
class noPrompt,noValue,noEdge noValue
class hostNode persisted
```
For nested subgraphs, preview exposures chain across immediate host boundaries
instead of persisting flattened deep paths.
```mermaid
flowchart LR
outerHost[Outer SubgraphNode] --> outerExposure[Outer previewExposures entry]
outerExposure --> innerHost[Immediate inner SubgraphNode]
innerHost --> innerExposure[Inner previewExposures entry]
innerExposure --> deepestPreview[Interior preview source]
deepestPreview --> media[Resolved media]
outerExposure -. sourcePreviewName names inner preview identity .-> innerExposure
outerExposure -. does not persist deep private path .-> opaque[Subgraph internals remain opaque]
classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef preview fill:#cff4fc,stroke:#055160,color:#032830
classDef note fill:#fff3cd,stroke:#a66f00,color:#332200
class outerHost,innerHost boundary
class outerExposure,innerExposure,deepestPreview,media preview
class opaque note
```
## Serialization summary
```mermaid
flowchart TD
canonical[Canonical serialized SubgraphNode] --> inputs[Subgraph interface / inputs]
canonical --> values[widgets_values for host-owned values]
canonical --> previews[properties.previewExposures]
canonical --> quarantine[properties.proxyWidgetErrorQuarantine]
canonical -. omits repaired entries .-> noProxy[No canonical proxyWidgets]
inputs --> valueWidgets[Promoted value widgets]
values --> valueWidgets
previews --> previewUi[Display-only preview UI]
quarantine --> futureTooling[Future recovery tooling]
valueWidgets --> prompt[Prompt serialization]
previewUi -. not serialized into prompt .-> prompt
quarantine -. inert .-> prompt
classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef inert fill:#fff3cd,stroke:#a66f00,color:#332200
classDef removed fill:#f8d7da,stroke:#842029,color:#330000
class inputs,values,valueWidgets,prompt,canonical canonical
class previews,previewUi,quarantine,futureTooling inert
class noProxy removed
```

View File

@@ -0,0 +1,147 @@
# Appendix: Removing `disambiguatingSourceNodeId`
This appendix explains where the existing promotion system needs
`disambiguatingSourceNodeId`, why that need appears, and how the canonical form
chosen by [ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md)
removes the pattern from promoted-widget identity.
## Why the disambiguator exists
The legacy promotion model identifies a promoted widget by source location:
```ts
type PromotedWidgetSource = {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
}
```
`sourceNodeId` is the immediate interior node visible from the host subgraph.
That is not always the original widget owner. When promotions pass through
nested subgraphs, two promoted widgets can have the same immediate
`sourceNodeId` and `sourceWidgetName` while pointing at different leaf widgets.
`disambiguatingSourceNodeId` carries the deepest source node ID so the runtime
can choose the right promoted view.
```mermaid
flowchart TD
outerHost[Outer host SubgraphNode] --> middleNode[Interior middle SubgraphNode]
middleNode --> middleWidgetA[Promoted widget view: text]
middleNode --> middleWidgetB[Promoted widget view: text]
middleWidgetA --> leafA[Leaf source node 17 / widget text]
middleWidgetB --> leafB[Leaf source node 42 / widget text]
oldKeyA[Old key: middleNodeId + text + disambiguatingSourceNodeId 17]
oldKeyB[Old key: middleNodeId + text + disambiguatingSourceNodeId 42]
middleWidgetA -. requires .-> oldKeyA
middleWidgetB -. requires .-> oldKeyB
classDef host fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef ambiguous fill:#fff3cd,stroke:#a66f00,color:#332200
classDef leaf fill:#cff4fc,stroke:#055160,color:#032830
class outerHost host
class middleNode,middleWidgetA,middleWidgetB,oldKeyA,oldKeyB ambiguous
class leafA,leafB leaf
```
The disambiguator is therefore not a domain concept. It is compensating for an
identity model that asks host UI state to identify private nested internals.
## Existing places that need it
| Area | Current use of `disambiguatingSourceNodeId` | Ambiguity being patched |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| Promotion source types | `PromotedWidgetSource` and `PromotedWidgetView` carry the optional field. | Source identity needs more than immediate node ID plus widget name for nested promoted views. |
| Concrete widget resolution | `findWidgetByIdentity(...)` matches promoted views by `(disambiguatingSourceNodeId ?? sourceNodeId)` when a source node ID is supplied. | Multiple promoted views under the same intermediate node can share a widget name. |
| Legacy proxy normalization | Prefixed legacy names such as `123:widget_name` are converted into structured source identity and tested with candidate disambiguators. | Old serialized names encode leaf identity inside the widget name string. |
| Promotion store keys | `makePromotionEntryKey(...)`, `isPromoted(...)`, and `demote(...)` include the field in equality. | Store-level uniqueness would collapse distinct nested promotions without the leaf ID. |
| Linked promotion propagation | `SubgraphNode._resolveLinkedPromotionBySubgraphInput(...)` preserves the leaf ID when a linked input targets an inner subgraph promoted view. | The outer host otherwise sees only the immediate inner `SubgraphNode` and the promoted widget name. |
| Subgraph editor UI | The editor uses the field when resolving active widgets and when writing reordered/toggled promotions back to the store. | UI list operations must not merge same-name promoted views from different leaves. |
## New promoted-widget identity
ADR 0009 moves promoted value identity to the host boundary:
```ts
type PromotedWidgetUiIdentity = {
hostNodeLocator: string
subgraphInputName: string
}
```
The canonical widget is owned by a `SubgraphInput` on the host
`SubgraphNode`. The host widget no longer needs to identify the deepest source
node to preserve value identity. The source widget is consulted for schema,
defaults, diagnostics, and migration, but it is not the value owner.
```mermaid
flowchart TD
host[Host SubgraphNode] --> inputA[SubgraphInput.name: prompt]
host --> inputB[SubgraphInput.name: negative_prompt]
inputA --> hostWidgetA[Host-owned widget entity]
inputB --> hostWidgetB[Host-owned widget entity]
hostWidgetA -. schema/default metadata .-> sourceA[Interior source widget text]
hostWidgetB -. schema/default metadata .-> sourceB[Interior source widget text]
identityA[Identity: hostNodeLocator + prompt] --> hostWidgetA
identityB[Identity: hostNodeLocator + negative_prompt] --> hostWidgetB
sourceA -. not part of host value key .-> identityA
sourceB -. not part of host value key .-> identityB
classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef metadata fill:#cff4fc,stroke:#055160,color:#032830
classDef removed fill:#f8d7da,stroke:#842029,color:#330000
class host,inputA,inputB,hostWidgetA,hostWidgetB,identityA,identityB owner
class sourceA,sourceB metadata
```
This is the same rule the subgraph interface already uses: `name` is stable
identity, and `label` / `localized_name` are display-only.
## How the new form removes each need
| Previous disambiguation site | New canonical replacement |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `PromotedWidgetSource.disambiguatingSourceNodeId` | Host value identity is `hostNodeLocator + SubgraphInput.name`; source locator fields become migration/diagnostic metadata only. |
| `PromotedWidgetView.disambiguatingSourceNodeId` | Host-scoped widget entities are derived from subgraph inputs, not from promoted views chained through nested source widgets. |
| `findWidgetByIdentity(...)` leaf matching | Runtime value lookup starts from the host input identity; source traversal is metadata resolution, not value identity resolution. |
| Legacy prefixed widget-name normalization | Load migration consumes legacy source-shaped entries and writes standard subgraph input state or quarantine metadata. |
| PromotionStore source-key equality | `PromotionStore` becomes a temporary derived index; canonical consumers query subgraph inputs directly. |
| Linked promotion propagation across nested hosts | Nested value composition is represented boundary-by-boundary by linked subgraph inputs with stable names. |
| Subgraph editor active widget matching | Editor state can operate on host boundary entries instead of matching leaf source widgets through same-name promoted views. |
## Boundary-by-boundary nested flow
The new form avoids flattened deep source paths. Each host boundary exposes its
own named input, and the next outer host links to that immediate boundary
contract.
```mermaid
flowchart LR
leaf[Leaf node widget] --> innerInput[Inner SubgraphInput.name: text]
innerInput --> innerHostWidget[Inner host-owned widget]
innerHostWidget --> outerInput[Outer SubgraphInput.name: prompt]
outerInput --> outerHostWidget[Outer host-owned widget]
innerIdentity[Inner value key: innerHost + text] --> innerHostWidget
outerIdentity[Outer value key: outerHost + prompt] --> outerHostWidget
leaf -. schema/default source .-> innerHostWidget
leaf -. not persisted as outer value key .-> outerIdentity
classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef source fill:#cff4fc,stroke:#055160,color:#032830
classDef note fill:#fff3cd,stroke:#a66f00,color:#332200
class innerInput,innerHostWidget,outerInput,outerHostWidget,innerIdentity,outerIdentity boundary
class leaf source
```
Because each layer has its own stable `SubgraphInput.name`, two same-name leaf
widgets no longer require a persisted leaf-node disambiguator at the outer host.
If the user exposes both, the collision is resolved when the host inputs are
created by assigning distinct input names with the existing unique-name
behavior.

View File

@@ -0,0 +1,37 @@
# Appendix: System comparison
This appendix compares the legacy promoted-widget systems with the canonical
linked-input model chosen by
[ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md).
| Concern | Legacy `properties.proxyWidgets` promotions | Linked `SubgraphInput` promotions before migration | New canonical linked-input system |
| -------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| Serialized authority | `properties.proxyWidgets` stores source node/widget tuples as promotion topology. | Subgraph interface/input links can also represent the same exposed widget. | Subgraph interface/input links are the only canonical topology for promoted value widgets. |
| Load-time role | Hydrates promoted widgets directly from legacy tuples. | May already describe the promoted widget, creating overlap with `proxyWidgets`. | Existing linked inputs are accepted as resolved; legacy tuples are consumed by repair or quarantined. |
| Save-time role | Could be re-emitted as promotion state. | Serialized as normal subgraph interface data. | Repaired `proxyWidgets` entries are omitted; standard subgraph inputs plus host `widgets_values` are saved. |
| Value owner | Ambiguous: host `widgets_values` and the interior source widget could both carry the value. | Closer to the desired boundary model, but still coexisted with source/proxy ownership paths. | Host `SubgraphNode` owns value state through host-scoped widget identity. |
| Schema/default provider | Interior source widget provides schema and may also become persistence carrier. | Interior source widget provides source metadata through the link. | Interior source widget provides schema, type, options, tooltip, and defaults only. |
| UI identity | Often persisted by source node/widget identity. | Can use subgraph input identity, but mixed states still exist while proxy identity remains. | Host node locator plus `SubgraphInput.name`. |
| Display label handling | Source widget identity and display concerns can blur. | Uses existing subgraph input naming conventions. | `SubgraphInput.name` is stable identity; `label` / `localized_name` are display-only. |
| Multiple host instances | Risk of host instances stomping one another through shared interior values. | Better host boundary shape, but overlap with proxy/source value paths can reintroduce ambiguity. | Host-instance-owned sparse overlay prevents shared interior widget value stomping. |
| Prompt serialization | May read values through promoted runtime state that can collapse to source widgets. | Can serialize through standard subgraph input widgets when used consistently. | Promoted values serialize only through standard host-owned subgraph-input widgets. |
| Interior mutation on save | Existing `SubgraphNode.serialize()` behavior could copy exterior values into connected interior widgets. | Could still be affected by legacy copy-back behavior. | Copy-back is removed; source widgets are not persistence carriers. |
| Primitive-node promotions | Legacy tuples may point at `PrimitiveNode` outputs. | Not the canonical primitive fanout representation by itself. | Repaired all-or-nothing into one `SubgraphInput` that reconnects validated fanout targets. |
| Invalid or unresolved data | Invalid data could sit in legacy promotion state or fail repair paths. | Missing linked inputs can be ambiguous when proxy data exists. | Invalid raw data logs and is ignored; unrepaired valid value entries go to `proxyWidgetErrorQuarantine`. |
| Display-only previews | Often mixed into `proxyWidgets` despite not being value widgets. | Linked inputs are inappropriate because previews do not own values or prompt inputs. | Separate host-scoped `properties.previewExposures` entries model preview UI only. |
| Preview persistence | Preview selections can depend on source preview/widget-like identity. | No clean distinction from promoted widget inputs. | Preview identity is host node locator plus `previewName`; unresolved previews stay inert and persisted. |
| Nested preview behavior | Deep source identity can leak through host UI state. | Linked value inputs do not model display-only preview composition. | Preview exposures chain across immediate subgraph host boundaries; deep private paths are not persisted. |
| ECS compatibility | Weak: value identity can depend on source widget tuples and mutable interior widgets. | Partial: linked inputs fit boundary modeling, but duplicate authority remains. | Strong: host-scoped widget entity identity maps cleanly to ECS component state. |
| Long-term status | Legacy load-time input only. | Becomes the standard representation once overlap is removed. | Canonical system; `PromotionStore` becomes a temporary derived compatibility/index layer. |
## Practical migration summary
| Legacy shape | New result |
| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Valid `proxyWidgets` entry already represented by a linked `SubgraphInput` | Entry is consumed; the existing linked input remains canonical. |
| Valid value-widget `proxyWidgets` entry without a linked input | Repair creates or reconnects standard subgraph input/link state. |
| Valid primitive fanout entry | Repair creates one `SubgraphInput`, reconnects all validated targets, and leaves the primitive node inert. |
| Valid value-widget entry that cannot be repaired | Entry is persisted in `properties.proxyWidgetErrorQuarantine` with the host value when available. |
| Preview-shaped legacy entry | Entry is migrated into `properties.previewExposures`, not a linked input. |
| Unresolved preview exposure | Entry remains inert in `previewExposures`; it is not quarantined because it owns no user value. |
| Invalid raw `proxyWidgets` data | Logs `console.error`, does not throw, and is not quarantined. |

View File

@@ -231,6 +231,11 @@ assigning synthetic widget IDs (via `lastWidgetId` counter on LGraphState).
the ID mapping — widgets currently lack independent IDs, so the bridge must
maintain a `(nodeId, widgetName) -> WidgetEntityId` lookup.
**Promoted-widget caveat:** ADR 0009 assigns promoted value widgets a
host-boundary identity (`host node locator + SubgraphInput.name`). Interior
source node/widget identity is preserved only as migration and diagnostic
metadata.
### 2c. Read-only bridge for Node metadata
Populate `NodeType`, `NodeVisual`, `Properties`, `Execution` components by
@@ -663,6 +668,10 @@ The 6 proto-ECS stores use 6 different keying strategies:
| NodeOutputStore | `"${subgraphId}:${nodeId}"` |
| SubgraphNavigationStore | subgraphId or `'root'` |
ADR 0009 refines the promoted-widget target: promoted value widgets should use
host boundary identity (`host node locator + SubgraphInput.name`), not interior
source node/widget identity.
The World unifies these under branded entity IDs. But stores that use
composite keys (e.g., `nodeId:widgetName`) reflect a genuine structural
reality — a widget is identified by its relationship to a node. Synthetic

View File

@@ -17,6 +17,10 @@ Six stores extract entity state out of class instances into centralized, queryab
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
the host boundary (`host node locator + SubgraphInput.name`), while interior
source node/widget identity is migration and diagnostic metadata only.
## 2. WidgetValueStore
**File:** `src/stores/widgetValueStore.ts`
@@ -254,6 +258,9 @@ Each store invents its own identity scheme:
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
For promoted value widgets, ADR 0009 narrows the target key to host boundary
identity (`host node locator + SubgraphInput.name`) instead of interior source
identity.
## 6. Extraction Map

View File

@@ -404,26 +404,21 @@ Whichever candidate is chosen:
instance-specific state beyond inputs — must remain reachable. This is a
constraint, not a current requirement.
### Recommendation and decision criteria
### Decision
**Lean toward A.** It eliminates an entire subsystem by recognizing a structural
truth: promotion is adding a typed input to a function signature. The type
system already handles widget creation for typed inputs. Building a parallel
mechanism for "promoted widgets" is building a second, narrower version of
something the system already does.
[ADR 0009](../adr/0009-subgraph-promoted-widgets-use-linked-inputs.md)
chooses Candidate A for promoted value widgets. It eliminates an entire
subsystem by recognizing a structural truth: promotion is adding a typed input
to a function signature. The type system already handles widget creation for
typed inputs. Building a parallel mechanism for "promoted widgets" is building
a second, narrower version of something the system already does.
The cost of A is a migration path for existing `proxyWidgets` serialization. On
load, the `SerializationSystem` converts `proxyWidgets` entries into interface
inputs and boundary links. This is a one-time ratchet conversion — once
loaded and re-saved, the workflow uses the new format.
**Choose B if** the team determines that promoted widgets must remain
visually or behaviorally distinct from normal input widgets in ways the type →
widget mapping cannot express, or if the `proxyWidgets` migration burden exceeds
the current release cycle's capacity.
**Decision needed before** Phase 3 of the ECS migration, when systems are
introduced and the widget/connectivity architecture solidifies.
load, the `SerializationSystem` converts value-widget `proxyWidgets` entries
into interface inputs and boundary links. Once loaded and re-saved, the workflow
uses the new format. ADR 0009 separates display-only preview exposures from
promoted value widgets; those previews use their own host-scoped serialized
representation instead of linked `SubgraphInput` widgets.
---
@@ -471,14 +466,14 @@ and produces the recursive `ExportedSubgraph` structure, matching the current
format exactly. Existing workflows, the ComfyUI backend, and third-party tools
see no change.
| Direction | Format | Notes |
| --------------- | ------------------------------- | ---------------------------------------- |
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
| **Load/import** | Nested (current) or future flat | Ratchet: normalize to flat World on load |
| Direction | Format | Notes |
| --------------- | ------------------------------- | ------------------------------------------ |
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
| **Load/import** | Nested (current) or future flat | Migration: normalize to flat World on load |
The "ratchet conversion" pattern: load any supported format, normalize to the
internal model. The system accepts old formats indefinitely but produces the
current format on save.
The migration pattern: load any supported format and normalize to the internal
model. The system accepts old formats indefinitely but produces the current
format on save.
### Widget identity at the boundary
@@ -511,13 +506,12 @@ SubgraphIO {
}
```
If Candidate A (connections-only promotion) is chosen: promoted widgets become
interface inputs, serialized as additional `SubgraphIO` entries. On load, legacy
`proxyWidgets` data is converted to interface inputs and boundary links (ratchet
migration). On save, `proxyWidgets` is no longer written.
If Candidate B (simplified promotion) is chosen: `proxyWidgets` continues to be
serialized in its current format.
ADR 0009 chooses Candidate A (connections-only promotion) for promoted value
widgets: they become interface inputs, serialized as additional `SubgraphIO`
entries. On load, legacy value-widget `proxyWidgets` data is converted to
interface inputs and boundary links. On save, repaired `proxyWidgets` entries
are no longer written. Display-only preview exposures use separate
host-scoped `previewExposures` serialization.
### Backward-compatible loading contract
@@ -555,7 +549,7 @@ This document proposes or surfaces the following changes to
| World structure | Implied per-graph containment | Flat World with `graphScope` tags; one World per workflow |
| Acyclicity | Not addressed | DAG invariant on `SubgraphStructure.graphId` references, enforced on mutation |
| Boundary model | Deferred | Typed interface contracts on `SubgraphStructure`; no virtual nodes or magic IDs |
| Widget promotion | Treated as a given feature to migrate | Open decision: Candidate A (connections-only) vs B (simplified component) |
| Widget promotion | Treated as a given feature to migrate | ADR 0009 chooses Candidate A: promoted value widgets are linked inputs |
| Serialization | Not explicitly separated from internal model | Internal model ≠ wire format; `SerializationSystem` is the membrane |
| Backward compat | Implicit | Explicit contract: load any prior format, indefinitely |

View File

@@ -26,10 +26,6 @@
width: 100%;
height: 100%;
margin: 0;
/* Disable trackpad two-finger horizontal swipe back/forward navigation
and other overscroll gestures. ComfyUI is a full-screen editor; the
browser's overscroll behaviors only ever leave or break the workflow. */
overscroll-behavior: none;
}
body {
display: grid;

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.19",
"version": "1.45.4",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -11,7 +11,7 @@
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' nx build",
"build:desktop": "nx build @comfyorg/desktop-ui",
"build-storybook": "storybook build",
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
"size:collect": "node scripts/size-collect.js",

View File

@@ -523,19 +523,6 @@ export type ImportPublishedAssetsRequest = {
* IDs of published assets (inputs and models) to import.
*/
published_asset_ids: Array<string>
/**
* Optional. Share ID of the published workflow these assets belong to.
* When provided (non-null, non-empty): all published_asset_ids must
* belong to this share's workflow version; returns
* 400/CodeInvalidAssets if the share is not found or any asset does
* not belong to it.
* When omitted, null, or empty string: no share-scoped validation is
* performed and the assets are validated only against global rules
* (legacy behaviour, preserved for clients that have not yet adopted
* share_id).
*
*/
share_id?: string | null
}
/**

View File

@@ -310,8 +310,7 @@ export const zImportPublishedAssetsResponse = z.object({
* Request body for importing assets from a published workflow.
*/
export const zImportPublishedAssetsRequest = z.object({
published_asset_ids: z.array(z.string()),
share_id: z.string().nullish()
published_asset_ids: z.array(z.string())
})
/**

View File

@@ -10057,6 +10057,8 @@ export interface components {
};
progress: number;
create_time: number;
/** @description Actual credits consumed by the task. Present once status is finalized; 0 for failed tasks. */
consumed_credit?: number;
};
TripoSuccessTask: {
/** @enum {integer} */

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