Compare commits

..

56 Commits

Author SHA1 Message Date
Glary-Bot
0727a2ebf1 test: comprehensive Vue right-click context menu e2e coverage
Adds a new e2e suite filling gaps left by the existing
contextMenu.spec.ts: Node Info, Color/Shape submenus, Delete,
Run Branch (output-node only / hidden for non-output), Open in
Mask Editor, Align Selected To submenu, Distribute Nodes
submenu, and widget-extra options (Favorite/Rename Widget) on
hovered widgets.

Refactors the existing contextMenu.spec.ts to share fixtures
via a new browser_tests/fixtures/utils/contextMenuTestHelpers.ts
so both files use one canonical set of openContextMenu /
clickExactMenuItem / openMultiNodeContextMenu / getNodeRef /
getNodeWrapper helpers.
2026-05-06 03:28:33 +00:00
Yourz
e46667b33f feat: replace spin logo video with Three.js instance in homepage HeroSection (#11964)
Replace the pre-rendered `.webm` video in the homepage hero section with
an interactive Three.js 3D logo.

## Changes

- Add `three` dependency to the website package and pnpm catalog
- Add `useHeroLogo` composable that sets up the Three.js scene:
  - Extruded Comfy "C" logo with stencil-masked image sequence slideshow
  - Auto-rotation with drag interaction and cursor tilt
  - Graceful degradation if some textures fail to load
- Update `HeroSection.vue` to use the composable instead of a `<video>`
element
- Upload 16 image sequence frames to
`gs://comfy-org-videos/website/homepage/hero-logo-seq/`


<img width="1000" height="648" alt="Kapture 2026-05-05 at 20 54 05"
src="https://github.com/user-attachments/assets/7a7b1634-2da3-4aa2-871a-f64d4d337b39"
/>

@coderabbitai approve


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11964-feat-replace-spin-logo-video-with-Three-js-instance-in-homepage-HeroSection-3576d73d365081bbab0ed19dd121830c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-06 08:57:35 +08:00
Dante
d5121d3fed fix: converge asset tag cache and server on partial-failure (#11695)
## Summary

`assetsStore.updateAssetTags` issues `removeAssetTags` and
`addAssetTags` as two separate network calls. When the remove succeeds
server-side but the subsequent add rejects, the cache rolls back to the
original tags while the server has already dropped the removed tags —
cache and backend diverge until the next refetch.

This adds a compensating action: if remove succeeded and add then fails,
attempt to re-add the just-removed tags so the server returns to its
prior state. If the compensating add also fails, invalidate the category
cache so the next access refetches fresh state.

- Fixes #11694
- Fixes
[FE-473](https://linear.app/comfyorg/issue/FE-473/fix-converge-asset-tag-cache-and-server-on-partial-failure)
## Changes

- `src/stores/assetsStore.ts`: track which tags were already removed
server-side; on add-failure, re-add them; if compensation fails,
invalidate the resolved category cache via `resolveCategory` +
`invalidateCategory`.
- `src/stores/assetsStore.test.ts`: extend the cloud asset-service mock
with `addAssetTags` / `removeAssetTags` and add a `updateAssetTags
partial-failure compensation` describe block:
  - re-adds removed tags when add fails so cache and server converge
  - invalidates the category cache when compensation also fails
- does not attempt compensation when only the add was attempted (no
remove ran)



## Test plan

- [x] `pnpm exec vitest run src/stores/assetsStore.test.ts` — 42 passed
(39 prior + 3 new)
- [x] `pnpm typecheck`
- [x] `pnpm lint`
- [x] `pnpm knip`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11695-fix-converge-asset-tag-cache-and-server-on-partial-failure-34f6d73d365081149900f95b6ee4bfa9)
by [Unito](https://www.unito.io)
2026-05-06 00:26:47 +00:00
Christian Byrne
733917d5cf [chore] Update Comfy Registry API types from comfy-api@69cbc3b (#11994)
## Automated API Type Update

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

- API commit: 69cbc3b
- Generated on: 2026-05-05T15:48:16Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11994-chore-Update-Comfy-Registry-API-types-from-comfy-api-69cbc3b-3576d73d3650818f85e4c98c783a6490)
by [Unito](https://www.unito.io)

Co-authored-by: james00012 <96548424+james00012@users.noreply.github.com>
2026-05-05 23:09:25 +00:00
AustinMroz
08967bc684 Fix pruning of uninitialized promoted primitives (#11987)
Primitive nodes do not create their widgets until their
`onAfterGraphConfigured` method is called. Previously, when a
subgraphNode contains a linked widget, any proxyWidgets that do not
resolve to a real widget are pruned at time of configure. Since this
occurs prior to initialization of the primitive, the primitive value
would be de-promoted before the widget could initialize

This is resolved by the minimally disruptive change of allowing proxied
promotions to primitive nodes to be kept so long as the node itself can
be found.
2026-05-05 21:22:10 +00:00
Yourz
fb32b9a5c5 fix(website): prevent HeroSection fade from bleeding into CloudBannerSection on /download (#11974)
*PR Created by the Glary-Bot Agent*

---

## Summary

The left side of `CloudBannerSection` on `/download` showed an
unintended fade-out: the bottom-left of the banner appeared darker than
the rest of the bar.

## Root cause

`product/local/HeroSection.vue` renders an SVG illustration whose
container has `lg:z-1` and whose SVG element has `overflow-visible`. The
SVG contains a left-edge fade `<rect x="300" y="150" width="250"
height="900" fill="url(#localHeroFadeLeft)" />` that paints outside the
SVG's `viewBox` (`400 200 550 800`) — including upward into the area
occupied by the preceding `CloudBannerSection`.

Because `CloudBannerSection` had `position: static` and `z-auto`, the
positively-stacked illustration painted over the banner's bottom-left,
producing the visible darkening.

## Fix

Establish a stacking context on `CloudBannerSection` (`relative z-20`)
so it always renders above the hero illustration's overflow on every
page that includes the banner (download, api, cloud/enterprise, and
zh-CN equivalents).

This is a minimal, isolated change to the shared component — no logic or
markup structure changes.

## Verification

- Reproduced visually at `lg` breakpoint and confirmed the fade is gone
after the fix.
- Verified `/download`, `/api`, `/cloud/enterprise`, and
`/zh-CN/download` render correctly.
- `pnpm typecheck` and `pnpm typecheck:website` pass (run automatically
by pre-commit hook).
- `oxfmt`, `oxlint`, `eslint`, `stylelint` all pass.

### Before
![Before — left side of CloudBannerSection darkens with a
fade](.glary/screenshots/before-download-top.png)

### After
![After — banner background is uniform across full
width](.glary/screenshots/after-download-fix.png)

## Follow-up

Consider adding a Playwright visual regression test for the banner/hero
seam on `/download` to catch future stacking regressions (called out by
review).

## Screenshots

![Before — CloudBannerSection on /download shows a darker fade on the
bottom-left of the
banner](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5e82c32678dd22a4a48d5fa9d8b531f033d1f4ad6e0e286657e985266efd672c/pr-images/1777994095943-59c4d522-597b-49ff-a494-6a4bb52d76a0.png)

![After — CloudBannerSection on /download has uniform background across
its full width with no left-side
fade](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5e82c32678dd22a4a48d5fa9d8b531f033d1f4ad6e0e286657e985266efd672c/pr-images/1777994096270-03017b24-b01f-49b9-8fa4-0a297c57253e.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11974-fix-website-prevent-HeroSection-fade-from-bleeding-into-CloudBannerSection-on-downloa-3576d73d3650813d8924fb54d5f78cee)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-05 18:06:10 +00:00
Yourz
6474faaa17 fix(website): prevent illustration cutoff in enterprise hero section (#11973)
*PR Created by the Glary-Bot Agent*

---

## Summary

The illustration in the `HeroSection` of `/cloud/enterprise` was getting
cut off at the top — the topmost ripple ring was clipped by the
section's `overflow-y-clip`/`overflow-hidden`.

**Root cause:** the SVG wrapper has `scale-150`, which makes the
rendered illustration 50% larger than its layout box and overflows
symmetrically (~25% above, ~25% below). The section only had
`lg:pb-[min(8vw,10rem)]` — bottom padding — and on lg the wrapper had
`lg:translate-y-[40px]` to nudge it down, but that wasn't enough room
for the top to escape clipping. On mobile there was no padding at all,
so the same issue occurred.

**Fix:** add symmetric vertical padding (`pt-16` on mobile,
`lg:pt-[min(8vw,10rem)]` mirroring the existing bottom value on lg) so
the scaled illustration has room above and below. Removed the
now-unnecessary `lg:translate-y-[40px]` since symmetric padding keeps
the illustration vertically centered within the flex row.

Verified at 375px (mobile), 1024px (lg), and 1440px (xl) viewports — all
four ripple rings render fully without clipping at the top.

## Verification

- `pnpm typecheck:website` 
- `pnpm exec oxfmt --check` on edited file 
- `pnpm exec oxlint` on edited file 
- `pnpm --filter @comfyorg/website build` 
- Pre-commit hooks (stylelint, oxfmt, oxlint, eslint, typecheck,
typecheck:website) 
- Visual verification with Playwright at mobile / lg / xl

## Before vs After

**Desktop (1440px) — before:** the topmost ripple ring is clipped at the
top of the section.
**Desktop (1440px) — after:** all four ripple rings are fully visible.

**Mobile (375px) — before:** the top of the outermost ring is cut off by
the section's top edge.
**Mobile (375px) — after:** the full illustration (rings + blocks) is
visible.

## Screenshots

![Before: 1440px viewport — top ripple rings clipped at the section's
top
edge](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/69533c93dc49c06a54e467a56e2fd862864e928779a183f7e07881a177bbbf72/pr-images/1777993734642-2fe8d55a-bfba-4715-909e-96310dfebbfb.png)

![After: 1440px viewport — all four ripple rings fully
visible](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/69533c93dc49c06a54e467a56e2fd862864e928779a183f7e07881a177bbbf72/pr-images/1777993735034-59b1694b-d478-4219-8b3a-76a6e784229e.png)

![Before: 375px mobile viewport — outermost ring cut off at the
top](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/69533c93dc49c06a54e467a56e2fd862864e928779a183f7e07881a177bbbf72/pr-images/1777993735430-5c33c79f-66a4-433f-9558-aaeaaea8fcab.png)

![After: 375px mobile viewport — full illustration
visible](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/69533c93dc49c06a54e467a56e2fd862864e928779a183f7e07881a177bbbf72/pr-images/1777993735799-bd72ec20-bc5d-43d3-846d-cc793cabf29e.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11973-fix-website-prevent-illustration-cutoff-in-enterprise-hero-section-3576d73d3650813f9f04c3f93b9b42d6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-05 18:04:34 +00:00
pythongosssss
da6a3e0722 test: add tests for dragging workflow tabs (#11971)
## Summary

Adds tests for drag to reorder workflow tabs

## Changes

- **What**: 
- test drag start/end, ensure active tab is maintained

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11971-test-add-tests-for-dragging-workflow-tabs-3576d73d365081d090fccfc8804fa6aa)
by [Unito](https://www.unito.io)
2026-05-05 18:02:55 +00:00
Yourz
eecbaa8f39 fix(website): change 'Blogs' to 'Blog' in Resources nav dropdown (#11970)
*PR Created by the Glary-Bot Agent*

---

## Summary

Corrects the "Blogs" label to "Blog" (singular) in the website header's
Resources dropdown menu, as requested in #website-and-docs Slack
channel.

## Changes

- `apps/website/src/i18n/translations.ts`: `nav.blogs` English value
`Blogs` → `Blog` (zh-CN translation `博客` left unchanged since it was
already correct)

This also makes the header consistent with the footer, which already
labeled the same link as "Blog".

## Verification

- `pnpm typecheck` (astro check): 0 errors, 0 warnings, 0 hints (87
files)
- `pnpm test:unit`: 30 tests passed across 4 files
- `pnpm exec eslint apps/website/src/i18n/translations.ts`: clean
- Manual verification via Playwright on `pnpm dev` — Resources dropdown
now displays "Blog"
- Code review (oracle): 0 issues found

## Screenshot

Resources dropdown after the change:

## Screenshots

![Resources dropdown in site header showing 'Blog'
(singular)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/65e8a452a2ef553db097f1a5b55a024084f75cfc77f4f18359fcf73f31e264f8/pr-images/1777991675069-e9e180b3-54e5-48d3-8365-3abafa09df27.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11970-fix-website-change-Blogs-to-Blog-in-Resources-nav-dropdown-3576d73d365081638245d235bec04230)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-05 17:55:48 +00:00
Marwan Ahmed
0e110bec0d fix(i18n): rename OpenAI GPT Image 1 to GPT Image 2 across locales (#11968)
## Summary

Aligns the `OpenAIGPTImage1` node display name in all 11 non-English
`nodeDefs.json` locale files with the English source-of-truth, which was
already updated to `OpenAI GPT Image 2`.

## Changes

- **What**: Updates `display_name`, the description string, and the
prompt tooltip in `ja`, `ru`, `zh`, `zh-TW`, `ar`, `pt-BR`, `ko`, `fr`,
`es`, `fa`, and `tr` locales from `GPT Image 1` to `GPT Image 2` (and
`GPT Görüntü 1` → `GPT Görüntü 2` in Turkish, `GPT صورة 1` → `GPT صورة
2` in Arabic). Other tooltips already referenced `GPT Image 2` and are
unchanged.
- **Breaking**: None — the registry node id `OpenAIGPTImage1` is
preserved (it is an internal identifier, not user-facing).

## Review Focus

- Translations were updated mechanically — please confirm the
version-number change is acceptable as-is for non-Latin scripts (Arabic,
Persian, Korean, Japanese, Chinese) where the version number was kept as
`2` per the existing pattern.
- The English locale already used `OpenAI GPT Image 2`, so this PR
brings the other locales into sync; no English copy was changed.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11968-fix-i18n-rename-OpenAI-GPT-Image-1-to-GPT-Image-2-across-locales-3576d73d365081bfa204cbf528d84bf3)
by [Unito](https://www.unito.io)

Co-authored-by: Marwan Ahmed <marwan@Marwans-MacBook-Pro.local>
2026-05-05 17:54:25 +00:00
Alexander Brown
32984459bf ci: exclude release branches from website previews (#11952)
## Summary

Exclude core and cloud minor release branches from Vercel website
preview deployments.

## Changes

- **What**: Added `pull_request.branches-ignore` entries for `core/*`
and `cloud/*` to the Vercel Website Preview workflow.

## Review Focus

Confirm the branch exclusion patterns match the minor release branch
naming convention.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11952-ci-exclude-release-branches-from-website-previews-3576d73d36508194b835eda9bc12f174)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-05-05 17:20:33 +00:00
jaeone94
0307281ff2 fix: highlight missing input slots on Vue nodes (#11950)
## Summary

Restores required-input validation highlighting on Vue node input slots.

## Changes

- **What**: Passes validation error state from `NodeSlots` to
`InputSlot` using node locator IDs, including subgraph and nested
subgraph execution IDs.
- **What**: Adds unit coverage for root, one-level subgraph, and nested
subgraph slot error mapping.
- **What**: Adds a Vue Nodes screenshot regression test that asserts the
missing required input slot itself receives the error highlight.
- **Dependencies**: None.

## Review Focus

- Required input errors on Vue-rendered node's slots.
- The new Playwright screenshot expectation will need the `New Browser
Test Expectation` label for Linux baseline generation.

## Screenshots (if applicable)
Before

<img width="499" height="324" alt="스크린샷 2026-05-05 오후 3 00 44"
src="https://github.com/user-attachments/assets/285fdf91-6d7e-480b-99b9-715705f78914"
/>

After 

<img width="482" height="356" alt="스크린샷 2026-05-05 오후 3 01 11"
src="https://github.com/user-attachments/assets/51b8db49-eb9c-4155-8aa5-109c0bd7699b"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11950-fix-highlight-missing-input-slots-on-Vue-nodes-3576d73d365081bd85bfd1ea149d45c5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-05-05 14:10:35 +00:00
jaeone94
21406dceb1 fix: skip nested subgraph containers in replay scan (#11908)
## Summary

Fixes the Cloud-only nested subgraph missing-model false positive
covered by the stacked regression test in #11907.

When returning from an outer subgraph to the root graph, the Vue graph
node manager replays `onNodeAdded` for existing graph nodes. The
realtime error-clearing hook handled a subgraph container by recursively
scanning all interior nodes. For nested subgraphs, that also scanned the
nested subgraph container itself.

Nested subgraph container widgets are promoted synthetic views of
interior widgets. Scanning them as real model-loader nodes is wrong: the
container node type is the subgraph UUID, not `UNETLoader`, so Cloud
asset resolution can classify an installed promoted model as missing.

## Changes

- Skip nested subgraph container nodes during parent subgraph replay
scans.
- Keep scanning real active interior leaf nodes.
- Add unit coverage proving the replay scan visits the `UNETLoader` leaf
but not the nested subgraph container.
- Remove the `test.fail()` annotation from the Cloud E2E regression test
added in #11907.

## Stacked PR

This PR is stacked on #11907. After #11907 lands, this branch should be
rebased or retargeted onto `main`.

## Verification

- `pnpm exec vitest run
src/composables/graph/useErrorClearingHooks.test.ts -t "skips nested
subgraph containers during parent subgraph replay scan"`
- `pnpm exec oxfmt --check
src/composables/graph/useErrorClearingHooks.ts
src/composables/graph/useErrorClearingHooks.test.ts
browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts`
- `pnpm exec eslint src/composables/graph/useErrorClearingHooks.ts
src/composables/graph/useErrorClearingHooks.test.ts
browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts`
- `pnpm exec oxlint src/composables/graph/useErrorClearingHooks.ts
src/composables/graph/useErrorClearingHooks.test.ts
browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts
--type-aware`
- `pnpm typecheck`
- `pnpm typecheck:browser`
- `pnpm build:cloud`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:8188 pnpm
exec playwright test
browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts
--project=cloud`
- commit hook: `pnpm typecheck`, `pnpm typecheck:browser`
- push hook: `pnpm knip`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11908-fix-skip-nested-subgraph-containers-in-replay-scan-3566d73d3650819c8687d6ab74add1b9)
by [Unito](https://www.unito.io)
2026-05-05 21:05:54 +09:00
jaeone94
14320a131f test: add Playwright regression test for nested subgraph Cloud missing model (#11907)
## Summary

Adds a Cloud Playwright regression test for the nested subgraph case
where an installed Lotus diffusion model is incorrectly surfaced as
missing after returning to the root graph.

The fixture keeps the reproduction small: root graph -> subgraph node ->
nested subgraph node -> `UNETLoader` using
`lotus-depth-d-v1-1.safetensors`. The test stubs `/api/assets` through
the shared asset API fixture so that model is explicitly present as a
`diffusion_models` asset.

This test is intentionally written as an XFAIL regression guard. Its
setup and precondition checks are outside the XFAIL section: initial
workflow load must not show the error overlay, the Errors tab must
initially stay hidden, subgraph entry must succeed, root return must
succeed, and the replay scan must run. Only the final `Errors` tab
visibility assertion is expected to fail on current Cloud behavior.

## What a green run means

A green CI run for this PR means the Cloud-only bug was reproduced at
the intended point. The test reaches the root-return replay scan,
verifies that the replay scan ran, and then current Cloud behavior makes
the Errors tab visible even though the Lotus model exists in
`/api/assets`.

If any earlier setup or navigation step fails, or if the root-return
replay scan does not run, the test fails normally because those checks
happen before `test.fail()` is applied.

Locally, removing `test.fail()` produces the expected red result after
the replay-scan precondition passes, with `panel-tab-errors` visible.

The intended post-fix contract is that the replay scan still runs, but
the Errors tab remains hidden.

## Why this is XFAIL

This PR intentionally ships only the regression test, not the production
fix. The final behavioral assertion is annotated with `test.fail()`
because the current Cloud replay path still treats the nested subgraph
promoted model widget as missing.

When the follow-up fix lands, Playwright will report this test as an
unexpected pass until the `test.fail()` annotation is removed. That is
the handoff point for converting this regression guard into a normal
passing E2E test.

## Follow-up

The stacked fix PR is #11908. It updates the replay scan so nested
subgraph container nodes are skipped, then removes the `test.fail()`
annotation from this test.

## Verification

- `pnpm exec oxfmt --check browser_tests/fixtures/assetApiFixture.ts
browser_tests/tests/cloud-asset-default.spec.ts
browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts`
- `pnpm exec oxlint browser_tests/fixtures/assetApiFixture.ts
browser_tests/tests/cloud-asset-default.spec.ts
browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts
--type-aware`
- `pnpm exec eslint browser_tests/fixtures/assetApiFixture.ts
browser_tests/tests/cloud-asset-default.spec.ts
browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts`
- `pnpm typecheck:browser`
- `pnpm typecheck`
- `pnpm lint`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:8188 pnpm
exec playwright test
browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts
browser_tests/tests/cloud-asset-default.spec.ts --project=cloud`
- Temporarily removed `test.fail()` locally and verified the test fails
only after the replay-scan precondition passes, with `panel-tab-errors`
visible

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11907-test-add-Playwright-regression-test-for-nested-subgraph-Cloud-missing-model-3566d73d3650810b86d4de916c2852f9)
by [Unito](https://www.unito.io)
2026-05-05 11:17:30 +00:00
Christian Byrne
a763c7132c feat(website): add "comfyui app" SEO keywords to product pages (#11834)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds "comfyui app" / "comfyui web app" / "comfy ui application" keywords
to the titles and meta descriptions of the home, download, and Comfy
Cloud pages (and zh-CN equivalents) to recover organic traffic for those
queries.

## Context

Organic traffic for the query **"comfyui app"** dropped after
`https://docs.comfy.org/interface/app-mode` started outranking the
product/landing pages. The docs page about app-mode converts worse than
the product pages, so we want Google to prefer comfy.org product pages
for that query. The cleanest, lowest-risk lever is on-page SEO metadata.

## Changes

- **What**:
- `apps/website/src/pages/index.astro` → title `ComfyUI App —
Professional Control of Visual AI` + product-focused description.
- `apps/website/src/pages/download.astro` → title `Download the ComfyUI
App — Run Visual AI Locally` + desktop-app description.
- `apps/website/src/pages/cloud/index.astro` → title `Comfy Cloud — The
ComfyUI Web App` + web-app description.
- `apps/website/src/pages/zh-CN/{index,download,cloud/index}.astro` →
localised Chinese titles and descriptions so the zh-CN product pages no
longer fall back to the English `BaseLayout` default.
- `apps/website/src/layouts/BaseLayout.astro` → unchanged net-net
(touched then reverted to neutral copy after review feedback so
non-product / non-localised pages keep their existing, generic
fallback).
- **Breaking**: none. Visual content, routing, and components are
untouched — only `<title>` and `<meta>` tags change.

## Review Focus

- The keyword copy reads naturally (no stuffing) and stays under typical
SERP truncation limits (≤ ~165 chars).
- zh-CN pages get Chinese descriptions — they intentionally don't repeat
the English keywords, since "comfyui app" is an English-language query.
- Pre-existing behaviour preserved: zh-CN pages **without** an explicit
description still inherit the English `BaseLayout` default. Fixing that
fallback for the whole zh-CN tree is out of scope for this PR — happy to
follow up if desired.

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm build` — 39 pages built clean
- `pnpm test:unit` — 23/23 pass
- `pnpm format:check apps/website/src` — clean
- Manually verified rendered `<title>` and `<meta name="description">`
via Playwright on `/`, `/download`, `/cloud`, and the zh-CN equivalents.

## Screenshots

Home page rendered with the new title (visible in browser tab / SERP
preview); visual content unchanged.

## Screenshots

![Home page rendered after SEO meta changes — visual content
unchanged](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/727d10d9c63b96b716a8b45e3e96a50b2d78a4282567880f9e3c2becd80ac988/pr-images/1777704618466-41280e96-bd96-4668-8dbb-afa8e3601838.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11834-feat-website-add-comfyui-app-SEO-keywords-to-product-pages-3546d73d3650819da11bd665c2fcfb88)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-05 11:05:52 +00:00
Benjamin Lu
3f223dbbb4 test: add jobs api browser mock fixture (#11280)
## Summary

Add a typed Playwright jobs API mock and migrate the floating queue
overlay browser spec onto it.

## Changes

- replace the backend/seed terminology with `JobsApiMock`,
`jobsApiMockFixture`, `mockJobs()`, and `MockJobRecord`
- keep the mock at the network boundary with `page.route()` for
`/api/jobs`, `/api/jobs/{id}`, and `/api/history`
- remove backend-like query behavior that these browser tests do not
use, including sort handling, workflow filtering, and strict limit
validation

## Why

This keeps jobs coverage fast and profile-independent while avoiding
backend architecture changes for test setup. The fixture now serves only
the response shapes the UI consumes instead of pretending to be a
general in-memory backend.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11280-test-add-in-memory-jobs-backend-fixture-3436d73d365081bb87e8c9771654496c)
by [Unito](https://www.unito.io)
2026-05-05 03:54:41 -07:00
Christian Byrne
60f789d580 test: add OutputHistory.vue component tests (#11140)
## Summary

Add 29 Vitest component tests for `OutputHistory.vue`, which previously
had 0% coverage (132 missed lines).

## Changes

- **What**: New test file
`src/renderer/extensions/linearMode/OutputHistory.test.ts` covering
rendering, selection behavior, emit updateSelection, workflow tab
switch, media change watcher, and keyboard navigation.

## Review Focus

- Mock setup for stores (`linearOutputStore`, `workflowStore`,
`appModeStore`, `queueStore`) and composables (`useOutputHistory`)
- Keyboard navigation tests dispatching events on `document.body`
- Selection emission tests verifying `updateSelection` event payloads

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11140-test-add-OutputHistory-vue-component-tests-33e6d73d3650811692cdc36fdd41e9ba)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-05-05 03:41:07 -07:00
Yourz
24fc11aa3e fix(website): remove placeholder author info for groove-jones customer story (#11937)
*PR Created by the Glary-Bot Agent*

---

Removes the placeholder "GROOVE JONES CONTRIBUTORS" author card from the
Groove Jones customer story (`/customers/groove-jones`). The card was
rendering with `TBD` / `待补充` values for the contributor name and role.

## Change

Deletes 3 i18n keys from `apps/website/src/i18n/translations.ts`:

- `customers.detail.groove-jones.topic-10.block.2.label`
- `customers.detail.groove-jones.topic-10.block.2.name`
- `customers.detail.groove-jones.topic-10.block.2.role`

Block types in `ContentSection.vue` are inferred from the presence of
suffix keys (`.role` → `author` block) via `deriveSections` in
`apps/website/src/config/contentSections.ts`. Removing the keys causes
the author card to drop out of the rendered output entirely. The other
two blocks in topic-10 (intro paragraph + Dale Carman blockquote) remain
unchanged.

## Verification

- `pnpm typecheck` — passes
- `pnpm lint` — 0 errors (1 pre-existing warning, unrelated)
- `pnpm format` — applied
- `pnpm knip` — clean (1 pre-existing warning, unrelated)
- Manual: ran `pnpm dev` for the website app, navigated to
`/customers/groove-jones`, confirmed the conclusion section ends
naturally — no `TBD` text, no orphan `CONTRIBUTORS` label, no broken
card.

Code review (Oracle): 0 critical / 0 warning / 0 suggestion.

## Screenshots

![Conclusion section of the Groove Jones customer story — ends with the
Dale Carman blockquote, no contributor card, no TBD
placeholders](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/97021278678b2d01e3b2a67eed39f0499d7de31ad48cc414e95caca876eb37d9/pr-images/1777939400797-d8b627c0-a52c-42ab-990f-f7d36ab8ef66.jpg)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11937-fix-website-remove-placeholder-author-info-for-groove-jones-customer-story-3576d73d36508193b1a0c0c3cd887686)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-05 09:48:12 +00:00
Yourz
055486cac0 feat(website): add 4 team photos and remove infinite scroll loop (#11945)
## Summary

Add 4 new team photos and remove the infinite scroll behavior from the
careers page team photos carousel.

## Changes

- **What**:
  - Add 4 new photos (team4–team7) to `TeamPhotosSection.vue`
- Remove the infinite scroll loop (`loopedPhotos`, `onScroll` handler,
`onMounted` scroll initializer)

<img width="1000" height="308" alt="Kapture 2026-05-05 at 11 02 16"
src="https://github.com/user-attachments/assets/f5f6737f-c6bf-4abf-8780-d72c895f4015"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11945-feat-website-add-4-team-photos-and-remove-infinite-scroll-loop-3576d73d365081cabebecbc06666b9d9)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-05-05 09:47:37 +00:00
Benjamin Lu
f6ddd26cef fix: use resized QPO thumbnails (#11946)
## Summary

Use resized preview URLs for floating QPO row thumbnails so the expanded
overlay does not load full-size image assets while the canvas is being
navigated.

Linear: FE-538

## Changes

- **What**: Pass `ResultItemImpl.previewUrl` into `AssetsListItem` for
completed image/video job rows.
- **Dependencies**: None.

## Review Focus

Confirm this only changes the row thumbnail source; full asset viewing
still flows through the existing job/task preview output.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11946-fix-use-resized-QPO-thumbnails-3576d73d365081b68682d1b7b109af30)
by [Unito](https://www.unito.io)
2026-05-05 08:58:25 +00:00
pythongosssss
6822a6883d test: add tests for layout settings (#11692)
## Summary

Adds tests for UI layout settings

## Changes

- **What**: 
- add initialFeatureFlags to allow setting feature flags before initial
setup to prevent needing to reload page
- tests sidebar + topbar settings

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11692-test-add-tests-for-layout-settings-34f6d73d36508117b1daedbb68176e04)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-05-05 08:47:07 +00:00
Benjamin Lu
3637b61fcd Use Reka popover for queue job details (#11540)
## Summary
Use ShadCN-style Reka popover primitives for the live queue job list
after the unused legacy queue row implementation is removed in #11621.
This is the first step in migrating popovers toward the ShadCN UI
pattern: local design-system wrappers over Reka UI, rather than ad hoc
direct Reka or PrimeVue popovers at each call site.

## Changes
- **What**: Added the minimal ShadCN-style popover primitives needed by
this fix: `Popover`, `PopoverAnchor`, and `PopoverContent`.
- **What**: Migrated `JobAssetsList` job details from manual fixed
positioning to these popover primitives with viewport collision
handling.
- **What**: Removed the obsolete manual hover-position helper after
`JobAssetsList` stopped using it.
- **Dependencies**: No new dependencies; the primitives wrap the
existing `reka-ui` package.
- Added browser coverage for bottom-row job details clipping in the
queue overlay.

## Review Focus
- This PR is stacked on #11621.
- The live queue surfaces are `JobAssetsList` consumers: expanded queue
progress overlay and job history sidebar.
- The new `src/components/ui/popover` files intentionally seed the
ShadCN-style migration path, but only include the pieces used here to
keep the first PR small.
- Follow-up PRs can add `PopoverTrigger` and migrate existing
PrimeVue/direct-Reka popovers once there is an actual caller.
2026-05-05 01:49:12 -07:00
Christian Byrne
d1df5fadf8 fix(website): update payment-failed heading to "Unable to complete payment" (#11943)
*PR Created by the Glary-Bot Agent*

---

## Summary

Reword the `payment.failed.title` copy on `comfy.org/payment/failed`
from "Payment was not completed" to "Unable to complete payment" — a
more active, distinguishing phrasing per design feedback.

## Changes

- `apps/website/src/i18n/translations.ts` — update English (`Unable to
complete payment`) and Chinese (`无法完成支付`) translations
- `apps/website/e2e/payment.spec.ts` — update both English and zh-CN
heading assertions to match

## Verification

- `pnpm --filter website typecheck` — passes
- `pnpm --filter website test:unit` — 30 tests passing
- Pre-commit hooks (oxfmt, oxlint, eslint, typecheck, typecheck:website)
— all pass
- Manual visual verification with Playwright on `/payment/failed` and
`/zh-CN/payment/failed` — both render the new heading correctly
(screenshots attached)

## Screenshots

![English /payment/failed page showing 'Unable to complete payment'
heading](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/704ef878d4d0c345146cd20fece87d6edf7e1cd0bbe5094daad0f00b414035fe/pr-images/1777946058984-deb77e19-c7ce-4c25-a004-c8425856145e.png)

![Chinese /zh-CN/payment/failed page showing the new
heading](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/704ef878d4d0c345146cd20fece87d6edf7e1cd0bbe5094daad0f00b414035fe/pr-images/1777946059339-c50ac07c-d4ed-46af-992b-0c56ac469c23.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11943-fix-website-update-payment-failed-heading-to-Unable-to-complete-payment-3576d73d3650817e85e2e7a3891cc307)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-05 08:30:12 +00:00
Christian Byrne
7d67fe364b [chore] Update Comfy Registry API types from comfy-api@274f83b (#11948)
## Automated API Type Update

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

- API commit: 274f83b
- Generated on: 2026-05-05T04:14:20Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11948-chore-Update-Comfy-Registry-API-types-from-comfy-api-274f83b-3576d73d3650813b9a39f8d0f7183445)
by [Unito](https://www.unito.io)

Co-authored-by: coderfromthenorth93 <213232275+coderfromthenorth93@users.noreply.github.com>
2026-05-05 08:03:32 +00:00
Comfy Org PR Bot
7c2321cc23 1.44.17 (#11938)
Patch version increment to 1.44.17

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11938-1-44-17-3576d73d365081e89010e68cbf1c2625)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-05 07:56:34 +00:00
Kelly Yang
a877ccde94 Test/edit attention unit tests (#11301)
## Summary

A follow-up PR of
https://github.com/Comfy-Org/ComfyUI_frontend/issues/11107.


## Changes

Add unit test to `editAttention.ts` 
- [x] `Extract pure functions to module level`: **Moved**
`incrementWeight`, `findNearestEnclosure`, and `addWeightToParentheses`
out of the `init()` closure and **promoted** them to module-level
functions with `export` to allow for independent testing.
- [x] `Add unit tests for incrementWeight`: **Added** 6 tests covering
edge cases such as normal increment/decrement, NaN input, negative
weights, and floating-point precision.
- [x] `Add unit tests for findNearestEnclosure`: **Added** 7 tests
covering edge cases including simple brackets, no brackets, cursor
outside, nested brackets (inner/outer), empty strings, and missing
closing brackets.
- [x] `Add unit tests for addWeightToParentheses`: **Added** 6 tests
covering scenarios like adding a default 1.0 weight, retaining existing
weights, no changes when brackets are absent, scientific notation
weights, negative weights, and multi-word tokens.
- [x] `Mock app module`: **Used** `vi.mock('@/scripts/app')` to
intercept side effects from `app.registerExtension`, **preventing** the
triggering of ComfyUI extension registration logic during module import.


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Adjusts token selection/weight-detection logic used during
Ctrl/Cmd+Arrow editing, which could subtly change how prompts are
rewritten in edge cases (nested parens, scientific notation, time-like
text). Adds tests that should reduce regression risk but behavior
changes still warrant verification in the UI.
> 
> **Overview**
> Adds a new `vitest` unit test suite for `editAttention` by mocking
`app.registerExtension` side effects and validating `incrementWeight`,
`findNearestEnclosure`, and `addWeightToParentheses` across common and
edge cases.
> 
> Refactors those helpers to exported module-level functions and
tightens parsing/selection behavior: `findNearestEnclosure` now handles
the cursor being on an opening `(`, `addWeightToParentheses` improves
trailing weight detection (supports scientific notation/negatives and
avoids misclassifying time-like `12:30`), and the weight-rewrite regex
now matches exponent forms.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
df20340b49. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11301-Test-edit-attention-unit-tests-3446d73d365081f29e8dfceefc06f5d3)
by [Unito](https://www.unito.io)
2026-05-05 06:48:26 +00:00
Dante
e3883f4a2c test: add unit tests for layoutStore setter and query paths (#11747)
## Summary

Adds 11 tests for \`src/renderer/core/layout/store/layoutStore.ts\`
covering paths previously uncovered by the existing 17-test suite.
Targets the customRef setter machinery, reactive queries, and
link-layout updates that are reachable through the public API.

## Test Coverage

\`getNodeLayoutRef\` setter:
- Setter on a fresh ref triggers \`createNode\`.
- Position-only change triggers \`moveNode\`.
- Size-only change triggers \`resizeNode\`.
- zIndex-only change triggers \`setNodeZIndex\`.
- Setting to \`null\` triggers \`deleteNode\`.

Queries:
- \`getNodesInBounds\` returns reactive node IDs intersecting the
bounds.
- \`queryNodeAtPoint\` returns the top-zIndex node containing the point.
- \`queryNodeAtPoint\` returns \`null\` when no node contains the point.

Link layouts:
- \`updateLinkLayout\` short-circuits when bounds and centerPos
unchanged but still updates the path.
- \`updateLinkLayout\` replaces stored layout when bounds change.
- \`deleteLinkLayout\` removes the link and its segment layouts.

## Testing

\`\`\`bash
pnpm vitest run src/renderer/core/layout/store/layoutStore.test.ts
\`\`\`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11747-test-add-unit-tests-for-layoutStore-setter-and-query-paths-3516d73d365081d9bc1de336ff7258ea)
by [Unito](https://www.unito.io)
2026-05-05 05:03:20 +00:00
Kelly Yang
5e16802832 refactor: remove @ts-expect-error suppressions in CustomizationDialog (#11339)
… (issue #11092 phase 4b)

## Summary

Part of #11092 — Phase 4b: remove 2 `@ts-expect-error` suppressions from
`CustomizationDialog.vue`.

## Changes

`selectedIcon` ref initialisation and `resetCustomization` assignment
both suppressed a type error on `Array.find()` returning `T |
undefined`.

**Why**

`Array.find()` has no way to statically guarantee a match, so its return
type is always `T | undefined`. Both usages were searching `iconOptions`
— a literal array of 8 entries declared in the same scope — and
TypeScript could not prove that the searched value would always be
found, even though at runtime it always is (the default icon value is
defined from `iconOptions[0]`).

**How**

Added `iconOptions[0]` as a final fallback via `??` in both places.
Because `iconOptions` is a non-empty literal array, `iconOptions[0]` is
provably non-null to TypeScript, which makes the overall expression type
`T` and satisfies the assignment. The explicit generic on `ref<{ name:
string; value: string }>` was also dropped — TypeScript infers the type
correctly from the non-nullable initialiser. In `resetCustomization`,
`||` was replaced with `??` since the values being null-coalesced are
objects (never falsy), making `??` the semantically precise operator for
this case.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: UI-only refactor that adds explicit fallbacks for
`Array.find()` results and introduces a small unit test suite; behavior
should remain the same except for safer handling of unexpected icon
values.
> 
> **Overview**
> Removes two `@ts-expect-error` suppressions in
`CustomizationDialog.vue` by making `selectedIcon` initialization and
`resetCustomization` use a guaranteed fallback (`iconOptions[0]`) via
`??`, ensuring the selected icon is never `undefined`.
> 
> Adds `CustomizationDialog.test.ts` to verify `confirm` emits the
expected icon/color for default, provided initial values, and an invalid
`initialIcon` fallback.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
f77addf713. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11339-refactor-remove-ts-expect-error-suppressions-in-CustomizationDialog-3456d73d36508165865ac569e047db2e)
by [Unito](https://www.unito.io)
2026-05-04 21:41:09 -04:00
Kelly Yang
0e9a5ecbe9 refactor: extract GPU lifecycle into useGPUResources (phase D) (#11784)
## Summary
Phase D of the **useBrushDrawing-refactor plan.md**. Extract `WebGPU`
state management from `useBrushDrawing` into a dedicated
`useGPUResources` composable, reducing `useBrushDrawing` from ~1,160
lines to ~230. This is Phase D of the ongoing `useBrushDrawing`
decomposition (Phases A–C landed in previous PRs).
   
## Changes
- **What**: Split `useBrushDrawing` along a clean boundary — GPU
device/texture lifecycle moves to `useGPUResources`, stroke
orchestration stays in `useBrushDrawing`. Shared reactive state
(`dirtyRect`, `isSavingHistory`, `previewCanvas`) is now owned by
`useGPUResources` and exposed as refs. A pure
   `clampDirtyRect` helper is extracted to `gpuUtils.ts`.
- **Dependencies**: No new dependencies
## Tests
Local test - pass            


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Refactors WebGPU initialization, texture management, and readback
paths used during drawing; regressions could affect stroke rendering,
canvas visibility, and undo/redo GPU sync.
> 
> **Overview**
> Extracts WebGPU device/texture/renderer lifecycle, watchers (clear,
undo/redo sync, texture recreation), and readback logic out of
`useBrushDrawing` into a new `useGPUResources` composable, with shared
refs (`dirtyRect`, `isSavingHistory`, `previewCanvas`, `hasRenderer`)
now owned by that module.
> 
> Updates `useBrushDrawing` to delegate GPU-specific operations
(prepare/render/draw point/composite/readback/cleanup) to
`useGPUResources` while keeping CPU drawing + stroke orchestration, and
adds new pure helpers in `gpuUtils` (`clampDirtyRect`,
`buildStrokePoints`) to centralize dirty-rect clamping and stroke point
resampling.
> 
> Adds Vitest coverage for the new helpers, `useGPUResources`
no-op/error behavior when GPU isn’t available, and `useBrushDrawing`
interactions with the extracted GPU API (composition mode selection,
shift-line, history save, and canvas/preview opacity restoration).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
a9fcd80ab5. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->



┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11784-refactor-extract-GPU-lifecycle-into-useGPUResources-phase-D-3526d73d365081108a97c164a0bfa13e)
by [Unito](https://www.unito.io)
2026-05-04 20:49:10 -04:00
Dante
9013102db9 fix: use capitalize for keybinding badges (#11810)
## Summary

Render keybinding badges in sentence case (`Ctrl + Shift + A`) instead
of UPPERCASE (`CTRL + SHIFT + A`) by swapping the `uppercase` Tailwind
class for `capitalize` in `KeyComboDisplay.vue`.

`KeyComboImpl.getKeySequences()` already returns labels in their
canonical form (`Ctrl`, `Alt`, `Shift`, plus the raw key). The badge
styling was forcing them all to UPPER, which is what FE-524 calls out.
`text-transform: capitalize` cleanly handles every case: lower modifier,
upper modifier, and single character keys.

- Fixes FE-524

## Before / After

| Before (`uppercase`) | After (`capitalize`) |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/c6bb96fce/docs/screenshots/fe-524/before.png"
width="480"> | <img
src="https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/c6bb96fce/docs/screenshots/fe-524/after.png"
width="480"> |

## Test plan

- [ ] Open Settings → Keybinding panel and confirm modifier badges
render as `Ctrl`, `Alt`, `Shift` instead of `CTRL`, `ALT`, `SHIFT`
- [ ] Confirm single-letter keys (e.g. `A`, `S`) still render uppercase
- [ ] Edit a keybinding and verify the live preview badges in the dialog
also render in sentence case
2026-05-04 20:38:31 -04:00
Christian Byrne
6ea5a5e32d fix(load3d): preserve unknown Model Config fields with spread (#11838)
## Summary

Use spread pattern when writing `nodeValue.properties['Model Config']`
so future ModelConfig fields are preserved across viewer dialog
cancel/apply.

## Changes

- **What**: Spread existing `Model Config` before applying known keys in
both `restoreInitialState()` and `applyChanges()` in
[useLoad3dViewer.ts](src/composables/useLoad3dViewer.ts). Removes the
hard-coded `showSkeleton: false` override from `applyChanges()` so it
falls through from the existing config.

## Review Focus

The change is intentionally minimal and matches the suggestion in the
upstream issue. Two regression tests added (one each for restore/apply)
verify that an unknown future field on Model Config survives both code
paths.

Fixes #11346

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11838-fix-load3d-preserve-unknown-Model-Config-fields-with-spread-3546d73d3650819686efc4e1a9799ad9)
by [Unito](https://www.unito.io)
2026-05-04 20:32:57 -04:00
Alexander Brown
90b3d8a5c6 test: add mask editor brush adjustment and layer management browser tests (#11368)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds `browser_tests/tests/maskEditorBrushLayers.spec.ts` covering
untested brush settings interaction and tool/layer management in the
mask editor.

### Coverage gaps filled
- `useBrushDrawing.ts` — brush thickness/opacity/hardness slider
interaction
- `useToolManager.ts` — tool switching with independent mask data, data
preservation across tool switches

### Test cases (5 tests, 2 groups)
| Group | Tests | Behavior |
|---|---|---|
| Brush settings | 3 | Thickness slider changes size, opacity slider
changes opacity, hardness slider changes hardness |
| Layer management | 2 | Different tools produce independent mask data,
switching tools preserves previous mask data |

### References
- Reuses patterns from existing `maskEditor.spec.ts` (`loadImageOnNode`,
`openMaskEditorDialog`, `drawStrokeOnPointerZone`,
`getMaskCanvasPixelData`)
- Follows `browser_tests/AGENTS.md` directory structure
- Follows `browser_tests/FLAKE_PREVENTION_RULES.md` assertion patterns

### Verification
- TypeScript: clean
- ESLint: clean
- oxlint: clean
- oxfmt: formatted

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11368-test-add-mask-editor-brush-adjustment-and-layer-management-browser-tests-3466d73d36508170ae24ebea2b73d60d)
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-04 20:31:15 -04:00
Christian Byrne
551cf21fb1 fix(load3d): reapply up-direction after fitToViewer() transform reset (#11826)
## Summary

`fitToViewer()` in `SceneModelManager` resets the model rotation to
`(0,0,0)` as part of its normalize-and-scale flow, but does not reapply
`currentUpDirection` afterward. This causes a state/view mismatch when
the user has previously selected a non-default up-axis (e.g. `+x`,
`-z`): the model snaps back to its raw orientation while the Up
Direction control still shows the previously selected value.

## Changes

- In `fitToViewer()`, clear `originalRotation` (to avoid compounding
with the stale pre-fit base) then reapply `currentUpDirection` if it is
not `'original'`
- Add 5 unit tests covering: no-op when no model, reapplication of
direction, no rotation compounding on repeated calls, zero rotation for
`'original'` direction, and stale `originalRotation` guard

## Testing

### Automated

- `src/extensions/core/load3d/SceneModelManager.test.ts` — 5 new tests
in `describe('fitToViewer')`
- All 48 unit tests pass

### E2E Verification Steps

1. Open the Load3D viewer with any 3D model
2. Change **Up Direction** to any non-default value (e.g. `+x` or `-z`)
— observe model rotates correctly
3. Click **Fit to Viewer** — model should remain in the chosen
up-direction orientation, not snap back to raw orientation
4. Click **Fit to Viewer** again — rotation should remain stable (no
compounding)
5. Change Up Direction back to `original` then click **Fit to Viewer** —
model should return to neutral orientation `(0,0,0)`

## Review Focus

The key invariant: `fitToViewer()` resets `rotation.set(0,0,0)`
explicitly, so we must clear `originalRotation = null` before calling
`setUpDirection`. Otherwise `setUpDirection` restores the stale pre-fit
rotation as a base and then adds the direction offset on top,
compounding incorrectly.

Fixes #11347

<!-- Pipeline-Ticket: pick-issue-3414 -->

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11826-fix-load3d-reapply-up-direction-after-fitToViewer-transform-reset-3546d73d36508166b9bcecc9949c4952)
by [Unito](https://www.unito.io)
2026-05-04 20:29:24 -04:00
Christian Byrne
2c8ecd82ec fix(load3d): snapshot original materials before reapplying materialMode (#11825)
## Summary

Fixes a bug where models reloaded in wireframe/normal/depth modes would
not restore to their original materials correctly, because the material
snapshot was being taken *after* the mode was applied.

## Changes

- Move `setupModelMaterials(model)` to immediately after
`scene.add(model)` and before `setMaterialMode()` / `setUpDirection()`
in `SceneModelManager.setupModel()`
- Save `materialMode` into `pendingMaterialMode` before calling
`setupModelMaterials()`, since the latter internally calls
`setMaterialMode('original')` which resets `this.materialMode` —
preserving the value ensures the subsequent reapplication guard works
correctly
- Update stale test assertion that reflected the old (incorrect) call
order
- Add regression test: verifies that `originalMaterials` captures the
pre-mutation material and that restoring to `'original'` after a
non-original load gives back the true original mesh material

## Testing

### Automated

- `src/extensions/core/load3d/SceneModelManager.test.ts` — 44 tests, all
pass
- Full load3d test suite — 401 tests, all pass

### E2E Verification Steps

1. Open ComfyUI with a Load3D node
2. Load any GLB/OBJ model
3. Switch Material Mode to **Wireframe** (or Normal/Depth)
4. Load a different model (or reload the same one)
5. Switch Material Mode back to **Original**
6. Verify the model renders with its original diffuse/PBR materials (not
wireframe)

## Review Focus

The key invariant: `setupModelMaterials()` must snapshot mesh materials
in their *unmodified* state. It must run before any `setMaterialMode()`
call that mutates them. The `pendingMaterialMode` variable is needed
because `setupModelMaterials()` internally calls
`setMaterialMode('original')`, which updates `this.materialMode`, making
the subsequent guard `if (this.materialMode !== 'original')` silently
skip reapplication without it.

Fixes #11344

<!-- Pipeline-Ticket: pick-issue-3417 -->

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11825-fix-load3d-snapshot-original-materials-before-reapplying-materialMode-3546d73d3650818b9c35fa60c15f17a3)
by [Unito](https://www.unito.io)
2026-05-04 20:28:09 -04:00
Kelly Yang
7b59c561ff fix(load3d): update renderer pixel ratio on canvas zoom to fix LOD resolution (#11734)
## Summary

Preview 3D and Animation nodes were stuck at the LOD from initial page
load because CSS `scale3d` transforms don't affect
`clientWidth`/`clientHeight` — `handleResize()` always received
layout-space dimensions regardless of zoom level. This fix passes
`ds.scale` as the renderer pixel ratio so the 3D scene renders at the
correct visual resolution when the graph is zoomed in or out.

## Changes

- **What**: In `Load3d.handleResize()`, call
`renderer.setPixelRatio(ds.scale)` before `setSize` so pixel density
scales with canvas zoom. A `getZoomScale` callback is threaded through
`Load3DOptions` → `Load3d` constructor → `handleResize`. In `useLoad3d`,
a watcher on `canvasStore.appScalePercentage` triggers `handleResize`
whenever the zoom level changes.
- **What**: Fix `SceneManager.captureScene()` to save and restore the
renderer's logical size and pixel ratio around capture, so exact-pixel
output is unaffected by the current zoom state.

## Review Focus

- `handleResize` now calls `setPixelRatio` before `setSize`. Three.js
renders at `logicalWidth × pixelRatio` physical pixels while CSS
displays it at `logicalWidth` CSS pixels — this is the standard pattern
for HiDPI but here used to match the visual zoom level.
- `captureScene` must reset `pixelRatio` to 1 so `setSize(w, h)`
produces exactly `w×h` pixel output. It saves and restores both logical
size and pixel ratio via `renderer.getSize()` /
`renderer.getPixelRatio()`.
- The zoom watcher is guarded with `getActivePinia()` to avoid errors in
unit tests and non-Pinia contexts.

## Test
before


https://github.com/user-attachments/assets/9778ad54-7cb2-4fdc-b200-65a683ee8e4d

after


https://github.com/user-attachments/assets/acfaaf7a-43c7-495f-b352-5dd2cdaa94db

## Analysis Report

https://linear.app/comfyorg/issue/FE-401/bug-preview-3d-and-animation-nodes-lod-stuck-at-initial-page-load

## More
- Add `debounce` and pixel ratio limit


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Medium risk because it changes core `Load3d.handleResize()` rendering
behavior (pixel ratio/LOD) and adds a debounced zoom-driven resize
watcher, which could affect performance or visual output across all
Load3D nodes. Capture logic is also refactored to manipulate renderer
size/pixel ratio and camera params, so regressions would show up in
thumbnails/exports.
> 
> **Overview**
> Fixes Load3D LOD/render sharpness when the graph canvas is zoomed by
threading a new `getZoomScale` option from `useLoad3d` into `Load3d` and
using it to call `renderer.setPixelRatio()` (clamped) during
`handleResize()`.
> 
> Adds a debounced watcher on `canvasStore.appScalePercentage` to
trigger `handleResize()` on zoom changes, and updates
`SceneManager.captureScene()` to temporarily force pixel ratio 1 and
restore renderer size/pixel ratio and camera settings after capture.
Coverage is expanded with new Playwright smoke coverage plus unit tests
for zoom propagation, debouncing, pixel ratio behavior, and capture
state restoration.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
261940d111. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->





┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11734-fix-load3d-update-renderer-pixel-ratio-on-canvas-zoom-to-fix-LOD-resolution-3516d73d365081e6b3d4cdd05f516489)
by [Unito](https://www.unito.io)
2026-05-04 20:25:55 -04:00
LifetimeVip
8b1d564729 chore(i18n): correct zh and zh-TW translations (#11930)
## Summary

Fixes several issues in both Simplified Chinese (zh) and Traditional
Chinese (zh-TW) locale files that were identified through systematic
comparison against the English source.

### Changes

**nodeDefs.json (zh + zh-TW):**
- **CLIPLoader.description**: Added missing model recipes (lumina2, wan,
hidream, omnigen2) to match English source
- **ByteDanceSeedreamNode.display_name**: Updated version from "Seedream
4" to "Seedream 4.5 and 5.0" to match English
- **BriaImageEditNode.display_name**: Added missing "FIBO" model name

**nodeDefs.json (zh only):**
- **APG.inputs.eta.name**: Fixed incorrect translation "预计到达时间" (ETA) ->
kept as "eta" (technical parameter name)

**commands.json (zh + zh-TW):**
- **Comfy_ToggleLinear**: Updated label to match English "Toggle App
Mode"
- **Experimental_ToggleVueNodes**: Rebranded "Vue 节点"/"Vue 節點" to "Nodes
2.0" to match English

**settings.json (zh + zh-TW):**
- **Comfy_VueNodes_Enabled / Comfy_VueNodes_AutoScaleLayout**: Rebranded
"Vue 节点"/"Vue 節點" to "Nodes 2.0"

**main.json (zh + zh-TW):**
- **errorDialog.accessRestrictedMessage / accessRestrictedTitle**: Added
missing Chinese translations

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11930-fix-i18n-correct-zh-and-zh-TW-translations-3566d73d365081ff9b0beb1c1fc7ef1a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: LifetimeVip <lifetimevip@users.noreply.github.com>
2026-05-05 00:05:51 +00:00
Christian Byrne
ea2e8e59f2 test: add MembersPanelContent unit tests (#11402)
## Summary

Add 27 unit tests for MembersPanelContent component covering workspace
views, member management, and billing states.

## Changes

- **What**: New test file for MembersPanelContent with 27 tests across 8
describe blocks (personal workspace, owner view, member view, sorting,
search filtering, pending invites, single seat plan, member count
display)

## Review Focus

- Uses `@testing-library/vue` + `@testing-library/user-event` per
project conventions
- Component stubs (Button, SearchInput, Menu, UserAvatar) for isolation
- Reactive mock refs in `vi.hoisted()` shared across `vi.mock()` calls

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11402-test-add-MembersPanelContent-unit-tests-3476d73d36508107abcbce95b72b3fb7)
by [Unito](https://www.unito.io)
2026-05-04 20:02:15 -04:00
Christian Byrne
1f60f7cfcc test: add unit tests for useImageCrop composable (#11138)
## Summary

Add 40 unit tests for `useImageCrop` composable (previously 0% coverage,
277 missed lines).

## Changes

- **What**: New test file `src/composables/useImageCrop.test.ts`
covering:
  - Crop computed properties (read/write/defaults)
  - `cropBoxStyle` computation
  - `selectedRatio` / `isLockEnabled` aspect ratio locking
  - `applyLockedRatio` with boundary clamping
  - `resizeHandles` filtering (8 handles unlocked, 4 corners locked)
  - `handleImageLoad` / `handleImageError`
  - Drag start/move/end with boundary clamping
  - Resize from all 4 edges + MIN_CROP_SIZE enforcement
  - Constrained resize with locked aspect ratio (corner handles)
  - `getInputImageUrl` with subgraph node resolution
  - `updateDisplayedDimensions` for landscape/portrait/zero dimensions
  - `initialize` with `resolveNode` lookup

## Review Focus

Test-only change. Mocks `resolveNode`, `useNodeOutputStore`, and
`useResizeObserver`. No production code changes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11138-test-add-unit-tests-for-useImageCrop-composable-33e6d73d365081e6aa06e98b66feb585)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-05-04 19:55:06 -04:00
Christian Byrne
5e3266e0c2 test: add e2e tests for node replacement flows (#11242)
## Summary

Add Playwright e2e tests for the node replacement feature (swap nodes UI
in the errors tab).

## Changes

- **What**: 6 e2e test cases across two describe blocks covering single
and multi-type node replacement flows. Tests verify swap nodes group
visibility, in-place replacement, widget value preservation, Replace All
across multiple types, output connection preservation, and success toast
display. Includes typed mock data for `/api/node_replacements` and two
workflow fixture files with fake missing node types mapped to real core
nodes.

## Review Focus

- Mock setup pattern in `setupNodeReplacement` — enables feature flag
via `page.evaluate` and routes the API endpoint
- Workflow fixture design — uses fake node types (E2E_OldSampler,
E2E_OldUpscaler) that map to real registered types (KSampler,
ImageScaleBy)
- Assertion coverage for link preservation after replacement

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11242-test-add-e2e-tests-for-node-replacement-flows-3426d73d3650811e87d7f0d96fd66433)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-04 15:52:33 -07:00
Terry Jia
b5b502755f fix(load3d): parse [output]/[input]/[temp] annotation when loading (#11805)
## Summary
The Vue node model picker mixes output assets into the dropdown with a
trailing ' [output]' suffix on the value. Forwarding that string to
loadModel as a literal filename under the configured input folder caused
a 404 and the model never rendered. Strip the trailing folder annotation
per call and use the matching folder so picking an output asset loads
correctly while plain values keep the configured folder.

Output assets stored under a subfolder (e.g. 3d/) were exposed in the
Vue node dropdown as just '<filename> [output]', so selection produced
an /api/view URL with empty subfolder and a 404. Read the subfolder from
the asset's OutputAssetMetadata and prefix it onto the annotated path so
the downstream load handler can split it back out and target the correct
folder.

## Screenshots (if applicable)

https://github.com/user-attachments/assets/463d1071-efdc-46a4-b101-8e1c012d19c7

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11805-fix-load3d-parse-output-input-temp-annotation-when-loading-3536d73d365081a8ac9cf75d14ae29e6)
by [Unito](https://www.unito.io)
2026-05-04 18:44:52 -04:00
pythongosssss
5fbcea6b27 test: add test for workflow delete confirmation (#11780)
## Summary

Adds tests for the `Comfy.Workflow.ConfirmDelete` setting

## Changes

- **What**: 
- ensures dialog does/doesnt appear based on the setting

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11780-test-add-test-for-workflow-delete-confirmation-3526d73d36508134a3cdf0e908b95919)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-05-04 21:51:50 +00:00
Comfy Org PR Bot
ac36dc47a4 docs: Weekly Documentation Update (#11465)
## Summary

Fixed two minor documentation inaccuracies found during comprehensive
documentation audit:
- Corrected outdated "Lodash" reference to "Utility Functions" in unit
testing guide
- Updated package manager command from `npx` to `pnpm dlx` in Playwright
skill documentation

## Changes Made

### Documentation Fixes

#### docs/testing/unit-testing.md:150
- **Before**: `## Mocking Lodash Functions`
- **After**: `## Mocking Utility Functions`
- **Reason**: The section describes mocking `es-toolkit/compat`
functions, not Lodash. The project uses es-toolkit as stated in
AGENTS.md line 158 and docs/guidance/typescript.md line 60.

#### .claude/skills/writing-playwright-tests/SKILL.md:117
- **Before**: `npx playwright show-trace trace.zip`
- **After**: `pnpm dlx playwright show-trace trace.zip`
- **Reason**: Project standardizes on pnpm, explicitly avoiding npx per
AGENTS.md line 42: "use `pnpx` or `pnpm dlx` — never `npx`"

## Audit Summary

Comprehensive audit verified accuracy of:
-  Core documentation (CLAUDE.md, AGENTS.md, README.md)
-  All docs/**/*.md files (40+ files including ADRs, testing guides,
architecture docs)
-  All README files throughout repository (21 files)
-  All .claude/commands/*.md files (8 files)
-  Code examples and API references
-  File structure references (verified src/router.ts, src/i18n.ts,
src/main.ts, config files exist)
-  Package dependencies (es-toolkit ^1.39.9 confirmed)
-  Script commands (pnpm test:unit, pnpm test:browser:local, etc.)
-  External resource links
-  ADR index and dates

All other documentation remains accurate and up-to-date as of
2026-05-04.

## Review Notes

This PR contains only two trivial corrections to terminology/commands.
No functional changes, no code changes, no breaking changes. The
documentation audit found the codebase documentation to be in excellent
condition overall.

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-05-04 21:37:52 +00:00
Christian Byrne
aef71852f0 feat: add demo pages with Arcade embeds at /demos/{slug} (#11436)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds a demo pages system to the website that embeds Arcade interactive
walkthroughs at `comfy.org/demos/{slug}`. These pages will be linked
from welcome/lifecycle emails via Customer.io.

- Adds `/demos/image-to-video` and `/demos/workflow-templates` as the
first two demos
- Follows the existing `customers/[slug].astro` pattern exactly
(config-driven `getStaticPaths()`)
- Full SEO: OG/Twitter cards, HowTo + LearningResource + BreadcrumbList
JSON-LD schemas
- GEO: AI crawler directives in robots.txt, crawlable transcript
alongside iframe
- A11y: iframe title, sr-only transcript, aria-expanded toggle, noscript
fallback
- Email optimization: 1200x630 OG images, SSG pre-rendered, preconnect
to Arcade CDN
- Full zh-CN localization
- Library index stub at /demos for future expansion
- Automatic sitemap inclusion

## Architecture

Adding a new demo = adding one object to `src/config/demos.ts`.

## Note

OG images are tiny placeholders — replace with real 1200x630 screenshots
before go-live.

## Screenshots

![Demo detail page showing Arcade embed with full design
system](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/d4e44d93c258f779ed62667c7924810f9ae7f20f0c9105acd9c3f86f63816bd1/pr-images/1776645565133-5566bf1b-e965-437d-b21f-89e7a751f883.png)

![Demo library index - Coming Soon
stub](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/d4e44d93c258f779ed62667c7924810f9ae7f20f0c9105acd9c3f86f63816bd1/pr-images/1776645565461-0e334640-13e6-4554-ad6e-b3843e107572.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11436-feat-add-demo-pages-with-Arcade-embeds-at-demos-slug-3486d73d365081abbd72e02bf497a43a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 14:53:36 -07:00
Dante
94b570a177 test: rename misnamed Mixpanel test and cover the actual provider class (#11749)
## Summary

The existing \`MixpanelTelemetryProvider.test.ts\` was misnamed: it only
tested \`getExecutionContext\` from \`../../utils/getExecutionContext\`,
never the provider class itself — provider coverage sat at **0%**
despite a 239-line test file living next to it.

This PR:

1. **Renames** the existing test file to
\`src/platform/telemetry/utils/getExecutionContext.test.ts\` (co-located
with the source it actually tests). Updates its relative import to
\`./getExecutionContext\`.
2. **Adds** a fresh
\`src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts\`
covering the provider class.

Lifts provider coverage from **0% → 81.1%** lines (functions 73.5%,
branches 88.5%).

## Test Coverage (new tests)

Constructor / initialization:
- Without \`mixpanel_token\`, warns and disables itself; subsequent
\`trackXxx\` calls are no-ops.
- With \`mixpanel_token\`, dynamically imports mixpanel-browser, calls
\`init\`, and after \`loaded()\` fires identifies users via
\`onUserResolved\`.

Queueing semantics:
- Events fired before \`loaded()\` are queued and flushed in order once
Mixpanel reports ready.

Filtering:
- Events listed in the default \`disabledEvents\` set (e.g.
\`workflow_opened\`) are suppressed.

Direct dispatchers (parameterized \`it.each\`):
- 16 \`trackXxx\` methods covered: signup/auth/login, subscription
lifecycle, credit topup events, template lifecycle, workflow
imported/saved, default-view, enter-linear, share-flow, execution
success/error.
- \`trackApiCreditTopupButtonPurchaseClicked\` payload includes
\`credit_amount\`.
- \`trackEmailVerification\` dispatches the matching
\`USER_EMAIL_VERIFY_*\` event for each stage.
- \`trackSubscription\` maps \`'modal_opened'\` and
\`'subscribe_clicked'\` to their distinct events.
- \`trackRunButton\` populates \`RunButtonProperties\` from the
execution context.
- \`trackWorkflowExecution\` consumes the latest \`trigger_source\` from
\`trackRunButton\`, then resets it to \`'unknown'\`.

Survey:
- On \`'submitted'\`, normalized properties are written to
\`Mixpanel.people\`.
- On \`'opened'\`, \`Mixpanel.people\` is not touched.

Topup delegation:
- \`startTopupTracking\`, \`clearTopupTracking\`,
\`checkForCompletedTopup\` all forward to the \`topupTracker\` utility.

## Testing

\`\`\`bash
pnpm vitest run
src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts
pnpm vitest run src/platform/telemetry/utils/getExecutionContext.test.ts
\`\`\`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11749-test-rename-misnamed-Mixpanel-test-and-cover-the-actual-provider-class-3516d73d365081609c54f34bd2d8b00d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-05-04 14:52:03 -07:00
Comfy Org PR Bot
846412af17 [chore] Update Ingest API types from cloud@758732f (#11479)
## Automated Ingest API Type Update

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

- Cloud commit: 758732f
- 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: skishore23 <178779+skishore23@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-04 21:37:31 +00:00
Benjamin Lu
aa2169e108 test: reset queue history cap in browser tests (#11773)
## Summary
- Reset `Comfy.Queue.MaxHistoryItems` in the shared browser-test
`comfyPage` setup so worker-persisted queue settings cannot leak between
tests.
- Keep the queue settings spec focused on asserting the setting behavior
without local cleanup scaffolding.

## Validation
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://127.0.0.1:65419
PLAYWRIGHT_SETUP_API_URL=http://127.0.0.1:65400
TEST_COMFYUI_DIR=/Users/ben/.codex/comfyui-preview-env/runtime/worktrees/fe-500-maxhistoryitems
pnpm exec playwright test
browser_tests/tests/queue/queueSettings.spec.ts --project=chromium
--workers=1`
- `pnpm exec eslint browser_tests/tests/queue/queueSettings.spec.ts
browser_tests/fixtures/ComfyPage.ts`
- `pnpm exec oxlint browser_tests/tests/queue/queueSettings.spec.ts
browser_tests/fixtures/ComfyPage.ts`
- `pnpm typecheck:browser`
- `git diff --check`
- commit hook: staged oxfmt, oxlint, eslint, `pnpm typecheck`, `pnpm
typecheck:browser`

Linear: FE-500
2026-05-04 21:31:36 +00:00
Comfy Org PR Bot
cc24d1411a 1.44.16 (#11813)
Patch version increment to 1.44.16

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-04 21:30:31 +00:00
AustinMroz
c2abbeda80 Fix core node detection for missing nodes (#11809)
Nodes in the 'essentials' category do not have type 'core'. The check
has been updated to instead use the dedicated `isCoreNode` prop.

No tests currently. The existing tests for this code section all mock
out the relevant code path and properly writing a test for this would
take far more time than I can allocate right now.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11809-Fix-core-node-detection-for-missing-nodes-3536d73d3650815aabb2deb54c4ecec4)
by [Unito](https://www.unito.io)
2026-05-04 14:21:35 -07:00
Benjamin Lu
56ac3762a0 fix: catch angle-bracket Error assertions (#11909)
## Summary

Extends the new unsafe Error assertion lint rule from #11845 to also
reject angle-bracket assertions.

## Changes

- **What**: Adds a `TSTypeAssertion` selector alongside the existing
`TSAsExpression` selector and broadens the lint message to cover Error
type assertions generally.

## Review Focus

This is stacked on #11845 and only addresses the lint-rule bypass for
`<Error>value` syntax.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11909-fix-catch-angle-bracket-Error-assertions-3566d73d365081f58ecfecfa2e948c33)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-05-04 14:16:47 -07:00
guill
f70285dcb2 fix(website): point Install via GitHub buttons to install docs anchor (#11852)
*PR Created by the Glary-Bot Agent*

---

## Summary

Updates the "Install via GitHub" CTA buttons on the `/download` page to
deep-link to the install instructions section of the ComfyUI README
(`#installing`) instead of the repo root, so users land directly on
setup steps.

## Changes

- `apps/website/src/config/routes.ts`: add `externalLinks.githubInstall
= 'https://github.com/Comfy-Org/ComfyUI#installing'` (separate from
`externalLinks.github`, which is still used by the navbar/footer/star
badge for the generic repo link).
- `apps/website/src/components/product/local/HeroSection.vue`: switch
the secondary CTA next to "Download Local" from `externalLinks.github`
to `externalLinks.githubInstall`.
- `apps/website/src/components/product/local/EcoSystemSection.vue`: same
swap on the ecosystem-section CTA.

The platform-aware `Download Local` button (Windows/macOS installers via
`useDownloadUrl`) and the generic GitHub social/repo links elsewhere on
the site are unchanged.

## Verification

- `pnpm --filter @comfyorg/website typecheck` — 0 errors
- `pnpm --filter @comfyorg/website test:unit` — 23/23 passing
- `pnpm exec eslint` on changed files — clean
- `pnpm exec oxfmt --check` — clean
- Manual via `pnpm dev` + Playwright DOM assertion on `/download`:
- Hero "INSTALL FROM GITHUB" →
`https://github.com/Comfy-Org/ComfyUI#installing` ✓
- EcoSystem "INSTALL FROM GITHUB" →
`https://github.com/Comfy-Org/ComfyUI#installing` ✓
- Other "GitHub" links (nav, footer, star badge) → unchanged at
`https://github.com/Comfy-Org/ComfyUI` ✓

Per request from #website-and-docs: the local download surfaces should
at least link to the ComfyUI install instructions on GitHub. Companion
change to comfy-org/website#227.

## Screenshots

![Download page hero with INSTALL FROM GITHUB button now linking to
install docs
anchor](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/3c608b478e1150f3fc43b6092811c93ff3cd90a253263ab05ac43fe8ce7a0843/pr-images/1777761785467-060efddb-f5a0-44a8-8bbe-287c991171ee.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11852-fix-website-point-Install-via-GitHub-buttons-to-install-docs-anchor-3546d73d365081fe8370cd675ae8f896)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 14:16:36 -07:00
Christian Byrne
6762c08134 feat(website): add payment success and failed pages (#11855)
*PR Created by the Glary-Bot Agent*

---

## Summary

Recreate the two payment status pages that were never migrated from
Framer (they weren't in the sitemap, so were missed). The Stripe
checkout flow in `comfy-api` redirects to
`https://www.comfy.org/payment/{success,failed}` after a checkout
session, so users currently hit a 404 on completion or cancel.

## Changes

- **What**: New static Astro pages at `/payment/success`,
`/payment/failed` and their `/zh-CN/` variants, sharing a
`PaymentStatusSection.vue` component built from the existing
`BrandButton` / `SectionLabel` primitives. Translation keys live in
`src/i18n/translations.ts` for both locales. Pages are `noindex` and
explicitly excluded from the generated sitemap. Adds a Playwright e2e
spec covering both pages in both locales.
- **Dependencies**: none

## Review Focus

- **URL slug**: matches production Stripe config
(`STRIPE_CANCEL_URL=…/payment/failed` in
`comfy-api/run-service-{prod,staging}.yaml`), not `/payment/failure`.
- **Primary CTA target**: links to `externalLinks.apiKeys`
(`platform.comfy.org/profile/api-keys`) — the platform root is just a
redirect, so this avoids a hop.
- **Locale-aware secondary CTA**: derived from `getRoutes(locale)` so
future i18n/route changes flow through the existing helper.
- **Stale fallback** (out of scope):
`comfy-api/gateways/stripe/stripe.go:159` has an unrelated stale
fallback pointing at `/payments/` (plural) that's overridden by the env
config in production. Worth fixing in a follow-up.

## Screenshots

EN success / failed and zh-CN success / failed at desktop widths. Mobile
viewport stacks the CTA buttons vertically (verified locally).

## Screenshots

![Payment success page (EN,
desktop)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/c44f59e47f22968047255c353237b19fb0543c1e166ec4c315cd9c1085308814/pr-images/1777774408278-fd3b63f2-357d-401a-8861-5e45050bc930.png)

![Payment failed page (EN,
desktop)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/c44f59e47f22968047255c353237b19fb0543c1e166ec4c315cd9c1085308814/pr-images/1777774408672-a8ada80c-030c-4f7e-805d-c9e3edd2ec1e.png)

![Payment success page (zh-CN,
desktop)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/c44f59e47f22968047255c353237b19fb0543c1e166ec4c315cd9c1085308814/pr-images/1777774408994-2ac1dc5a-8556-4ca1-929b-71d8812337e1.png)

![Payment failed page (zh-CN,
desktop)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/c44f59e47f22968047255c353237b19fb0543c1e166ec4c315cd9c1085308814/pr-images/1777774409357-a79be0ae-36b3-4c1a-84ce-cb65415fee0a.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11855-feat-website-add-payment-success-and-failed-pages-3556d73d3650819f8f45d8ecf27cb264)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 14:15:57 -07:00
Alexander Brown
211c49f538 docs(skill): improve backport-management with tiered triage, path pre-filter, and public-API conflict review (#11868)
*PR Created by the Glary-Bot Agent*

---

Synthesizes lessons from the recent `v1.43.16` backport session (PRs
#11856–#11862) into the `.claude/skills/backport-management/` skill.

**Documentation only — no code, no workflow, no automation changes.**

## What's new

### `SKILL.md`
- **Quick Start** expanded from 7 to 12 steps, surfacing the pre-filter
and target-file-existence check that should run BEFORE per-PR triage
- **New gotcha**: *Cherry-Picked Tests Can Reference Files Added By
Earlier Unbackported PRs* — drop the test, document why
- **New gotcha**: *Backport-Only Compatibility Shims* — when a
refactor-style fix's mechanism relies on changes that aren't on the
older branch, a literal cherry-pick can recreate the bug for extensions
still using the old contract. Real example: #11541 + `LGraphNode.vue`
`handleDrop`
- **New section**: *Path Pre-Filter* under Auto-Skip Categories —
auto-skip PRs touching only `apps/website/`, `browser_tests/`,
`.github/`, `packages/design-system/`, generated types, `.claude/`,
`docs/`, or `*.stories.ts`. Removes 30–50% of candidates without reading
their bodies

### `reference/analysis.md`
- **New subsection**: *Verify Target File Existence* — `git cat-file -e`
before cherry-pick to skip PRs targeting features the branch doesn't
ship, faster than letting cherry-pick fail with modify/delete
- **New section**: *Tiered Triage* — **Tier 1** (core editor
must-haves), **Tier 2** (cloud-distribution only), **Tier 3** (skip)
before per-PR Y/N. Surfaces release-engineering decisions a flat
MUST/SHOULD list obscures

### `reference/discovery.md`
- Reconciliation workflow combining Slack bot list + git gap,
subtracting already-backported PRs (extracted via `Backport of #` grep
on the branch)
- Clarifies that no single source is authoritative

### `reference/execution.md`
- **New Step 0**: *Test-Then-Resolve Pre-Pass* — moved the dry-run loop
to the top of the workflow so you classify clean vs conflict before
triggering automation
- **New inline guard**: *Public-API conflict review* in the manual
cherry-pick loop — consult oracle BEFORE pushing if resolution touches
LiteGraph callbacks, `node.*` methods, or extension-API surfaces
- **Per-PR validation block** (typecheck + targeted unit tests + ESLint
+ oxfmt) before push, in addition to wave verification
- **Three new lessons learned** (19–21) covering the above
- **New PR Body Template** for manual cherry-picks — non-negotiable
conflict-resolution section so reviewers don't have to re-derive
resolution logic from the diff six months later

## Why

1. **Path pre-filtering** caught 35 of 61 candidates as `skip` before
any per-PR analysis was needed during the `v1.43.16` session
(`apps/website/`, CI, tests, design-system). The previous flat
MUST/SHOULD/SKIP rubric meant reading every PR.

2. **Oracle review on PR #11856** (#11541 backport) caught a regression
in `LGraphNode.vue:823` where the upstream PR's removal of the legacy
`handled === true` sync-return path would silently break custom nodes
still using the old `onDragDrop` contract — recreating the very
duplicate-node bug the PR was fixing. The skill had no guidance for this
class of issue.

3. **Two separate backports** (#11180, #11541) hit the same
modify/delete conflict pattern: a test file added on `main` by an
earlier unbackported PR. The skill's *Conflict Triage* table covered
modify/delete generally but didn't surface this specific anti-pattern of
smuggling in test scaffolding without its prerequisites.

4. **Discovery sources** — the skill assumed Slack bot + git gap as
inputs but didn't show how to reconcile them with already-backported
PRs, leading to potential double-cherry-picking.

## Verification

- No code, no workflow, no automation changes — skill documentation only
- `git diff --check` clean
- All four edited files render as valid markdown
- Cross-references between SKILL.md and `reference/*.md` files validated
by line-count check

## Out of scope (intentionally not changed)

- The `pr-backport.yaml` GitHub Action — the skill describes this but
doesn't own it
- The Slack bot — described as *Source 1*, not modified
- The PR title convention `[backport TARGET] ...` — kept as-is
(CodeRabbit's auto-skip filter relies on it)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11868-docs-skill-improve-backport-management-with-tiered-triage-path-pre-filter-and-public-3556d73d3650814faec3df795567bee3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-04 14:13:13 -07:00
Christian Byrne
b83602fd23 feat: hide Google SSO button in embedded webviews (#10699)
Hide the Google SSO login/signup button when the app runs inside an
embedded webview (Android WebView, iOS WKWebView, social app in-app
browsers), where Google blocks OAuth with a `403 disallowed_useragent`
error.


Fixes #7017

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10699-feat-hide-Google-SSO-button-in-embedded-webviews-3326d73d365081048e35d9d678fe1a2f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-05-04 14:08:06 -07:00
Christian Byrne
aee2e6e6dd test: add e2e tests for nested SubgraphNode input target resolution (#11405)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Adds four Playwright tests targeting `resolveSubgraphInputTarget`
lines 20-31 — the `inputNode.isSubgraphNode()` branch where the target
widget is a PromotedWidgetView
- These lines had 0% e2e coverage because no existing test loaded a
multi-level nested subgraph with VueNodes enabled
- Tests use the existing `subgraph-nested-promotion.json` workflow (node
5 → Sub 0 → node 6 → Sub 1), which has outer SubgraphNode inputs
connecting to an inner SubgraphNode

## Test cases

| Test | Coverage target | Mechanism |
|---|---|---|
| Nested SubgraphNode promoted widgets render without resolution
failures | Lines 20-31 (via VueNodes rendering) | Console warning
collection + widget count assertion |
| Subgraph input resolves through inner SubgraphNode with
PromotedWidgetView | Lines 20-31 (graph structure verification) |
`page.evaluate` walks link chain, asserts `isSubgraphNode() === true`
and `isPromotedWidgetView === true` |
| Promoted widgets from inner SubgraphNode carry correct source identity
| Lines 24-31 (source identity) | Asserts widgets with `sourceNodeId ===
'6'` have correct `sourceWidgetName` |
| Serialize and reload preserves nested promoted widget resolution |
Lines 20-31 (persistence) | `serializeAndReload()` + polled widget count
comparison |

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11405-test-add-e2e-tests-for-nested-SubgraphNode-input-target-resolution-3476d73d365081ab932edc8a01c55c40)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-04 13:53:16 -07:00
Christian Byrne
2a3b692c0b Repair: re-add bug-dump-ingest skill (#11460) — GitHub squash-commit incident recovery, step 2 of 2 (#11630)
*PR Created by the Glary-Bot Agent*

---

## Step 2 of 2 — GitHub squash-commit incident recovery for #11460

This is the companion to #11629. After #11629 (the revert) merges, this
PR re-applies the original PR #11460 contents on top of the post-revert
`main`, restoring the intended state of the codebase.

### ⚠️ Sequencing — must merge after #11629

Until #11629 is merged, this PR's diff against `main` is empty (because
`main` currently still contains the squashed bug-dump-ingest commit that
#11629 will revert). After #11629 is merged into `main`, GitHub will
recompute the diff and this PR will show a clean re-add of the same 5
`.claude/skills/bug-dump-ingest/` files.

### Verification

The branch was constructed by:
1. Branching from `glary/revert-pr-11460` (the revert branch in #11629).
2. `git checkout FETCH_HEAD -- .claude/skills/bug-dump-ingest/` from
`refs/pull/11460/head` to restore the exact files.
3. Committing them as a single squash-style commit.

I verified that all 5 file blobs are byte-for-byte identical to the
original squash commit `559922eaa5c129767c22275c206c6877931ac15c` by
comparing git object SHAs:

| File | Object SHA |
|---|---|
| `.claude/skills/bug-dump-ingest/SKILL.md` |
`413737835fa1c996291019483effdd39e6da33e5` |
| `.claude/skills/bug-dump-ingest/reference/examples.md` |
`4fc54a4f14b1359de63f558acca0de48c1b65c57` |
| `.claude/skills/bug-dump-ingest/reference/linear-api.md` |
`57986740df2ee02c19b81059f0f1e00e54c2a042` |
| `.claude/skills/bug-dump-ingest/reference/schema.md` |
`84db1a5818c04ee53a94167092ec76dd814992d4` |
| `.claude/skills/bug-dump-ingest/reference/verify-commands.md` |
`a2c99a43a030ccc3769692d3c09be74132645bb4` |

### Notes

- One commit on `refs/pull/11460/head` (an automated lint commit
`76ca1598e`) modified
`src/renderer/extensions/vueNodes/widgets/components/WidgetChart.test.ts`
to remove an `eslint-disable` directive. That change was **not** in the
original squash commit on `main` (verified via `git show --stat`), and
the directive has separately been removed from `main` by an unrelated
commit (#11550), so re-applying that change would now be a no-op. This
repair PR therefore intentionally restores **only the 5 bug-dump-ingest
files**, matching the original squash commit exactly.
- Branch name is `glary/repair-pr-11460` (the `glary/` prefix is
required by the tooling; otherwise equivalent to GitHub's suggested
`repair-pr-11460`).

Refs: #11460, #11629

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11630-Repair-re-add-bug-dump-ingest-skill-11460-GitHub-squash-commit-incident-recovery--34d6d73d365081acbd54c19316561fa9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 13:51:29 -07:00
Alexander Brown
dac3396de8 test: add selection paste, rename, and batch rename browser tests (#11367)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds `browser_tests/tests/selectionPasteRename.spec.ts` covering the
untested `pasteSelection()` and `renameSelection()` paths in
`useSelectionOperations.ts`.

### Coverage gaps filled
- `pasteSelection()` — copy → paste creates new nodes
- `renameSelection()` single node path — opens title editor
- `renameSelection()` batch path — prompt dialog with sequential naming
- Empty selection → toast warning

### References
- Follows patterns from `selectionToolboxMoreActions.spec.ts` (More
Options menu, `selectNodeWithPan`)
- Follows `browser_tests/AGENTS.md` directory structure
- Follows `browser_tests/FLAKE_PREVENTION_RULES.md` assertion patterns

### Verification
- TypeScript: clean
- ESLint: clean
- oxlint: clean
- oxfmt: formatted

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11367-test-add-selection-paste-rename-and-batch-rename-browser-tests-3466d73d3650812194a4d8bfbed3dee7)
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-04 20:30:42 +00:00
190 changed files with 13495 additions and 4568 deletions

View File

@@ -9,13 +9,18 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
## Quick Start
1. **Discover** — Collect candidates from Slack bot + git log gap (`reference/discovery.md`)
2. **Analyze** — Categorize MUST/SHOULD/SKIP, check deps (`reference/analysis.md`)
3. **Human Review** — Present candidates in batches for interactive approval (see Interactive Approval Flow)
4. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
5. **Execute**Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
6. **Verify** — After each wave, verify branch integrity before proceeding
7. **Log & Report** — Generate session report (`reference/logging.md`)
1. **Discover** — Collect candidates from Slack bot + git log gap, then **reconcile both lists** (`reference/discovery.md`)
2. **Pre-filter by path** — Auto-skip PRs whose changed files are entirely under `apps/website/`, `browser_tests/`, `.github/`, `packages/design-system/`, `packages/{cloud,registry}-types/`, `.claude/`, `docs/`. Don't read PR bodies for these — they don't ship to core ComfyUI users (`reference/analysis.md`)
3. **Verify target file existence** — For each surviving candidate, run `git cat-file -e origin/$TARGET:$path` for primary changed files. If they don't exist on the target, auto-mark SKIP with reason `feature-not-on-branch`
4. **Tiered triage** — Bucket into **Tier 1 (core editor must-haves)**, **Tier 2 (cloud-distribution only)**, **Tier 3 (skip)** before reviewing individually (`reference/analysis.md`)
5. **Analyze**Categorize remaining MUST/SHOULD, check deps (`reference/analysis.md`)
6. **Human Review** — Present candidates in batches for interactive approval, with tier context attached (see Interactive Approval Flow)
7. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
8. **Test-then-resolve dry-run** — Classify clean vs conflict before committing time (`reference/execution.md`)
9. **Execute** — Label-driven automation for clean PRs → worktree fallback for conflicts (`reference/execution.md`)
10. **Public-API conflict review** — If conflict resolution touches a public LiteGraph callback, extension API, or `node.*` method, consult oracle for compat-regression review BEFORE pushing (`reference/execution.md`)
11. **Verify** — Per-PR validation (typecheck + targeted tests + lint on changed files) AND per-wave verification (full typecheck + test:unit on branch HEAD)
12. **Log & Report** — Generate session report + author accountability report + Slack status update (`reference/logging.md`)
## System Context
@@ -107,6 +112,35 @@ Husky hooks fail in worktrees (can't find lint-staged config). Always use `git p
In the 2026-04-06 session: core/1.42 got 18/26 auto-PRs, cloud/1.42 got only 1/25. The cloud branch has more divergence. **Always plan for manual fallback** — don't assume automation will handle most PRs.
### Cherry-Picked Tests Can Reference Files Added By Earlier Unbackported PRs
A common conflict: PR A on main modifies a test file that was _added_ on main by an earlier PR B (not backported to the target). The cherry-pick of A reports "modify/delete" on B's test file because the file doesn't exist on the target. Adding the new file would smuggle in B's test scaffolding without B's runtime changes.
**Detection:** Conflict says `deleted in HEAD and modified in <PR>`. Verify with:
```bash
git log --diff-filter=A --oneline origin/main -- path/to/test.ts
```
If the introducing commit is **not** on the target branch, the test file isn't a real prerequisite for the runtime fix.
**Fix:** `git rm` the test file (drop it from the backport). Document in the commit body which PR introduced it on main and why dropping it is safe. The runtime fix itself usually doesn't depend on these tests — coverage exists at the integration layer.
### Backport-Only Compatibility Shims
When a PR's _mechanism_ relies on changes upstream that aren't on the older branch, a literal cherry-pick can recreate the original bug for any consumer still using the old contract. This is most dangerous for **public LiteGraph callbacks, extension APIs, and `node.*` methods** that custom-node packages depend on.
**Real example (#11541, core/1.43 backport):** The PR removed `LGraphNode.vue`'s legacy `handled === true` sync-return check from `handleDrop`, replacing it with `await node.onDragDrop(event, true)`. Safe on `main` because all in-repo `onDragDrop` handlers had migrated to participate in the new `claimEvent` flag. On `core/1.43`, `onDragDrop` is a public callback — custom-node packages with synchronous `onDragDrop` returning `true` would no longer have their event claimed, recreating the duplicate-node-creation bug the PR was fixing.
**Detection:** The PR's diff modifies a file that is part of a public extension API surface. Look for:
- `node.onXxx` callback assignments
- Methods on `LGraphNode`, `LGraphCanvas`, `LGraph`, `Subgraph`
- Public exports from `src/lib/litegraph/`
- Type changes affecting `litegraph-augmentation.d.ts`
**Fix:** Add a backport-only compatibility shim that preserves the old contract while keeping the new fix. Document it explicitly in the commit body under a `## Backport-only compatibility fix` heading. Consult oracle for review before pushing — a bad shim is worse than no fix.
## Conflict Triage
**Always categorize before deciding to skip. High conflict count ≠ hard conflicts.**
@@ -147,6 +181,26 @@ Skip these without discussion:
- **Features not on target branch** — e.g., Painter, GLSLShader, appModeStore on core/1.40
- **Cloud-only PRs on core/\* branches** — Team workspaces, cloud queue, cloud-only login. (Note: app mode and Firebase auth are NOT cloud-only — see Branch Scope Rules)
### Path Pre-Filter (run BEFORE reading PR bodies)
For 50+ candidate PRs, classify by changed paths first to skip the unproductive ones without spending time on triage. Run `git show --stat $SHA` (or `gh pr view --json files`) and bucket:
| Path prefix | Bucket | Reason |
| ---------------------------------------------- | ---------------------- | ------------------------------------------------ |
| `apps/website/` | SKIP | Marketing/platform site, not core ComfyUI bundle |
| `apps/desktop-ui/` | SKIP for `core/*` | Desktop app, separate release cadence |
| `browser_tests/` only (no `src/`) | SKIP | Test-only |
| `.github/workflows/` only | SKIP | CI/release infra |
| `packages/design-system/` only | SKIP | Design tokens, not core |
| `packages/{cloud,registry,ingest}-types/` only | SKIP | Generated types |
| `.claude/`, `.agents/`, `docs/` | SKIP | Agent / documentation |
| `*.stories.ts` only | SKIP | Storybook only |
| `src/` (core editor) | KEEP — analyze further | Runtime/editor code that requires full triage |
A PR touches multiple paths? Keep it if **any** changed file is under `src/` (or other core paths) and run normal analysis. Auto-skip is conservative — only skip when _all_ paths match the SKIP buckets.
This filter alone removes ~30-50% of candidates in a typical session, leaving only the PRs that need real triage.
## Wave Verification
After merging each wave of PRs to a target branch, verify branch integrity before proceeding:

View File

@@ -39,6 +39,89 @@ Check before backporting — these don't exist on older branches:
- **App builder** — check per branch
- **appModeStore.ts** — not on core/1.40
### Verify Target File Existence (Run Before Cherry-Pick)
Before cherry-picking any PR, confirm the files it modifies actually exist on the target branch. If they don't, the PR's runtime fix is for a feature that hasn't been added yet — skip cleanly without attempting cherry-pick:
```bash
# For each file the PR changes
for f in $(gh pr view $PR --json files --jq '.files[].path' | grep -v "^browser_tests/\|\.test\." ); do
if ! git cat-file -e origin/$TARGET:$f 2>/dev/null; then
echo "MISSING on $TARGET: $f"
fi
done
```
If the _primary_ changed files (the runtime ones, not tests) are missing, mark the PR `SKIP / feature-not-on-branch`. This is faster than letting cherry-pick fail with modify/delete conflicts and gives a clean signal.
This check is the first thing that runs after the path pre-filter and BEFORE you spend time reading PR descriptions.
## Tiered Triage (Recommended for 30+ Candidates)
Before the interactive Y/N approval flow, bucket all surviving candidates into three tiers. This surfaces release-engineering decisions that a flat MUST/SHOULD list obscures:
### Tier 1 — Core Editor Must-Haves
User-facing bugs, crashes, data corruption, or security issues in code paths that exist on the target branch. These are the strongest backport candidates.
Indicators:
- `fix:` prefix and the bug is reproducible on the target branch
- Crash guards, runtime null checks, race-condition fixes
- Data-loss bugs (state not persisted, duplicates, drops)
- Security hardening (CSRF, XSS, auth)
- Vue Nodes 2.0 regression cluster (if the target ships Vue Nodes 2.0)
- Subgraph correctness fixes
- Public-API extension callback fixes
Recommend `Y` to user.
### Tier 2 — Cloud-Distribution Only
Bugs that only manifest on cloud-hosted distributions (Secrets panel, subscription flows, cloud signup, workspace tracking, etc.). Whether to backport depends on whether cloud ships from the target `core/*` branch in your release matrix.
Indicators:
- Files under `src/platform/secrets/`, `src/platform/subscription/`, signup flows
- PR description mentions cloud staging issues
- Fix gated behind cloud feature flags
Default: ask the cloud release rotation owner. If unsure, defer.
### Tier 3 — Skip
Path pre-filter caught most of these. The rest are PRs where the diff _touches_ `src/` but the practical impact is non-user-facing or scoped to features the target doesn't ship.
Indicators:
- All changes in test files even if the PR touched `src/` test files
- Storybook stories only
- Lint config / lint rule additions
- Documentation comments
- Internal refactors with no behavior change
### Presentation Format
When showing tier results to the user, format as:
```text
Tier 1 (N PRs) — strong backport candidates
- #11541 fix: stop duplicate node creation when dropping image on Vue nodes
Why: Vue Nodes 2.0 regression — async onDragDrop bypassed handled-check, drops bubble to document, spawns extra LoadImage nodes
- #10849 fix: store promoted widget values per SubgraphNode instance
Why: Multiple instances overwriting each other's promoted widget values — data loss
Tier 2 (N PRs) — cloud-distribution release rotation should decide
- #11636 fix: enable Chrome password autofill on signup form
- ...
Tier 3 (N PRs) — skip recommended
- #11586 fix: website polish (apps/website/ only)
- ...
```
Then run interactive Y/N over Tier 1 and Tier 2; Tier 3 gets confirmed-skip without per-PR review.
## Dep Refresh PRs
Always SKIP on stable branches. Risk of transitive dependency regressions outweighs audit cleanup benefit. If a specific CVE fix is needed, cherry-pick that individual fix instead.

View File

@@ -1,5 +1,11 @@
# Discovery — Candidate Collection
**Run all sources, then reconcile.** No single source is authoritative:
- Slack bot may flag PRs that have already been backported (false positive)
- Git gap may include PRs that don't need backport (test-only, design-system, website)
- Bot can also miss PRs that landed without the right labels
## Source 1: Slack Backport-Checker Bot
Use `slackdump` skill to export `#frontend-releases` channel (C09K9TPU2G7):
@@ -36,7 +42,43 @@ gh pr view $PR --json mergeCommit,title --jq '"Title: \(.title)\nMerge: \(.merge
gh pr view $PR --json files --jq '.files[].path'
```
## Source 4: Already-Backported PRs (cross-reference)
When the target branch already has some cherry-picks on it (e.g., partway through a release window), extract the originals to avoid re-backporting:
```bash
# Get all original PR numbers already backported to TARGET since the last release tag
git log --format="%H%n%B" $LAST_TAG..origin/$TARGET \
| grep -oiE "(backport of|cherry.picked) #?[0-9]+" \
| grep -oE "[0-9]+" \
| sort -un > /tmp/already-backported.txt
```
Subtract this list from your candidates.
## Reconciliation Workflow
```bash
# 1. Slack bot list (parse from export)
# /tmp/bot-flagged.txt — one PR# per line, sorted
# 2. Git gap fix/perf only
MB=$(git merge-base origin/main origin/$TARGET)
git log --format="%h|%s" $MB..origin/main \
| grep -iE "^[a-f0-9]+\|(fix|perf)" \
| grep -oE "#[0-9]+\)" | grep -oE "[0-9]+" \
| sort -un > /tmp/gap-fixes.txt
# 3. Already backported (Source 4 above)
# 4. Candidates = (gap-fixes bot-flagged) already-backported
sort -u /tmp/gap-fixes.txt /tmp/bot-flagged.txt > /tmp/union.txt
comm -23 /tmp/union.txt /tmp/already-backported.txt > /tmp/candidates.txt
```
The result is the input to the path pre-filter (`SKILL.md` Quick Start step 2).
## Output: candidate_list.md
Table per target branch:
| PR# | Title | Category | Flagged by Bot? | Decision |
| PR# | Title | Source (bot/gap/both) | Path bucket | Tier | Decision |

View File

@@ -6,6 +6,43 @@
2. Medium gap next (quick win)
3. Largest gap last (main effort)
## Step 0: Test-Then-Resolve Pre-Pass (Recommended)
Before triggering label-driven automation, run a dry-run cherry-pick loop to classify candidates. This is much faster than discovering conflicts after-the-fact across automation, manual cherry-picks, and CI failures.
```bash
git fetch origin TARGET_BRANCH
git worktree add /tmp/dryrun-TARGET origin/TARGET_BRANCH
cd /tmp/dryrun-TARGET
CLEAN=()
CONFLICT=()
for pr in "${CANDIDATES[@]}"; do
SHA=$(gh pr view $pr --json mergeCommit --jq '.mergeCommit.oid')
git checkout -b dryrun-$pr origin/TARGET_BRANCH 2>/dev/null
if git cherry-pick -m 1 $SHA 2>/dev/null; then
CLEAN+=($pr)
else
CONFLICT+=($pr)
git cherry-pick --abort
fi
git checkout --detach HEAD 2>/dev/null
git branch -D dryrun-$pr 2>/dev/null
done
echo "CLEAN (${#CLEAN[@]}): ${CLEAN[*]}"
echo "CONFLICT (${#CONFLICT[@]}): ${CONFLICT[*]}"
cd -
git worktree remove /tmp/dryrun-TARGET --force
```
Use the result to:
- Send CLEAN PRs through label-driven automation (Step 1) — they'll typically self-merge
- Reserve manual worktree time (Step 3) for CONFLICT PRs only
- Surface PRs likely to need backport-only compat shims (CONFLICT files in `src/lib/litegraph/` or `src/scripts/app.ts`)
## Step 1: Label-Driven Automation (Batch)
```bash
@@ -88,6 +125,39 @@ for PR in ${CONFLICT_PRS[@]}; do
git add .
GIT_EDITOR=true git cherry-pick --continue
# ── Public-API conflict review (REQUIRED for extension-API surfaces) ──
# If the conflict resolution touched any of these surfaces, consult oracle
# BEFORE pushing. A bad shim is worse than no fix:
# - node.onXxx callback assignments (onDragDrop, onConnectionsChange, onRemoved, onConfigure, etc.)
# - Methods on LGraphNode, LGraphCanvas, LGraph, Subgraph
# - Public exports from src/lib/litegraph/
# - Type changes in litegraph-augmentation.d.ts
# If a public callback's signature/contract changed: add a backport-only
# compatibility shim that preserves the OLD contract while keeping the
# new fix. Document it in the commit body under
# "## Backport-only compatibility fix". See SKILL.md gotcha section.
# ───────────────────────────────────────────────────────────────────────
# Per-PR validation BEFORE push (catches issues earlier than wave verification).
# Guard each targeted command against empty file lists — running `pnpm test:unit -- run`
# with no arg matchers would run the full suite, and `pnpm exec eslint` with no args errors.
pnpm typecheck
mapfile -t TEST_FILES < <(git diff --name-only HEAD~1 | grep -E '\.test\.ts$' || true)
if [ ${#TEST_FILES[@]} -gt 0 ]; then
pnpm test:unit -- run "${TEST_FILES[@]}"
else
echo "No changed test files — skipping targeted unit tests"
fi
mapfile -t CODE_FILES < <(git diff --name-only HEAD~1 | grep -E '\.(ts|vue)$' || true)
if [ ${#CODE_FILES[@]} -gt 0 ]; then
pnpm exec eslint "${CODE_FILES[@]}"
pnpm exec oxfmt --check "${CODE_FILES[@]}"
else
echo "No changed ts/vue files — skipping targeted lint/format"
fi
git push origin backport-$PR-to-TARGET --no-verify
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
--title "[backport TARGET] TITLE (#$PR)" \
@@ -243,6 +313,9 @@ gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
16. **Use `--no-verify` in worktrees** — husky hooks fail in `/tmp/` worktrees. Always push/commit with `--no-verify`.
17. **Automation success varies by branch** — core/1.42 got 18/26 auto-PRs (69%), cloud/1.42 got 1/25 (4%). Cloud branches diverge more. Plan for manual fallback.
18. **Test-then-resolve pattern** — for branches with low automation success, run a dry-run loop to classify clean vs conflict PRs before processing. This is much faster than resolving conflicts serially.
19. **Public-API conflict resolutions need oracle review** — when a conflict touches `node.onXxx` callbacks, `LGraphNode`/`LGraphCanvas`/`LGraph`/`Subgraph` methods, or types in `litegraph-augmentation.d.ts`, consult oracle BEFORE pushing. Custom-node packages depend on these contracts. A literal cherry-pick of a refactor-style fix can silently break extensions still using the old contract — sometimes recreating the very bug the PR was fixing. Document any backport-only compatibility shim explicitly in the commit body.
20. **Cherry-picked tests can require unbackported test scaffolding** — when a PR modifies a test file that was _added_ on main by an earlier unbackported PR, the cherry-pick reports modify/delete on that file. Drop it from the backport (`git rm`) and document which PR introduced it. Don't smuggle in test infrastructure without its runtime prerequisites.
21. **Per-PR validation catches issues earlier than wave verification** — for high-stakes branches, run `pnpm typecheck && pnpm exec eslint <changed files> && pnpm exec oxfmt --check` per PR before pushing. Wave verification still matters (it catches cross-PR interactions), but per-PR makes attribution trivial when something fails.
## CI Failure Triage
@@ -268,3 +341,40 @@ Common failure categories:
| Type error | Interface changed on main but not branch | May need manual adaptation |
**Never assume a failure is safe to skip.** Present all failures to the user with analysis.
## PR Body Template (Manual Cherry-Picks)
Manual cherry-pick PRs need detail beyond the automation's terse default. Use this template — reviewers will look here before re-deriving conflict-resolution logic from the diff.
```markdown
Manual backport of #ORIG to `TARGET` for inclusion in `vX.Y.Z`.
Cherry-picked from upstream merge commit `SHORT_SHA`.
## Why
[1-2 sentences from the original PR's "Summary" — what bug, what fix mechanism]
## Conflict resolution
- **`path/to/file`** — [what conflicted on this branch] → [resolution chosen + why]
- **`path/to/dropped-test.test.ts`** — added on main by unrelated PR #XXXX (not backported). Dropped from this backport; runtime fix intact.
- [...]
## Backport-only compatibility fix (if applicable)
[If you added a shim that wasn't in the upstream PR, document it here — what extension surface, what contract, what the shim preserves, why the upstream version would have regressed it]
## Validation
- `pnpm typecheck`
- `pnpm test:unit -- run <targeted suites>` ✅ (N/N passing)
- `pnpm exec eslint <changed files>` ✅ (0 errors)
- `pnpm exec oxfmt --check` ✅ (clean)
[If manual e2e was skipped, explain why — e.g., requires live backend, headless not feasible. State that source is byte-identical to upstream + how long it's been baking on main.]
Original PR: #ORIG / Original commit: `FULL_SHA`
```
The conflict-resolution section is non-negotiable — every conflict you resolved by hand needs a one-liner. This makes archaeology trivial six months later when someone asks "why does this look slightly different from main?"

View File

@@ -114,7 +114,7 @@ await expect(async () => {
## CI Debugging
1. Download artifacts from failed CI run
2. Extract and view trace: `npx playwright show-trace trace.zip`
2. Extract and view trace: `pnpm dlx playwright show-trace trace.zip`
3. CI deploys HTML report to Cloudflare Pages (link in PR comment)
4. Reproduce CI: `CI=true pnpm test:browser`
5. Local runs: `pnpm test:browser:local`

View File

@@ -4,6 +4,9 @@ name: 'CI: Vercel Website Preview'
on:
pull_request:
types: [opened, synchronize, reopened]
branches-ignore:
- 'core/**'
- 'cloud/**'
paths:
- 'apps/website/**'
- 'packages/design-system/**'

View File

@@ -3,6 +3,23 @@ import sitemap from '@astrojs/sitemap'
import vue from '@astrojs/vue'
import tailwindcss from '@tailwindcss/vite'
const LOCALES = ['en', 'zh-CN'] as const
const DEFAULT_LOCALE = 'en'
const PAYMENT_STATUSES = ['success', 'failed'] as const
const LOCALE_PREFIXES = LOCALES.map((locale) =>
locale === DEFAULT_LOCALE ? '' : `/${locale}`
)
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
LOCALE_PREFIXES.flatMap((prefix) =>
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
)
)
function isExcludedFromSitemap(page: string): boolean {
const pathname = new URL(page).pathname.replace(/\/$/, '')
return SITEMAP_EXCLUDED_PATHNAMES.has(pathname)
}
export default defineConfig({
site: 'https://comfy.org',
output: 'static',
@@ -17,7 +34,12 @@ export default defineConfig({
assets: '_website'
},
devToolbar: { enabled: !process.env.NO_TOOLBAR },
integrations: [vue(), sitemap()],
integrations: [
vue(),
sitemap({
filter: (page) => !isExcludedFromSitemap(page)
})
],
vite: {
plugins: [tailwindcss()],
server: {
@@ -27,8 +49,8 @@ export default defineConfig({
}
},
i18n: {
locales: ['en', 'zh-CN'],
defaultLocale: 'en',
locales: [...LOCALES],
defaultLocale: DEFAULT_LOCALE,
routing: {
prefixDefaultLocale: false
}

View File

@@ -0,0 +1,44 @@
import { expect, test } from '@playwright/test'
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()
})
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('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()
})
test('demo library page renders', async ({ page }) => {
await page.goto('/demos')
await expect(page.getByText('Coming Soon')).toBeVisible()
})
test('non-existent demo returns 404', async ({ page }) => {
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

@@ -46,7 +46,7 @@ test.describe('Download page @smoke', () => {
await expect(githubBtn).toBeVisible()
await expect(githubBtn).toHaveAttribute(
'href',
'https://github.com/Comfy-Org/ComfyUI'
'https://github.com/Comfy-Org/ComfyUI#installing'
)
await context.close()

View File

@@ -0,0 +1,115 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { externalLinks } from '../src/config/routes'
import { test } from './fixtures/blockExternalMedia'
const CLOUD_URL = externalLinks.cloud
const PLATFORM_USAGE_URL = externalLinks.platformUsage
const SUPPORT_URL = externalLinks.support
const DOCS_SUBSCRIPTION_URL = externalLinks.docsSubscription
async function expectNoIndex(page: Page) {
await expect(page.locator('meta[name="robots"]')).toHaveAttribute(
'content',
'noindex, nofollow'
)
}
test.describe('Payment success page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/payment/success')
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Successful — Comfy')
await expectNoIndex(page)
})
test('shows success heading and subtitle', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Payment successful/i, level: 1 })
).toBeVisible()
await expect(page.getByText(/Thanks for your purchase/i)).toBeVisible()
})
test('primary CTA links to Comfy Cloud', async ({ page }) => {
const cta = page.getByRole('link', { name: /CONTINUE TO COMFY CLOUD/i })
await expect(cta).toBeVisible()
await expect(cta).toHaveAttribute('href', CLOUD_URL)
})
test('secondary CTA links to platform usage & payments page', async ({
page
}) => {
const cta = page.getByRole('link', { name: /VIEW USAGE & PAYMENTS/i })
await expect(cta).toBeVisible()
await expect(cta).toHaveAttribute('href', PLATFORM_USAGE_URL)
})
})
test.describe('Payment failed page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/payment/failed')
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Failed — Comfy')
await expectNoIndex(page)
})
test('shows failure heading and subtitle', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: /Unable to complete payment/i,
level: 1
})
).toBeVisible()
await expect(page.getByText(/payment didn't go through/i)).toBeVisible()
})
test('primary CTA links to support help center', async ({ page }) => {
const cta = page.getByRole('link', { name: /CONTACT SUPPORT/i })
await expect(cta).toBeVisible()
await expect(cta).toHaveAttribute('href', SUPPORT_URL)
})
test('secondary CTA links to subscription docs', async ({ page }) => {
const cta = page.getByRole('link', { name: /READ SUBSCRIPTION DOCS/i })
await expect(cta).toBeVisible()
await expect(cta).toHaveAttribute('href', DOCS_SUBSCRIPTION_URL)
})
})
test.describe('Payment pages zh-CN @smoke', () => {
test('zh-CN success page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/success')
await expect(page).toHaveTitle('支付成功 — Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '支付成功', level: 1 })
).toBeVisible()
await expect(
page.getByRole('link', { name: '前往 COMFY CLOUD' })
).toHaveAttribute('href', CLOUD_URL)
await expect(
page.getByRole('link', { name: '查看用量与支付' })
).toHaveAttribute('href', PLATFORM_USAGE_URL)
})
test('zh-CN failed page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/failed')
await expect(page).toHaveTitle('支付失败 — Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '无法完成支付', level: 1 })
).toBeVisible()
await expect(page.getByRole('link', { name: '联系支持' })).toHaveAttribute(
'href',
SUPPORT_URL
)
await expect(
page.getByRole('link', { name: '查看订阅文档' })
).toHaveAttribute('href', DOCS_SUBSCRIPTION_URL)
})
})

View File

@@ -126,6 +126,7 @@ test.describe('Overflow guards', { tag: '@visual' }, () => {
const pages = [
'/',
'/cloud',
'/cloud/enterprise',
'/cloud/pricing',
'/contact',
'/download',

View File

@@ -27,6 +27,7 @@
"gsap": "catalog:",
"lenis": "catalog:",
"posthog-js": "catalog:",
"three": "catalog:",
"vue": "catalog:",
"zod": "catalog:"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -29,5 +29,30 @@ Allow: /
Disallow: /_astro/
Disallow: /_website/
Disallow: /_vercel/
Disallow: /payment/
User-agent: GPTBot
Allow: /
User-agent: OAI-SearchBot
Allow: /
User-agent: ChatGPT-User
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: Claude-User
Allow: /
User-agent: Claude-SearchBot
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: Google-Extended
Allow: /
Sitemap: https://comfy.org/sitemap-index.xml

View File

@@ -1,6 +1,4 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const photos = [
{
src: 'https://media.comfy.org/website/careers/team0.webp',
@@ -17,45 +15,34 @@ const photos = [
{
src: 'https://media.comfy.org/website/careers/team3.webp',
alt: 'Team on a boat'
},
{
src: 'https://media.comfy.org/website/careers/team4.webp',
alt: 'Teammates posing at a restaurant'
},
{
src: 'https://media.comfy.org/website/careers/team5.webp',
alt: 'Teammates at a social gathering'
},
{
src: 'https://media.comfy.org/website/careers/team6.webp',
alt: 'Team sailing at golden hour'
},
{
src: 'https://media.comfy.org/website/careers/team7.webp',
alt: 'Team on a sailboat at sunset'
}
]
const loopedPhotos = [...photos, ...photos, ...photos]
const scrollRef = ref<HTMLElement>()
function onScroll() {
const el = scrollRef.value
if (!el) return
const third = el.scrollWidth / 3
const maxScroll = el.scrollWidth - el.clientWidth
if (el.scrollLeft >= maxScroll - 1) {
el.scrollLeft -= third
} else if (el.scrollLeft <= 1) {
el.scrollLeft += third
}
}
onMounted(() => {
const el = scrollRef.value
if (el) {
el.scrollLeft = el.scrollWidth / 3
}
})
</script>
<template>
<section class="py-12 md:py-24">
<div
ref="scrollRef"
class="flex gap-4 overflow-x-auto px-6 md:gap-6 md:px-20"
style="scrollbar-width: none"
@scroll="onScroll"
>
<div
v-for="(photo, i) in loopedPhotos"
v-for="(photo, i) in photos"
:key="i"
class="aspect-3/4 h-64 shrink-0 md:h-96"
>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { ref } from 'vue'
import { t } from '../../i18n/translations'
const {
arcadeId,
title,
locale = 'en'
} = defineProps<{
arcadeId: string
title: string
locale?: Locale
}>()
const loaded = ref(false)
</script>
<template>
<section
class="px-4 py-8 lg:px-20 lg:py-16"
: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"
>
<div
v-if="!loaded"
aria-hidden="true"
class="absolute inset-0 flex flex-col items-center justify-center bg-black/50"
>
<div
class="border-primary-comfy-canvas/60 mb-4 size-10 animate-pulse rounded-full border-2"
/>
<p class="text-primary-warm-gray text-sm">
{{ t('demos.loading', locale) }}
</p>
</div>
<iframe
class="size-full"
:src="`https://demo.arcade.software/${arcadeId}?embed&show_title=0`"
:title="`${t('demos.embed.label', locale)}: ${title}`"
loading="lazy"
allow="clipboard-write"
referrerpolicy="strict-origin-when-cross-origin"
@load="loaded = true"
/>
</div>
<noscript>
<p class="text-primary-warm-gray mt-4 text-sm">
{{ t('demos.noscript', locale) }}
<a
class="text-primary-comfy-yellow ml-2 underline"
:href="`https://demo.arcade.software/${arcadeId}`"
rel="noopener noreferrer"
target="_blank"
>
{{ t('demos.noscript.link', locale) }}
</a>
</p>
</noscript>
</section>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const {
label,
title,
description,
difficulty,
estimatedTime,
locale = 'en'
} = defineProps<{
label: string
title: string
description: string
difficulty: 'beginner' | 'intermediate' | 'advanced'
estimatedTime: string
locale?: Locale
}>()
const difficultyKey = `demos.difficulty.${difficulty}` as TranslationKey
</script>
<template>
<section class="pt-16 lg:px-20 lg:pt-40 lg:pb-8">
<div class="mx-auto flex max-w-4xl flex-col items-center text-center">
<span
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
>
{{ label }}
</span>
<h1
class="text-primary-comfy-canvas mt-4 text-3xl/tight font-light lg:text-5xl/tight"
>
{{ title }}
</h1>
<p
class="text-primary-warm-gray mt-6 max-w-xl text-sm/relaxed lg:text-base/relaxed"
>
{{ description }}
</p>
<div class="mt-6 flex flex-wrap justify-center gap-3">
<span
class="bg-transparency-white-t4 text-primary-comfy-canvas rounded-full px-3 py-1 text-xs font-semibold tracking-wide uppercase"
>
{{ t(difficultyKey, locale) }}
</span>
<span
class="bg-transparency-white-t4 text-primary-comfy-canvas rounded-full px-3 py-1 text-xs font-semibold"
>
{{ t(estimatedTime as TranslationKey, locale) }}
</span>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const {
nextTitle,
nextSlug,
nextThumbnail,
locale = 'en'
} = defineProps<{
nextTitle: string
nextSlug: string
nextThumbnail: string
locale?: Locale
}>()
const localePrefix = locale === 'en' ? '' : `/${locale}`
const nextHref = `${localePrefix}/demos/${nextSlug}`
</script>
<template>
<section class="px-4 py-16 lg:px-20 lg:py-24">
<h2 class="text-primary-comfy-canvas mb-10 text-2xl font-light lg:text-3xl">
{{ t('demos.nav.nextDemo' as TranslationKey, locale) }}
</h2>
<div
class="bg-transparency-white-t4 rounded-5xl mx-auto flex flex-col gap-8 p-2 lg:max-w-237.5 lg:flex-row lg:items-center"
>
<a :href="nextHref" class="shrink-0 lg:w-1/2">
<img
:src="nextThumbnail"
:alt="nextTitle"
class="w-full rounded-4xl object-cover"
/>
</a>
<div class="flex flex-col gap-6">
<h3 class="text-primary-comfy-canvas text-xl font-light lg:text-2xl">
{{ nextTitle }}
</h3>
<a :href="nextHref" class="flex items-center gap-3">
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex size-10 items-center justify-center rounded-full"
>
<span class="text-lg font-bold"></span>
</span>
<span
class="text-primary-comfy-canvas ppformula-text-center text-sm font-semibold tracking-wider uppercase"
>
{{ t('demos.nav.viewDemo' as TranslationKey, locale) }}
</span>
</a>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import { t } from '../../i18n/translations'
const { transcript, locale = 'en' } = defineProps<{
transcript: string
locale?: Locale
}>()
const expanded = ref(false)
</script>
<template>
<section
class="px-4 py-8 lg:px-20 lg:py-12"
:aria-label="t('demos.transcript.label', locale)"
>
<div class="mx-auto max-w-4xl">
<button
type="button"
class="text-primary-comfy-canvas text-left"
:aria-expanded="expanded"
@click="expanded = !expanded"
>
<span class="text-sm font-semibold tracking-wide uppercase">
{{ t('demos.transcript.label', locale) }}
</span>
<span class="text-primary-warm-gray ml-2 text-xs">
{{ t('demos.transcript.note', locale) }}
</span>
</button>
<div
role="region"
:aria-label="t('demos.transcript.label', locale)"
:class="
cn(
expanded ? 'mt-4' : 'sr-only',
'text-primary-warm-gray text-sm/relaxed'
)
"
v-html="transcript"
/>
</div>
</section>
</template>

View File

@@ -1,24 +1,31 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { externalLinks } from '../../config/routes'
import { useHeroLogo } from '../../composables/useHeroLogo'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const logoContainer = ref<HTMLElement>()
const { loaded: logoLoaded } = useHeroLogo(logoContainer)
</script>
<template>
<section
class="relative flex min-h-auto flex-col lg:flex-row lg:items-center"
>
<div class="relative flex-1">
<video
src="https://media.comfy.org/website/homepage/hero-logo-seq.webm"
autoplay
loop
muted
playsinline
class="w-full"
<div
ref="logoContainer"
class="relative flex aspect-square w-full flex-1 items-center justify-center"
>
<img
v-show="!logoLoaded"
src="https://media.comfy.org/website/homepage/hero-logo-seq/Logo00.webp"
alt="Comfy logo"
class="w-3/5"
/>
</div>

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { externalLinks } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import SectionLabel from '../common/SectionLabel.vue'
// Display-only thank-you / failure pages: payment state is verified
// server-side via Stripe webhooks (see comfy-api). These pages exist
// solely as the redirect target for Stripe Checkout.
type Status = 'success' | 'failed'
const { status, locale = 'en' } = defineProps<{
status: Status
locale?: Locale
}>()
const primaryHref =
status === 'success' ? externalLinks.cloud : externalLinks.support
const secondaryHref =
status === 'success'
? externalLinks.platformUsage
: externalLinks.docsSubscription
const iconRingClass =
status === 'success'
? 'border-primary-comfy-yellow text-primary-comfy-yellow'
: 'border-secondary-mauve text-secondary-mauve'
</script>
<template>
<section
class="flex min-h-[calc(100dvh-12rem)] items-center justify-center px-6 py-16 lg:py-24"
>
<div class="flex max-w-2xl flex-col items-center gap-6 text-center">
<div
:class="
cn(
'flex size-20 items-center justify-center rounded-full border-2',
iconRingClass
)
"
aria-hidden="true"
>
<svg
v-if="status === 'success'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="size-10"
>
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
<svg
v-else
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="size-10"
>
<path d="M6 6l12 12" />
<path d="M18 6L6 18" />
</svg>
</div>
<SectionLabel>{{ t(`payment.${status}.label`, locale) }}</SectionLabel>
<h1
class="text-primary-comfy-canvas text-4xl/tight font-light md:text-5xl/tight lg:text-6xl/tight"
>
{{ t(`payment.${status}.title`, locale) }}
</h1>
<p
class="text-primary-comfy-canvas/80 max-w-xl text-base font-light lg:text-lg"
>
{{ t(`payment.${status}.subtitle`, locale) }}
</p>
<div
class="mt-2 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-center"
>
<BrandButton :href="primaryHref" variant="solid" size="nav">
{{ t(`payment.${status}.primaryCta`, locale) }}
</BrandButton>
<BrandButton :href="secondaryHref" variant="outline" size="nav">
{{ t(`payment.${status}.secondaryCta`, locale) }}
</BrandButton>
</div>
</div>
</section>
</template>

View File

@@ -35,20 +35,20 @@ onMounted(() => {
<template>
<section
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden lg:flex-row-reverse lg:items-center lg:overflow-x-visible lg:overflow-y-clip lg:pb-[min(8vw,10rem)]"
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden pt-16 lg:flex-row-reverse lg:items-center lg:overflow-x-visible lg:overflow-y-clip lg:pt-[min(8vw,10rem)] lg:pb-[min(8vw,10rem)]"
>
<!-- Illustration (overlaps text slightly; stacks above on mobile, right on lg) -->
<div
class="aspect-square w-4/5 max-w-md scale-150 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-ml-12 lg:-translate-x-[10%] lg:translate-y-[40px] lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
class="aspect-square w-4/5 max-w-md scale-150 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-ml-12 lg:-translate-x-[10%] lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
>
<svg
ref="svgRef"
class="block size-full"
class="block size-full overflow-visible"
viewBox="0 0 1600 1046"
fill="none"
aria-hidden="true"
>
<g clip-path="url(#enterpriseHeroClip)">
<g>
<rect width="1600" height="1046" fill="#211927" />
<rect
width="800"
@@ -84,7 +84,7 @@ onMounted(() => {
/>
<!-- Exploding block cluster -->
<g class="block-cluster">
<g class="block-cluster" clip-path="url(#enterpriseHeroBlockClip)">
<path
class="block-piece"
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
@@ -353,7 +353,7 @@ onMounted(() => {
<stop stop-color="#211927" stop-opacity="0" />
<stop offset="1" stop-color="#211927" />
</linearGradient>
<clipPath id="enterpriseHeroClip">
<clipPath id="enterpriseHeroBlockClip">
<rect width="1600" height="1046" fill="white" />
</clipPath>
</defs>

View File

@@ -28,7 +28,11 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<!-- CTA buttons -->
<div class="mt-10 flex flex-col gap-4 lg:flex-row">
<DownloadLocalButton :locale />
<BrandButton :href="externalLinks.github" variant="outline" size="lg">
<BrandButton
:href="externalLinks.githubInstall"
variant="outline"
size="lg"
>
<span class="inline-flex items-center gap-2">
<i
class="icon-mask size-5 -translate-y-px mask-[url('/icons/social/github.svg')]"

View File

@@ -323,7 +323,7 @@ onUnmounted(() => {
<div class="mt-8 flex flex-col gap-4 lg:flex-row">
<DownloadLocalButton :locale class="lg:min-w-60 lg:p-4" />
<BrandButton
:href="externalLinks.github"
:href="externalLinks.githubInstall"
variant="outline"
size="lg"
class="lg:min-w-60 lg:p-4"

View File

@@ -8,7 +8,9 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="bg-transparency-white-t4 p-4 text-center lg:px-20 lg:py-8">
<section
class="bg-transparency-white-t4 relative z-20 p-4 text-center lg:px-20 lg:py-8"
>
<p
class="text-primary-comfy-canvas relative z-10 text-lg font-semibold lg:text-sm lg:font-normal"
>

View File

@@ -0,0 +1,328 @@
import type { Ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import * as THREE from 'three'
import { SVGLoader } from 'three/addons/loaders/SVGLoader.js'
import { prefersReducedMotion } from './useReducedMotion'
const IMAGE_COUNT = 16
const BASE_URL = 'https://media.comfy.org/website/homepage/hero-logo-seq'
const SVG_MARKUP = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 375 404"><path fill="#000000" d="M296.597 302.576C297.299 300.205 297.682 297.705 297.682 295.078C297.682 280.529 285.938 268.736 271.45 268.736H153.883C147.564 268.8 142.395 263.673 142.395 257.328C142.395 256.174 142.586 255.084 142.841 254.059L174.499 143.309C175.839 138.438 180.307 134.849 185.541 134.849L303.554 134.72C328.446 134.72 349.444 117.864 355.763 94.8555L373.506 33.1353C374.081 30.9562 374.4 28.5848 374.4 26.2134C374.4 11.7288 362.72 0 348.295 0H205.518C180.754 0 159.819 16.7279 153.373 39.4804L141.373 81.5886C139.969 86.3954 135.565 89.9205 130.332 89.9205H96.0573C71.4845 89.9205 50.7412 106.328 44.1034 128.824L0.957382 280.144C0.319127 282.387 0 284.823 0 287.258C0 301.807 11.7439 313.6 26.2323 313.6H59.9321C66.2508 313.6 71.4207 318.727 71.4207 325.137C71.4207 326.226 71.293 327.316 70.9739 328.341L59.0385 370.065C58.4641 372.308 58.0811 374.615 58.0811 376.987C58.0811 391.471 69.7612 403.2 84.1857 403.2L227.027 403.072C251.855 403.072 272.79 386.28 279.172 363.399L296.533 302.64L296.597 302.576Z"/></svg>`
interface HeroLogoConfig {
speed: number
tiltX: number
tiltZ: number
zoom: number
fov: number
logoColor: string
extrudeDepth: number
cursorTiltStrength: number
bgScale: number
slideDuration: number
}
const DEFAULTS: HeroLogoConfig = {
speed: 1,
tiltX: -0.1,
tiltZ: -0.1,
zoom: 7,
fov: 50,
logoColor: '#F2FF59',
extrudeDepth: 200,
cursorTiltStrength: 0.5,
bgScale: 0.8,
slideDuration: 0.4
}
function buildImageUrls(): string[] {
return Array.from({ length: IMAGE_COUNT }, (_, i) => {
const index = String(i).padStart(5, '0')
return `${BASE_URL}/image_sequence_${index}.webp`
})
}
function parseShapes(): THREE.Shape[] {
const loader = new SVGLoader()
const svgData = loader.parse(SVG_MARKUP)
const shapes: THREE.Shape[] = []
svgData.paths.forEach((path) => {
shapes.push(...SVGLoader.createShapes(path))
})
return shapes
}
function loadTextures(urls: string[]): Promise<THREE.Texture[]> {
return Promise.all(
urls.map(
(url) =>
new Promise<THREE.Texture | null>((resolve) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
const tex = new THREE.Texture(img)
tex.needsUpdate = true
tex.colorSpace = THREE.SRGBColorSpace
resolve(tex)
}
img.onerror = () => resolve(null)
img.src = url
})
)
).then((results) => results.filter((t): t is THREE.Texture => t !== null))
}
export function useHeroLogo(
containerRef: Ref<HTMLElement | undefined>,
config: Partial<HeroLogoConfig> = {}
) {
const cfg = { ...DEFAULTS, ...config }
const loaded = ref(false)
let cleanup: (() => void) | undefined
onMounted(async () => {
try {
const container = containerRef.value
if (!container || prefersReducedMotion()) return
const { width, height } = container.getBoundingClientRect()
const renderer = new THREE.WebGLRenderer({
antialias: true,
stencil: true,
alpha: true
})
renderer.setSize(width, height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.domElement.style.position = 'absolute'
renderer.domElement.style.inset = '0'
renderer.domElement.style.width = '100%'
renderer.domElement.style.height = '100%'
renderer.domElement.style.opacity = '0'
renderer.domElement.setAttribute('aria-hidden', 'true')
container.appendChild(renderer.domElement)
let disposed = false
const teardowns: Array<() => void> = []
cleanup = () => {
disposed = true
teardowns.forEach((fn) => fn())
}
teardowns.push(() => {
renderer.dispose()
renderer.domElement.remove()
})
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(
cfg.fov,
width / height,
0.1,
1000
)
camera.position.z = cfg.zoom
// SVG shape
const shapes = parseShapes()
const tempGeo = new THREE.ShapeGeometry(shapes)
tempGeo.computeBoundingBox()
const bb = tempGeo.boundingBox!
const cx = (bb.max.x + bb.min.x) / 2
const cy = (bb.max.y + bb.min.y) / 2
const scaleFactor = 3 / (bb.max.y - bb.min.y)
tempGeo.dispose()
// Image sequence textures — load first frame eagerly, rest lazily
const urls = buildImageUrls()
const textures = await loadTextures(urls.slice(0, 1))
if (disposed) return
renderer.domElement.style.opacity = '1'
loaded.value = true
loadTextures(urls.slice(1)).then((rest) => {
if (!disposed) textures.push(...rest)
})
// Background plane (stencil read)
const bgPlaneGeo = new THREE.PlaneGeometry(14, 14)
const bgPlaneMat = new THREE.MeshBasicMaterial({
transparent: true,
opacity: 1,
map: textures[0] ?? null,
depthTest: false,
depthWrite: false,
stencilWrite: true,
stencilFunc: THREE.EqualStencilFunc,
stencilRef: 1,
stencilFail: THREE.KeepStencilOp,
stencilZFail: THREE.KeepStencilOp,
stencilZPass: THREE.KeepStencilOp
})
const bgPlane = new THREE.Mesh(bgPlaneGeo, bgPlaneMat)
bgPlane.renderOrder = 1
bgPlane.scale.set(cfg.bgScale, cfg.bgScale, 1)
scene.add(bgPlane)
// Logo group
const group = new THREE.Group()
scene.add(group)
const s = scaleFactor
const depth = cfg.extrudeDepth
// Front face
const shapeGeo = new THREE.ShapeGeometry(shapes)
shapeGeo.translate(-cx, -cy, 0)
shapeGeo.scale(s, -s, s)
const shapeMat = new THREE.MeshBasicMaterial({
color: cfg.logoColor,
side: THREE.DoubleSide,
depthTest: false,
depthWrite: false,
transparent: true
})
const logoMesh = new THREE.Mesh(shapeGeo, shapeMat)
logoMesh.renderOrder = 2
group.add(logoMesh)
// Extrusion stencil mask
const extrudeGeo = new THREE.ExtrudeGeometry(shapes, {
depth,
bevelEnabled: false
})
extrudeGeo.translate(-cx, -cy, -depth)
extrudeGeo.scale(s, -s, s)
const extrudeMat = new THREE.MeshBasicMaterial({
colorWrite: false,
depthWrite: true,
depthTest: true,
stencilWrite: true,
stencilRef: 1,
stencilFunc: THREE.AlwaysStencilFunc,
stencilZPass: THREE.ReplaceStencilOp,
stencilFail: THREE.KeepStencilOp,
stencilZFail: THREE.KeepStencilOp,
side: THREE.DoubleSide
})
const extrudeMesh = new THREE.Mesh(extrudeGeo, extrudeMat)
extrudeMesh.renderOrder = 0
group.add(extrudeMesh)
// Interaction
let isDragging = false
let previousX = 0
let dragVelocity = 0
let currentTiltX = 0
let currentTiltY = 0
let pointerX = 0
let pointerY = 0
let rotationT = 0
let currentSlide = 0
let slideTimer = 0
let animationId = 0
function onMouseMove(e: MouseEvent) {
pointerX = (e.clientX / window.innerWidth) * 2 - 1
pointerY = (e.clientY / window.innerHeight) * 2 - 1
}
function onPointerDown(e: PointerEvent) {
isDragging = true
dragVelocity = 0
previousX = e.clientX
}
function onPointerMove(e: PointerEvent) {
if (!isDragging) return
dragVelocity = (e.clientX - previousX) * 0.005
rotationT += dragVelocity
previousX = e.clientX
}
function onPointerUp() {
isDragging = false
}
function onResize() {
const rect = container!.getBoundingClientRect()
camera.aspect = rect.width / rect.height
camera.updateProjectionMatrix()
renderer.setSize(rect.width, rect.height)
}
window.addEventListener('mousemove', onMouseMove)
renderer.domElement.addEventListener('pointerdown', onPointerDown)
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('resize', onResize)
const clock = new THREE.Clock()
function animate() {
if (disposed) return
animationId = requestAnimationFrame(animate)
const dt = clock.getDelta()
if (!isDragging && Math.abs(dragVelocity) > 0.0001) {
dragVelocity *= 0.95
rotationT += dragVelocity
} else if (!isDragging) {
dragVelocity = 0
}
rotationT += cfg.speed * dt
currentTiltX += (pointerY - currentTiltX) * 0.08
currentTiltY += (pointerX - currentTiltY) * 0.08
group.rotation.y = rotationT % (Math.PI * 2)
group.rotation.x = cfg.tiltX - currentTiltX * cfg.cursorTiltStrength
group.rotation.z = cfg.tiltZ
if (textures.length > 1) {
slideTimer += dt
if (slideTimer >= cfg.slideDuration) {
slideTimer = 0
currentSlide = (currentSlide + 1) % textures.length
bgPlaneMat.map = textures[currentSlide]
bgPlaneMat.needsUpdate = true
}
}
renderer.render(scene, camera)
}
animate()
teardowns.push(
() => cancelAnimationFrame(animationId),
() => window.removeEventListener('mousemove', onMouseMove),
() =>
renderer.domElement.removeEventListener('pointerdown', onPointerDown),
() => window.removeEventListener('pointermove', onPointerMove),
() => window.removeEventListener('pointerup', onPointerUp),
() => window.removeEventListener('resize', onResize),
() => bgPlaneGeo.dispose(),
() => bgPlaneMat.dispose(),
() => shapeGeo.dispose(),
() => shapeMat.dispose(),
() => extrudeGeo.dispose(),
() => extrudeMat.dispose(),
() => textures.forEach((tex) => tex.dispose())
)
} catch (err) {
console.error('[useHeroLogo] initialization failed:', err)
cleanup?.()
}
})
onUnmounted(() => {
cleanup?.()
})
return { loaded }
}

View File

@@ -0,0 +1,68 @@
import type { TranslationKey } from '../i18n/translations'
interface Demo {
readonly slug: string
readonly arcadeId: string
readonly category: TranslationKey
readonly title: TranslationKey
readonly description: TranslationKey
readonly ogImage: string
readonly thumbnail: string
readonly estimatedTime: TranslationKey
readonly durationIso: string
readonly difficulty: 'beginner' | 'intermediate' | 'advanced'
readonly tags: readonly string[]
readonly transcript?: TranslationKey
readonly publishedDate: string
readonly modifiedDate: string
}
export const demos: readonly Demo[] = [
{
slug: 'image-to-video',
arcadeId: 'F3CTalnGnR4R0qJIVMNX',
category: 'demos.category.templates',
title: 'demos.image-to-video.title',
description: 'demos.image-to-video.description',
transcript: 'demos.image-to-video.transcript',
ogImage: '/images/demos/image-to-video-og.png',
thumbnail: '/images/demos/image-to-video-thumb.webp',
estimatedTime: 'demos.duration.2min',
durationIso: 'PT2M',
difficulty: 'beginner',
tags: ['templates', 'image', 'video'],
publishedDate: '2026-04-19',
modifiedDate: '2026-04-19'
},
{
slug: 'workflow-templates',
arcadeId: 'KhqcXDElnFWklo7ACBqE',
category: 'demos.category.gettingStarted',
title: 'demos.workflow-templates.title',
description: 'demos.workflow-templates.description',
transcript: 'demos.workflow-templates.transcript',
ogImage: '/images/demos/workflow-templates-og.png',
thumbnail: '/images/demos/workflow-templates-thumb.webp',
estimatedTime: 'demos.duration.2min',
durationIso: 'PT2M',
difficulty: 'beginner',
tags: ['getting-started', 'templates', 'workflow'],
publishedDate: '2026-04-19',
modifiedDate: '2026-04-19'
}
]
export function getDemoBySlug(slug: string): Demo | undefined {
return demos.find((demo) => demo.slug === slug)
}
export function getNextDemo(slug: string): Demo {
if (demos.length === 0) {
throw new Error('No demos configured')
}
const index = demos.findIndex((demo) => demo.slug === slug)
if (index === -1) {
throw new Error(`Unknown demo slug: ${slug}`)
}
return demos[(index + 1) % demos.length]
}

View File

@@ -11,6 +11,7 @@ const baseRoutes = {
about: '/about',
careers: '/careers',
customers: '/customers',
demos: '/demos',
termsOfService: '/terms-of-service',
privacyPolicy: '/privacy-policy',
contact: '/contact'
@@ -33,8 +34,11 @@ export const externalLinks = {
discord: 'https://discord.com/invite/comfyorg',
docs: 'https://docs.comfy.org/',
docsApi: 'https://docs.comfy.org/api-reference/cloud',
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',
support: 'https://support.comfy.org/hc/en-us',
workflows: 'https://comfy.org/workflows',
youtube: 'https://www.youtube.com/@ComfyOrg'

View File

@@ -1599,7 +1599,7 @@ const translations = {
},
'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' },
'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
'nav.blogs': { en: 'Blogs', 'zh-CN': '博客' },
'nav.blogs': { en: 'Blog', 'zh-CN': '博客' },
'nav.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
'nav.discord': { en: 'Discord', 'zh-CN': 'Discord' },
'nav.docs': { en: 'Docs', 'zh-CN': '文档' },
@@ -3498,18 +3498,6 @@ const translations = {
en: 'Dale Carman | Co-founder @ Groove Jones',
'zh-CN': 'Dale Carman | Groove Jones 联合创始人'
},
'customers.detail.groove-jones.topic-10.block.2.label': {
en: 'GROOVE JONES CONTRIBUTORS',
'zh-CN': 'GROOVE JONES 贡献者'
},
'customers.detail.groove-jones.topic-10.block.2.name': {
en: 'TBD',
'zh-CN': '待补充'
},
'customers.detail.groove-jones.topic-10.block.2.role': {
en: 'TBD',
'zh-CN': '待补充'
},
// Contact FormSection
'contact.form.badge': {
@@ -3542,6 +3530,80 @@ const translations = {
'zh-CN': '我们会为您处理请求。'
},
'demos.category.templates': { en: 'TEMPLATES', 'zh-CN': '模板' },
'demos.category.gettingStarted': { en: 'GETTING STARTED', 'zh-CN': '入门' },
'demos.image-to-video.title': {
en: 'Create a Video from an Image',
'zh-CN': '从图片创建视频'
},
'demos.image-to-video.description': {
en: 'Learn how to use the Image to Video workflow template in ComfyUI to generate short video clips from a single image.',
'zh-CN':
'了解如何使用 ComfyUI 中的图片转视频工作流模板,从单张图片生成短视频。'
},
'demos.image-to-video.transcript': {
en: '<ol><li><strong>Open ComfyUI</strong> — Launch the application and you\'ll see the node-based workflow canvas where all your AI pipelines are built.</li><li><strong>Browse templates</strong> — Click the workflow templates button in the sidebar to browse available starting points.</li><li><strong>Select Image to Video</strong> — Find and select the "Image to Video" template from the list to load it onto your canvas.</li><li><strong>Upload your image</strong> — Click the image upload node and select the source image you want to animate.</li><li><strong>Run the workflow</strong> — Click the "Queue" button to execute the workflow and generate your video output.</li></ol>',
'zh-CN':
'<ol><li><strong>打开 ComfyUI</strong> — 启动应用程序,您将看到基于节点的工作流画布。</li><li><strong>浏览模板</strong> — 点击侧栏中的工作流模板按钮,浏览可用模板。</li><li><strong>选择图片转视频</strong> — 从列表中找到并选择"图片转视频"模板。</li><li><strong>上传图片</strong> — 点击图片上传节点,选择要动画化的源图片。</li><li><strong>运行工作流</strong> — 点击"排队"按钮执行工作流并生成视频输出。</li></ol>'
},
'demos.workflow-templates.title': {
en: 'Browse Workflow Templates',
'zh-CN': '浏览工作流模板'
},
'demos.workflow-templates.description': {
en: "Explore ComfyUI's built-in workflow templates to quickly get started with common AI generation tasks.",
'zh-CN': '探索 ComfyUI 内置的工作流模板,快速开始常见的 AI 生成任务。'
},
'demos.workflow-templates.transcript': {
en: '<ol><li><strong>Open the template browser</strong> — Click the templates icon in the ComfyUI sidebar to open the template library.</li><li><strong>Browse categories</strong> — Templates are organized by task: image generation, video, upscaling, and more.</li><li><strong>Preview a template</strong> — Hover over any template to see a preview of its workflow and expected output.</li><li><strong>Load and customize</strong> — Click to load a template, then modify parameters to fit your needs.</li></ol>',
'zh-CN':
'<ol><li><strong>打开模板浏览器</strong> — 点击 ComfyUI 侧栏中的模板图标。</li><li><strong>浏览分类</strong> — 模板按任务分类:图像生成、视频、放大等。</li><li><strong>预览模板</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': '所有演示' },
'demos.transcript.label': { en: 'Demo transcript', 'zh-CN': '演示文字记录' },
'demos.transcript.note': {
en: '(for accessibility & search)',
'zh-CN': '(无障碍和搜索)'
},
'demos.loading': {
en: 'Loading interactive demo…',
'zh-CN': '正在加载互动演示…'
},
'demos.noscript': {
en: 'This interactive demo requires JavaScript.',
'zh-CN': '此互动演示需要 JavaScript。'
},
'demos.noscript.link': {
en: 'View on Arcade →',
'zh-CN': '在 Arcade 上查看 →'
},
'demos.duration.2min': { en: '~2 min', 'zh-CN': '~2 分钟' },
'demos.difficulty.beginner': { en: 'Beginner', 'zh-CN': '入门' },
'demos.difficulty.intermediate': {
en: 'Intermediate',
'zh-CN': '中级'
},
'demos.difficulty.advanced': { en: 'Advanced', 'zh-CN': '高级' },
'demos.embed.label': {
en: 'Interactive demo',
'zh-CN': '互动演示'
},
'demos.comingSoon.title': {
en: 'Coming Soon',
'zh-CN': '即将推出'
},
'demos.comingSoon.body': {
en: 'This page is being redesigned. Check back soon.',
'zh-CN': '此页面正在重新设计中,请稍后再来。'
},
'demos.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
'demos.breadcrumb.demos': { en: 'Demos', 'zh-CN': '演示' },
'customers.story.whatsNext': {
en: "What's next?",
'zh-CN': '接下来看什么?'
@@ -3592,6 +3654,49 @@ const translations = {
'customers.feedback.role3': {
en: 'Head of AI at Creative Studios',
'zh-CN': 'Creative Studios AI 负责人'
},
// Payment status pages
'payment.success.label': {
en: 'PAYMENT',
'zh-CN': '支付'
},
'payment.success.title': {
en: 'Payment successful',
'zh-CN': '支付成功'
},
'payment.success.subtitle': {
en: "Thanks for your purchase. Your account has been credited and you're ready to keep building.",
'zh-CN': '感谢您的购买。您的账户已充值完成,可以继续创作了。'
},
'payment.success.primaryCta': {
en: 'CONTINUE TO COMFY CLOUD',
'zh-CN': '前往 COMFY CLOUD'
},
'payment.success.secondaryCta': {
en: 'VIEW USAGE & PAYMENTS',
'zh-CN': '查看用量与支付'
},
'payment.failed.label': {
en: 'PAYMENT',
'zh-CN': '支付'
},
'payment.failed.title': {
en: 'Unable to complete payment',
'zh-CN': '无法完成支付'
},
'payment.failed.subtitle': {
en: "Your payment didn't go through and you have not been charged. Reach out to support or read the subscription docs if you need help.",
'zh-CN':
'您的支付未能完成,未发生扣款。如需帮助,请联系支持或查阅订阅文档。'
},
'payment.failed.primaryCta': {
en: 'CONTACT SUPPORT',
'zh-CN': '联系支持'
},
'payment.failed.secondaryCta': {
en: 'READ SUBSCRIPTION DOCS',
'zh-CN': '查看订阅文档'
}
} as const satisfies Record<string, Record<Locale, string>>

View File

@@ -10,6 +10,7 @@ import { fetchGitHubStars, formatStarCount } from '../utils/github'
interface Props {
title: string
description?: string
keywords?: string[]
ogImage?: string
noindex?: boolean
}
@@ -17,10 +18,13 @@ interface Props {
const {
title,
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
keywords,
ogImage = 'https://media.comfy.org/website/comfy.webp',
noindex = false,
} = Astro.props
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
const siteBase = Astro.site ?? 'https://comfy.org'
const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
@@ -62,6 +66,7 @@ const websiteJsonLd = {
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
{keywordsContent && <meta name="keywords" content={keywordsContent} />}
{noindex && <meta name="robots" content="noindex, nofollow" />}
<title>{title}</title>
@@ -109,6 +114,7 @@ const websiteJsonLd = {
)}
<ClientRouter />
<slot name="head" />
</head>
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
{gtmEnabled && (

View File

@@ -7,9 +7,14 @@ import AudienceSection from '../../components/product/cloud/AudienceSection.vue'
import PricingSection from '../../components/product/cloud/PricingSection.vue'
import ProductCardsSection from '../../components/product/cloud/ProductCardsSection.vue'
import FAQSection from '../../components/product/cloud/FAQSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout title="Comfy Cloud — AI in the Cloud">
<BaseLayout
title="Comfy Cloud — AI in the Cloud"
description={t('cloud.hero.subtitle', 'en')}
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'comfy cloud', 'comfy ui application', 'comfyui browser', 'cloud comfyui', 'managed comfyui']}
>
<HeroSection />
<ReasonSection />
<AIModelsSection />

View File

@@ -0,0 +1,139 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../layouts/BaseLayout.astro'
import DemoHeroSection from '../../components/demos/DemoHeroSection.vue'
import ArcadeEmbed from '../../components/demos/ArcadeEmbed.vue'
import DemoTranscript from '../../components/demos/DemoTranscript.vue'
import DemoNavSection from '../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
import { t } from '../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
params: { slug: demo.slug }
}))
}
const { slug } = Astro.params
const demo = getDemoBySlug(slug as string)!
const nextDemo = getNextDemo(slug as string)
const title = t(demo.title)
const description = t(demo.description)
const canonicalURL = new URL(`/demos/${demo.slug}`, Astro.site)
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: title,
description,
image: new URL(demo.ogImage, Astro.site).href,
totalTime: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const learningResourceJsonLd = {
'@context': 'https://schema.org',
'@type': 'LearningResource',
name: title,
description,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel: demo.difficulty === 'beginner'
? 'Beginner'
: demo.difficulty === 'intermediate'
? 'Intermediate'
: 'Advanced',
url: canonicalURL.href,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('demos.breadcrumb.home'),
item: 'https://comfy.org'
},
{
'@type': 'ListItem',
position: 2,
name: t('demos.breadcrumb.demos'),
item: 'https://comfy.org/demos'
},
{
'@type': 'ListItem',
position: 3,
name: title
}
]
}
---
<BaseLayout
title={`${title} — Comfy`}
description={description}
ogImage={demo.ogImage}
>
<Fragment slot="head">
<meta property="article:published_time" content={demo.publishedDate} />
<meta property="article:modified_time" content={demo.modifiedDate} />
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(howToJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(learningResourceJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<link rel="preconnect" href="https://demo.arcade.software" />
</Fragment>
<DemoHeroSection
label={t(demo.category)}
title={title}
description={description}
difficulty={demo.difficulty}
estimatedTime={demo.estimatedTime}
/>
<ArcadeEmbed
arcadeId={demo.arcadeId}
title={title}
client:load
/>
{demo.transcript && (
<DemoTranscript
transcript={t(demo.transcript)}
client:visible
/>
)}
<DemoNavSection
nextTitle={t(nextDemo.title)}
nextSlug={nextDemo.slug}
nextThumbnail={nextDemo.thumbnail}
/>
</BaseLayout>

View File

@@ -0,0 +1,8 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ComingSoon from '../../components/common/ComingSoon.astro'
---
<BaseLayout title="Demos — Comfy" description="Interactive demos and tutorials for ComfyUI.">
<ComingSoon />
</BaseLayout>

View File

@@ -7,9 +7,14 @@ import ReasonSection from '../components/product/local/ReasonSection.vue'
import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
import FAQSection from '../components/product/local/FAQSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout title="Download Comfy — Run AI Locally">
<BaseLayout
title="Download Comfy — Run AI Locally"
description={t('download.hero.subtitle', 'en')}
keywords={['comfyui app', 'comfyui desktop app', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux', 'comfyui local']}
>
<CloudBannerSection />
<HeroSection client:load />
<ReasonSection />

View File

@@ -8,9 +8,14 @@ import UseCaseSection from '../components/home/UseCaseSection.vue'
import CaseStudySpotlightSection from '../components/home/CaseStudySpotlightSection.vue'
import GetStartedSection from '../components/home/GetStartedSection.vue'
import BuildWhatSection from '../components/home/BuildWhatSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout title="Comfy — Professional Control of Visual AI">
<BaseLayout
title="Comfy — Professional Control of Visual AI"
description={t('hero.subtitle', 'en')}
keywords={['comfyui app', 'comfyui web app', 'comfy ui application', 'comfyui application', 'comfy app', 'comfyui', 'visual ai app', 'node-based ai', 'generative ai workflows']}
>
<HeroSection client:load />
<SocialProofBarSection />
<ProductShowcaseSection client:load />

View File

@@ -0,0 +1,12 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import PaymentStatusSection from '../../components/payment/PaymentStatusSection.vue'
---
<BaseLayout
title="Payment Failed — Comfy"
description="Your payment was not completed."
noindex
>
<PaymentStatusSection status="failed" />
</BaseLayout>

View File

@@ -0,0 +1,12 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import PaymentStatusSection from '../../components/payment/PaymentStatusSection.vue'
---
<BaseLayout
title="Payment Successful — Comfy"
description="Your payment was processed successfully."
noindex
>
<PaymentStatusSection status="success" />
</BaseLayout>

View File

@@ -7,9 +7,14 @@ import AudienceSection from '../../../components/product/cloud/AudienceSection.v
import PricingSection from '../../../components/product/cloud/PricingSection.vue'
import ProductCardsSection from '../../../components/product/cloud/ProductCardsSection.vue'
import FAQSection from '../../../components/product/cloud/FAQSection.vue'
import { t } from '../../../i18n/translations'
---
<BaseLayout title="Comfy Cloud — 云端 AI">
<BaseLayout
title="Comfy Cloud — 云端 AI"
description={t('cloud.hero.subtitle', 'zh-CN')}
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'ComfyUI 网页版', 'ComfyUI 云端', 'ComfyUI 应用', 'Comfy Cloud', '云端 ComfyUI']}
>
<HeroSection locale="zh-CN" />
<ReasonSection locale="zh-CN" />
<AIModelsSection locale="zh-CN" />

View File

@@ -0,0 +1,143 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import DemoHeroSection from '../../../components/demos/DemoHeroSection.vue'
import ArcadeEmbed from '../../../components/demos/ArcadeEmbed.vue'
import DemoTranscript from '../../../components/demos/DemoTranscript.vue'
import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
import { t } from '../../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
params: { slug: demo.slug }
}))
}
const { slug } = Astro.params
const demo = getDemoBySlug(slug as string)!
const nextDemo = getNextDemo(slug as string)
const title = t(demo.title, 'zh-CN')
const description = t(demo.description, 'zh-CN')
const canonicalURL = new URL(`/zh-CN/demos/${demo.slug}`, Astro.site)
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: title,
description,
image: new URL(demo.ogImage, Astro.site).href,
totalTime: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const learningResourceJsonLd = {
'@context': 'https://schema.org',
'@type': 'LearningResource',
name: title,
description,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel: demo.difficulty === 'beginner'
? 'Beginner'
: demo.difficulty === 'intermediate'
? 'Intermediate'
: 'Advanced',
url: canonicalURL.href,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('demos.breadcrumb.home', 'zh-CN'),
item: 'https://comfy.org/zh-CN'
},
{
'@type': 'ListItem',
position: 2,
name: t('demos.breadcrumb.demos', 'zh-CN'),
item: 'https://comfy.org/zh-CN/demos'
},
{
'@type': 'ListItem',
position: 3,
name: title
}
]
}
---
<BaseLayout
title={`${title} — Comfy`}
description={description}
ogImage={demo.ogImage}
>
<Fragment slot="head">
<meta property="article:published_time" content={demo.publishedDate} />
<meta property="article:modified_time" content={demo.modifiedDate} />
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(howToJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(learningResourceJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<link rel="preconnect" href="https://demo.arcade.software" />
</Fragment>
<DemoHeroSection
label={t(demo.category, 'zh-CN')}
title={title}
description={description}
difficulty={demo.difficulty}
estimatedTime={demo.estimatedTime}
locale="zh-CN"
/>
<ArcadeEmbed
arcadeId={demo.arcadeId}
title={title}
locale="zh-CN"
client:load
/>
{demo.transcript && (
<DemoTranscript
transcript={t(demo.transcript, 'zh-CN')}
locale="zh-CN"
client:visible
/>
)}
<DemoNavSection
nextTitle={t(nextDemo.title, 'zh-CN')}
nextSlug={nextDemo.slug}
nextThumbnail={nextDemo.thumbnail}
locale="zh-CN"
/>
</BaseLayout>

View File

@@ -0,0 +1,17 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { t } from '../../../i18n/translations'
---
<BaseLayout title="演示 — Comfy" description="ComfyUI 的互动演示和教程。">
<section class="flex min-h-[60vh] items-center justify-center px-6">
<div class="text-center">
<h1 class="text-primary-comfy-canvas text-4xl font-light">
{t('demos.comingSoon.title', 'zh-CN')}
</h1>
<p class="text-primary-warm-gray mt-4 text-sm">
{t('demos.comingSoon.body', 'zh-CN')}
</p>
</div>
</section>
</BaseLayout>

View File

@@ -7,9 +7,14 @@ import ReasonSection from '../../components/product/local/ReasonSection.vue'
import EcoSystemSection from '../../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../../components/product/local/ProductCardsSection.vue'
import FAQSection from '../../components/product/local/FAQSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout title="下载 — Comfy">
<BaseLayout
title="下载 — Comfy"
description={t('download.hero.subtitle', 'zh-CN')}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
>
<CloudBannerSection locale="zh-CN" />
<HeroSection locale="zh-CN" client:load />
<ReasonSection locale="zh-CN" />

View File

@@ -8,9 +8,14 @@ import UseCaseSection from '../../components/home/UseCaseSection.vue'
import CaseStudySpotlightSection from '../../components/home/CaseStudySpotlightSection.vue'
import GetStartedSection from '../../components/home/GetStartedSection.vue'
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout title="Comfy — 视觉 AI 的最强可控性">
<BaseLayout
title="Comfy — 视觉 AI 的最强可控性"
description={t('hero.subtitle', 'zh-CN')}
keywords={['comfyui app', 'comfyui web app', 'comfyui application', 'ComfyUI 应用', 'ComfyUI 网页版', 'ComfyUI 桌面应用', 'ComfyUI 下载', '可视化 AI', '节点式 AI', '生成式 AI 工作流']}
>
<HeroSection locale="zh-CN" client:load />
<SocialProofBarSection />
<ProductShowcaseSection locale="zh-CN" client:load />

View File

@@ -0,0 +1,8 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro'
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
---
<BaseLayout title="支付失败 — Comfy" description="您的支付未能完成。" noindex>
<PaymentStatusSection status="failed" locale="zh-CN" />
</BaseLayout>

View File

@@ -0,0 +1,8 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro'
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
---
<BaseLayout title="支付成功 — Comfy" description="您的支付已成功完成。" noindex>
<PaymentStatusSection status="success" locale="zh-CN" />
</BaseLayout>

View File

@@ -0,0 +1,232 @@
{
"id": "14af6003-d4ee-4dee-8e3d-cbff2e5519b3",
"revision": 0,
"last_node_id": 205,
"last_link_id": 383,
"nodes": [
{
"id": 205,
"type": "821645cc-a5d2-468f-990c-17d9de2e0d1b",
"pos": [4720, 5820],
"size": [400, 470],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"label": "lotus_model",
"name": "unet_name_1",
"type": "COMBO",
"widget": {
"name": "unet_name_1"
},
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"proxyWidgets": [["76", "unet_name"]]
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "821645cc-a5d2-468f-990c-17d9de2e0d1b",
"version": 1,
"state": {
"lastGroupId": 8,
"lastNodeId": 205,
"lastLinkId": 383,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Depth to Image (Z-Image-Turbo)",
"inputNode": {
"id": -10,
"bounding": [28, 4936, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [1599, 4936, 128, 68]
},
"inputs": [
{
"id": "80e6915f-5d59-4d6b-a197-d8c565ad2922",
"name": "unet_name_1",
"type": "COMBO",
"linkIds": [258],
"pos": [132, 4960]
}
],
"outputs": [
{
"id": "47f9a22d-6619-4917-9447-a7d5d08dceb5",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [],
"pos": [1623, 4960]
}
],
"widgets": [],
"nodes": [
{
"id": 76,
"type": "a1134394-29e4-48dc-9b1e-e601a14d6fb8",
"pos": [250, 4910],
"size": [400, 210],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "unet_name",
"type": "COMBO",
"widget": {
"name": "unet_name"
},
"link": 258
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": []
}
],
"properties": {
"proxyWidgets": [["203", "unet_name"]]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 258,
"origin_id": -10,
"origin_slot": 0,
"target_id": 76,
"target_slot": 0,
"type": "COMBO"
}
],
"extra": {
"workflowRendererVersion": "LG",
"ds": {
"scale": 1,
"offset": [-30, -4760]
}
}
},
{
"id": "a1134394-29e4-48dc-9b1e-e601a14d6fb8",
"version": 1,
"state": {
"lastGroupId": 8,
"lastNodeId": 205,
"lastLinkId": 383,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Image to Depth Map (Lotus)",
"inputNode": {
"id": -10,
"bounding": [-60, -173, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [1650, -173, 128, 68]
},
"inputs": [
{
"id": "d721b249-fd2a-441b-9a78-2805f04e2644",
"name": "unet_name",
"type": "COMBO",
"linkIds": [256],
"pos": [44, -149]
}
],
"outputs": [
{
"id": "2ec278bd-0b66-4b30-9c5b-994d5f638214",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [],
"pos": [1674, -149]
}
],
"widgets": [],
"nodes": [
{
"id": 203,
"type": "UNETLoader",
"pos": [180, -200],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "unet_name",
"type": "COMBO",
"widget": {
"name": "unet_name"
},
"link": 256
}
],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": []
}
],
"properties": {},
"widgets_values": ["lotus-depth-d-v1-1.safetensors", "default"]
}
],
"groups": [],
"links": [
{
"id": 256,
"origin_id": -10,
"origin_slot": 0,
"target_id": 203,
"target_slot": 0,
"type": "COMBO"
}
],
"extra": {
"workflowRendererVersion": "LG",
"ds": {
"scale": 1,
"offset": [40, 350]
}
}
}
]
},
"config": {},
"extra": {
"workflowRendererVersion": "LG",
"ds": {
"scale": 1,
"offset": [-4500, -5670]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,68 @@
{
"last_node_id": 4,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "E2E_OldSampler",
"pos": [100, 100],
"size": [400, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldSampler" },
"widgets_values": [42, 20, 7, "euler", "normal"]
},
{
"id": 2,
"type": "E2E_OldUpscaler",
"pos": [500, 100],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [{ "name": "image", "type": "IMAGE", "link": null }],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [2],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldUpscaler" },
"widgets_values": ["lanczos", 1.5]
},
{
"id": 3,
"type": "SaveImage",
"pos": [900, 100],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [{ "name": "images", "type": "IMAGE", "link": 2 }],
"properties": { "Node name for S&R": "SaveImage" },
"widgets_values": ["ComfyUI"]
}
],
"links": [[2, 2, 0, 3, 0, "IMAGE"]],
"groups": [],
"config": {},
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
"version": 0.4
}

View File

@@ -0,0 +1,59 @@
{
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "E2E_OldSampler",
"pos": [100, 100],
"size": [400, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [1],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldSampler" },
"widgets_values": [42, 20, 7, "euler", "normal"]
},
{
"id": 2,
"type": "VAEDecode",
"pos": [500, 100],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "samples", "type": "LATENT", "link": 1 },
{ "name": "vae", "type": "VAE", "link": null }
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "VAEDecode" },
"widgets_values": []
}
],
"links": [[1, 1, 0, 2, 0, "LATENT"]],
"groups": [],
"config": {},
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
"version": 0.4
}

View File

@@ -0,0 +1,179 @@
{
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
"revision": 0,
"last_node_id": 4,
"last_link_id": 2,
"nodes": [
{
"id": 2,
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"pos": [602, 409],
"size": [225, 144],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "value"],
["4", "value"]
]
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [349, 383, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [867, 383, 128, 48]
},
"inputs": [
{
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
"name": "value",
"type": "STRING",
"linkIds": [1],
"pos": [453, 407]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "PrimitiveString",
"pos": [537, 368],
"size": [270, 108],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 1
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveString"
},
"widgets_values": [""]
},
{
"id": 3,
"type": "PrimitiveInt",
"pos": [534.9899497487436, 515.4924623115581],
"size": [270, 104],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "INT",
"widget": {
"name": "value"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "INT",
"name": "INT",
"type": "INT",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 4,
"type": "PrimitiveNode",
"pos": [258.4381232333541, 549.1608040200999],
"size": [225, 104],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"widget": {
"name": "value"
},
"links": [2]
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [0, "randomize"]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "STRING"
},
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "INT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.44.17"
},
"version": 0.4
}

View File

@@ -460,10 +460,15 @@ export const testComfySnapToGridGridSize = 50
const COLLECT_COVERAGE = process.env.COLLECT_COVERAGE === 'true'
export const comfyPageFixture = base.extend<{
initialFeatureFlags: Record<string, unknown>
comfyPage: ComfyPage
comfyMouse: ComfyMouse
comfyFiles: ComfyFiles
}>({
// Allows configuring feature flags for tests with before initial setup:
// `test.use({ initialFeatureFlags: { my_flag: true } })`.
initialFeatureFlags: [{}, { option: true }],
page: async ({ page, browserName }, use) => {
if (browserName !== 'chromium' || !COLLECT_COVERAGE) {
return use(page)
@@ -480,7 +485,7 @@ export const comfyPageFixture = base.extend<{
await mcr.add(coverage)
},
comfyPage: async ({ page, request }, use, testInfo) => {
comfyPage: async ({ page, request, initialFeatureFlags }, use, testInfo) => {
const comfyPage = new ComfyPage(page, request)
const { parallelIndex } = testInfo
@@ -505,6 +510,7 @@ export const comfyPageFixture = base.extend<{
'Comfy.userId': userId,
// Set tutorial completed to true to avoid loading the tutorial workflow.
'Comfy.TutorialCompleted': true,
'Comfy.Queue.MaxHistoryItems': 64,
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
'Comfy.VueNodes.AutoScaleLayout': false,
// Disable toast warning about version compatibility, as they may or
@@ -523,6 +529,10 @@ export const comfyPageFixture = base.extend<{
await comfyPage.cloudAuth.mockAuth()
}
if (Object.keys(initialFeatureFlags).length > 0) {
await comfyPage.featureFlags.seedFlags(initialFeatureFlags)
}
await comfyPage.setup()
if (isVueNodes) {

View File

@@ -5,6 +5,7 @@ import type { Locator, Page } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
export class VueNodeHelpers {
/**
@@ -37,6 +38,22 @@ export class VueNodeHelpers {
return this.getNodeLocator(nodeId).getByTestId(TestIds.node.innerWrapper)
}
getInputSlotRow(nodeId: string, slotIndex: number): Locator {
return this.getNodeLocator(nodeId)
.locator('.lg-slot--input')
.filter({
has: this.page.locator(
`[data-slot-key="${getSlotKey(nodeId, slotIndex, true)}"]`
)
})
}
getInputSlotConnectionDot(nodeId: string, slotIndex: number): Locator {
return this.getInputSlotRow(nodeId, slotIndex).getByTestId(
TestIds.node.slotConnectionDot
)
}
/**
* Get locator for Vue nodes by the node's title (displayed name in the header).
* Matches against the actual title element, not the full node body.

View File

@@ -1,8 +1,34 @@
import { test as base } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
const ASSETS_ROUTE_PATTERN = /\/api\/assets(?:\?.*)?$/
const cloudAssetRequestsByPage = new WeakMap<Page, string[]>()
function makeAssetsResponse(assets: ReadonlyArray<Asset>): ListAssetsResponse {
return { assets: [...assets], total: assets.length, has_more: false }
}
export function assetRequestIncludesTag(url: string, tag: string): boolean {
const includeTags = new URL(url).searchParams.get('include_tags') ?? ''
return includeTags
.split(',')
.map((value) => value.trim())
.filter(Boolean)
.includes(tag)
}
export function countAssetRequestsByTag(
requests: string[],
tag: string
): number {
return requests.filter((url) => assetRequestIncludesTag(url, tag)).length
}
export const assetApiFixture = base.extend<{
assetApi: AssetHelper
}>({
@@ -14,3 +40,31 @@ export const assetApiFixture = base.extend<{
await assetApi.clearMocks()
}
})
export function createCloudAssetsFixture(assets: ReadonlyArray<Asset>) {
return comfyPageFixture.extend<{
cloudAssetRequests: string[]
}>({
page: async ({ page }, use) => {
const cloudAssetRequests: string[] = []
cloudAssetRequestsByPage.set(page, cloudAssetRequests)
async function assetsRouteHandler(route: Route) {
cloudAssetRequests.push(route.request().url())
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeAssetsResponse(assets))
})
}
await page.route(ASSETS_ROUTE_PATTERN, assetsRouteHandler)
await use(page)
await page.unroute(ASSETS_ROUTE_PATTERN, assetsRouteHandler)
cloudAssetRequestsByPage.delete(page)
},
cloudAssetRequests: async ({ page }, use) => {
await use(cloudAssetRequestsByPage.get(page) ?? [])
}
})
}

View File

@@ -1,16 +1,23 @@
import type { Locator, Page } from '@playwright/test'
import type { WorkspaceStore } from '@e2e/types/globals'
import { TestIds } from '@e2e/fixtures/selectors'
export class Topbar {
private readonly menuLocator: Locator
private readonly menuTrigger: Locator
readonly newWorkflowButton: Locator
readonly workflowTabs: Locator
readonly integratedTabBarActions: Locator
constructor(public readonly page: Page) {
this.menuLocator = page.locator('.comfy-command-menu')
this.menuTrigger = page.locator('.comfy-menu-button-wrapper')
this.newWorkflowButton = page.locator('.new-blank-workflow-button')
this.workflowTabs = page.getByTestId(TestIds.topbar.workflowTabs)
this.integratedTabBarActions = this.workflowTabs.getByTestId(
TestIds.topbar.integratedTabBarActions
)
}
async getTabNames(): Promise<string[]> {

View File

@@ -0,0 +1,47 @@
import type { NodeReplacementResponse } from '@/platform/nodeReplacement/types'
/**
* Mock node replacement mappings for e2e tests.
*
* Maps fake "missing" node types (E2E_OldSampler, E2E_OldUpscaler) to real
* core node types that are always available in the test server.
*/
export const mockNodeReplacements: NodeReplacementResponse = {
E2E_OldSampler: [
{
new_node_id: 'KSampler',
old_node_id: 'E2E_OldSampler',
old_widget_ids: ['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'],
input_mapping: [
{ new_id: 'model', old_id: 'model' },
{ new_id: 'positive', old_id: 'positive' },
{ new_id: 'negative', old_id: 'negative' },
{ new_id: 'latent_image', old_id: 'latent_image' },
{ new_id: 'seed', old_id: 'seed' },
{ new_id: 'steps', old_id: 'steps' },
{ new_id: 'cfg', old_id: 'cfg' },
{ new_id: 'sampler_name', old_id: 'sampler_name' },
{ new_id: 'scheduler', old_id: 'scheduler' }
],
output_mapping: [{ new_idx: 0, old_idx: 0 }]
}
],
E2E_OldUpscaler: [
{
new_node_id: 'ImageScaleBy',
old_node_id: 'E2E_OldUpscaler',
old_widget_ids: ['upscale_method', 'scale_by'],
input_mapping: [
{ new_id: 'image', old_id: 'image' },
{ new_id: 'upscale_method', old_id: 'upscale_method' },
{ new_id: 'scale_by', old_id: 'scale_by' }
],
output_mapping: [{ new_idx: 0, old_idx: 0 }]
}
]
}
/** Subset containing only the E2E_OldSampler replacement. */
export const mockNodeReplacementsSingle: NodeReplacementResponse = {
E2E_OldSampler: mockNodeReplacements.E2E_OldSampler
}

View File

@@ -0,0 +1,176 @@
import type { Page, Route } from '@playwright/test'
import type {
JobDetailResponse,
JobEntry,
JobsListResponse
} from '@comfyorg/ingest-types'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
const defaultJobsListLimit = 100
export type MockJobRecord = {
listItem: JobEntry
detail: JobDetailResponse
}
function parsePositiveIntegerParam(url: URL, name: string): number | undefined {
const value = Number(url.searchParams.get(name))
return Number.isInteger(value) && value > 0 ? value : undefined
}
function getJobIdFromRequest(route: Route): string | null {
const url = new URL(route.request().url())
const jobId = url.pathname.split('/').at(-1)
return jobId ? decodeURIComponent(jobId) : null
}
export class JobsApiMock {
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
private jobsById = new Map<string, MockJobRecord>()
constructor(private readonly page: Page) {}
async mockJobs(jobs: MockJobRecord[]): Promise<void> {
this.jobsById = new Map(
jobs.map(
(job) => [job.listItem.id, job] satisfies [string, MockJobRecord]
)
)
await this.ensureRoutesRegistered()
}
async clear(): Promise<void> {
this.jobsById.clear()
if (this.listRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
this.listRouteHandler = null
}
if (this.detailRouteHandler) {
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
this.detailRouteHandler = null
}
if (this.historyRouteHandler) {
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
this.historyRouteHandler = null
}
}
private async ensureRoutesRegistered(): Promise<void> {
if (!this.listRouteHandler) {
this.listRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
let filteredJobs = Array.from(
this.jobsById.values(),
({ listItem }) => listItem
)
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
const offset = parsePositiveIntegerParam(url, 'offset') ?? 0
const limit =
parsePositiveIntegerParam(url, 'limit') ?? defaultJobsListLimit
const total = filteredJobs.length
const visibleJobs = filteredJobs.slice(offset, offset + limit)
const response = {
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
} satisfies JobsListResponse
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
}
if (!this.detailRouteHandler) {
this.detailRouteHandler = async (route: Route) => {
const jobId = getJobIdFromRequest(route)
const job = jobId ? this.jobsById.get(jobId) : undefined
if (!job) {
await route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Job not found' })
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(job.detail)
})
}
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
}
if (!this.historyRouteHandler) {
this.historyRouteHandler = async (route: Route) => {
const request = route.request()
if (request.method() !== 'POST') {
await route.continue()
return
}
const requestBody = request.postDataJSON() as
| { delete?: string[]; clear?: boolean }
| undefined
if (requestBody?.clear) {
this.jobsById = new Map(
Array.from(this.jobsById).filter(([, job]) => {
const status = job.listItem.status
return status === 'pending' || status === 'in_progress'
})
)
}
if (requestBody?.delete?.length) {
for (const jobId of requestBody.delete) {
this.jobsById.delete(jobId)
}
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
}
await this.page.route(historyRoutePattern, this.historyRouteHandler)
}
}
}

View File

@@ -0,0 +1,136 @@
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
const MASK_CANVAS_INDEX = 2
const RGB_CANVAS_INDEX = 1
export type BrushSliderLabel = 'thickness'
export class MaskEditorHelper {
constructor(private comfyPage: ComfyPage) {}
private get page() {
return this.comfyPage.page
}
async loadImageOnNode() {
await this.comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
const loadImageNode = (
await this.comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const { x, y } = await loadImageNode.getPosition()
await this.comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
dropPosition: { x, y }
})
const imagePreview = this.page.locator('.image-preview')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.locator('img')).toBeVisible()
await expect(imagePreview).toContainText('x')
return {
imagePreview,
nodeId: String(loadImageNode.id)
}
}
async openDialog(): Promise<Locator> {
const { imagePreview } = await this.loadImageOnNode()
await imagePreview.getByRole('region').hover()
await this.page.getByLabel('Edit or mask image').click()
const dialog = this.page.locator('.mask-editor-dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('heading', { name: 'Mask Editor' })
).toBeVisible()
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
await expect(canvasContainer).toBeVisible()
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
return dialog
}
async drawStrokeOnPointerZone(dialog: Locator) {
const pointerZone = dialog.getByTestId('pointer-zone')
await expect(pointerZone).toBeVisible()
const box = await pointerZone.boundingBox()
if (!box) throw new Error('Pointer zone bounding box not found')
const startX = box.x + box.width * 0.3
const startY = box.y + box.height * 0.5
const endX = box.x + box.width * 0.7
const endY = box.y + box.height * 0.5
await this.page.mouse.move(startX, startY)
await this.page.mouse.down()
await this.page.mouse.move(endX, endY, { steps: 10 })
await this.page.mouse.up()
return { startX, startY, endX, endY, box }
}
async drawStrokeAndExpectPixels(dialog: Locator) {
await this.drawStrokeOnPointerZone(dialog)
await expect.poll(() => this.pollMaskPixelCount()).toBeGreaterThan(0)
}
getCanvasPixelData(canvasIndex: number) {
return this.page.evaluate((idx) => {
const canvases = document.querySelectorAll(
'#maskEditorCanvasContainer canvas'
)
const canvas = canvases[idx] as HTMLCanvasElement | undefined
if (!canvas) return null
const ctx = canvas.getContext('2d')
if (!ctx) return null
const data = ctx.getImageData(0, 0, canvas.width, canvas.height)
let nonTransparentPixels = 0
for (let i = 3; i < data.data.length; i += 4) {
if (data.data[i] > 0) nonTransparentPixels++
}
return { nonTransparentPixels, totalPixels: data.data.length / 4 }
}, canvasIndex)
}
pollMaskPixelCount(): Promise<number> {
return this.getCanvasPixelData(MASK_CANVAS_INDEX).then(
(d) => d?.nonTransparentPixels ?? 0
)
}
pollRgbPixelCount(): Promise<number> {
return this.getCanvasPixelData(RGB_CANVAS_INDEX).then(
(d) => d?.nonTransparentPixels ?? 0
)
}
getCanvasSnapshot(canvasIndex: number): Promise<string> {
return this.page.evaluate((idx) => {
const canvas = document.querySelectorAll(
'#maskEditorCanvasContainer canvas'
)[idx] as HTMLCanvasElement | undefined
return canvas?.toDataURL() ?? ''
}, canvasIndex)
}
brushInput(dialog: Locator, label: BrushSliderLabel): Locator {
return dialog.getByTestId(`brush-${label}-input`)
}
}
export const maskEditorTest = comfyPageFixture.extend<{
maskEditor: MaskEditorHelper
}>({
maskEditor: async ({ comfyPage }, use) => {
await use(new MaskEditorHelper(comfyPage))
}
})

View File

@@ -0,0 +1,93 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import type { NodeReplacementResponse } from '@/platform/nodeReplacement/types'
/**
* Mock `/api/node_replacements` and enable the node replacement feature.
*
* Unlike features that only consult settings (e.g. shareWorkflowDialog,
* managerDialog), node replacement gates on `api.serverFeatureFlags`. The
* server sends a `feature_flags` WS message that wholesale replaces
* `serverFeatureFlags`, racing with any test-side override done via
* `page.evaluate`. To make the flow deterministic across CI shards, this
* helper patches `WebSocket.prototype` so every incoming `feature_flags`
* message has `node_replacements: true` injected before the api's WS
* handler sees it. Reload the page so the patched WebSocket and persisted
* settings apply to a fresh app boot, then wait for the resulting
* `/api/node_replacements` fetch before returning.
*/
export async function setupNodeReplacement(
comfyPage: ComfyPage,
replacements: NodeReplacementResponse
): Promise<void> {
await comfyPage.page.route('**/api/node_replacements', (route) =>
route.fulfill({ json: replacements })
)
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.settings.setSetting('Comfy.NodeReplacement.Enabled', true)
await comfyPage.page.addInitScript(() => {
const proto = window.WebSocket.prototype
const originalAdd = proto.addEventListener
proto.addEventListener = function patchedAdd(
this: WebSocket,
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: AddEventListenerOptions | boolean
) {
if (type === 'message' && typeof listener === 'function') {
const wrapped = function (this: WebSocket, event: Event) {
const msgEvent = event as MessageEvent
if (typeof msgEvent.data === 'string') {
try {
const msg = JSON.parse(msgEvent.data)
if (
msg &&
msg.type === 'feature_flags' &&
msg.data &&
typeof msg.data === 'object'
) {
msg.data.node_replacements = true
const patched = new MessageEvent('message', {
data: JSON.stringify(msg),
origin: msgEvent.origin,
lastEventId: msgEvent.lastEventId
})
return (listener as EventListener).call(this, patched)
}
} catch {
// not JSON or not a feature_flags message - pass through
}
}
return (listener as EventListener).call(this, event)
}
return originalAdd.call(this, type, wrapped as EventListener, options)
}
return originalAdd.call(
this,
type,
listener as EventListenerOrEventListenerObject,
options
)
}
})
const fetchPromise = comfyPage.page.waitForResponse(
(response) =>
response.url().includes('/api/node_replacements') && response.ok(),
{ timeout: 10000 }
)
await comfyPage.workflow.reloadAndWaitForApp()
await fetchPromise
}
export function getSwapNodesGroup(page: Page): Locator {
return page.getByTestId(TestIds.dialogs.swapNodesGroup)
}

View File

@@ -0,0 +1,15 @@
import { test as base } from '@playwright/test'
import { JobsApiMock } from '@e2e/fixtures/helpers/JobsApiMock'
export const jobsApiMockFixture = base.extend<{
jobsApi: JobsApiMock
}>({
jobsApi: async ({ page }, use) => {
const jobsApi = new JobsApiMock(page)
await use(jobsApi)
await jobsApi.clear()
}
})

View File

@@ -64,6 +64,7 @@ export const TestIds = {
missingModelRefresh: 'missing-model-refresh',
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
missingMediaRow: 'missing-media-row',
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
missingMediaLibrarySelect: 'missing-media-library-select',
@@ -90,6 +91,8 @@ export const TestIds = {
loginButton: 'login-button',
loginButtonPopover: 'login-button-popover',
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
workflowTabs: 'topbar-workflow-tabs',
integratedTabBarActions: 'integrated-tab-bar-actions',
actionBarButtons: 'action-bar-buttons'
},
nodeLibrary: {
@@ -113,7 +116,8 @@ export const TestIds = {
titleInput: 'node-title-input',
pinIndicator: 'node-pin-indicator',
innerWrapper: 'node-inner-wrapper',
mainImage: 'main-image'
mainImage: 'main-image',
slotConnectionDot: 'slot-connection-dot'
},
selectionToolbox: {
root: 'selection-toolbox',
@@ -210,6 +214,7 @@ export const TestIds = {
},
queue: {
overlayToggle: 'queue-overlay-toggle',
jobDetailsPopover: 'queue-job-details-popover',
clearHistoryAction: 'clear-history-action',
jobAssetsList: 'job-assets-list',
notificationBanner: 'queue-notification-banner'

View File

@@ -122,19 +122,3 @@ export async function saveAndReopenInAppMode(
await comfyPage.appMode.toggleAppMode()
}
export async function saveCloseAndReopenInBuilder(
comfyPage: ComfyPage,
appMode: AppModeHelper,
workflowName: string
) {
await appMode.steps.goToPreview()
await builderSaveAs(appMode, workflowName)
await appMode.saveAs.closeButton.click()
await expect(appMode.saveAs.successDialog).toBeHidden()
await appMode.footer.exitBuilder()
await openWorkflowFromSidebar(comfyPage, workflowName)
await appMode.enterBuilder()
await appMode.steps.goToInputs()
}

View File

@@ -0,0 +1,90 @@
import type { Locator } from '@playwright/test'
import { comfyExpect as expect } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
/**
* Click a menu item by exact label and wait for the menu to close.
*/
export async function clickExactMenuItem(comfyPage: ComfyPage, name: string) {
await comfyPage.contextMenu.clickMenuItemExact(name)
await expect(comfyPage.contextMenu.primeVueMenu).toBeHidden()
}
/**
* Open the context menu for a single Vue node by title.
* Selects the node first (required for correct menu items).
*/
export async function openContextMenu(
comfyPage: ComfyPage,
nodeTitle: string
): Promise<Locator> {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
await comfyPage.contextMenu.openForVueNode(fixture.header)
return comfyPage.contextMenu.primeVueMenu
}
/**
* Open the context menu for multiple selected Vue nodes.
*/
export async function openMultiNodeContextMenu(
comfyPage: ComfyPage,
titles: string[]
): Promise<Locator> {
if (titles.length === 0) {
throw new Error('openMultiNodeContextMenu requires at least one title')
}
// deselectAll via evaluate — clearSelection() clicks at a fixed position
// which can hit nodes or the toolbar overlay
await comfyPage.page.evaluate(() => window.app!.canvas.deselectAll())
await comfyPage.nextFrame()
for (const title of titles) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
await fixture.header.click({ modifiers: ['ControlOrMeta'] })
}
await comfyPage.nextFrame()
const firstFixture = await comfyPage.vueNodes.getFixtureByTitle(titles[0])
const box = await firstFixture.header.boundingBox()
if (!box) throw new Error(`Header for "${titles[0]}" not found`)
await comfyPage.page.mouse.click(
box.x + box.width / 2,
box.y + box.height / 2,
{ button: 'right' }
)
const menu = comfyPage.contextMenu.primeVueMenu
await menu.waitFor({ state: 'visible' })
return menu
}
/**
* Get the inner wrapper locator for a Vue node by title.
*/
export function getNodeWrapper(
comfyPage: ComfyPage,
nodeTitle: string
): Locator {
return comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.getByTestId(TestIds.node.innerWrapper)
}
/**
* Get the first NodeReference matching the given title.
*/
export async function getNodeRef(
comfyPage: ComfyPage,
nodeTitle: string
): Promise<NodeReference> {
const refs = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
const firstRef = refs[0]
if (!firstRef) {
throw new Error(`No node found with title "${nodeTitle}"`)
}
return firstRef
}

View File

@@ -0,0 +1,34 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
const GROUP_TITLE_CLICK_OFFSET_X = 50
const GROUP_TITLE_CLICK_OFFSET_Y = 15
/**
* Returns the client-space position of a group's title bar (for clicking).
*/
export async function getGroupTitlePosition(
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number }> {
const pos = await comfyPage.page.evaluate(
({ title, offsetX, offsetY }) => {
const app = window.app!
const group = app.graph.groups.find(
(g: { title: string }) => g.title === title
)
if (!group) return null
const clientPos = app.canvasPosToClientPos([
group.pos[0] + offsetX,
group.pos[1] + offsetY
])
return { x: clientPos[0], y: clientPos[1] }
},
{
title,
offsetX: GROUP_TITLE_CLICK_OFFSET_X,
offsetY: GROUP_TITLE_CLICK_OFFSET_Y
}
)
if (!pos) throw new Error(`Group "${title}" not found`)
return pos
}

View File

@@ -0,0 +1,52 @@
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
import type { MockJobRecord } from '@e2e/fixtures/helpers/JobsApiMock'
export function createMockJob(
overrides: Partial<JobEntry> & { id: string }
): JobEntry {
const now = Date.now()
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5_000,
preview_output: {
filename: `output_${overrides.id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
...overrides
}
}
function isTerminalStatus(status: JobEntry['status']) {
return status === 'completed' || status === 'failed' || status === 'cancelled'
}
function createMockJobRecord(listItem: JobEntry): MockJobRecord {
const updateTime =
listItem.execution_end_time ??
listItem.execution_start_time ??
listItem.create_time
const detail: JobDetailResponse = {
...listItem,
update_time: updateTime,
...(isTerminalStatus(listItem.status) ? { outputs: {} } : {})
}
return {
listItem,
detail
}
}
export function createMockJobRecords(
listItems: readonly JobEntry[]
): MockJobRecord[] {
return listItems.map(createMockJobRecord)
}

View File

@@ -0,0 +1,20 @@
import type { Locator } from '@playwright/test'
import { comfyExpect as expect } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
/**
* Opens the selection toolbox "More Options" menu and returns the menu
* locator so callers can scope follow-up queries to it.
*/
export async function openMoreOptions(comfyPage: ComfyPage): Promise<Locator> {
await expect(comfyPage.selectionToolbox).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu.getByText('Copy', { exact: true })).toBeVisible()
return menu
}

View File

@@ -1,44 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import {
saveCloseAndReopenInBuilder,
setupBuilder
} from '@e2e/fixtures/utils/builderTestUtils'
const WIDGETS = ['seed', 'steps', 'cfg']
test.describe(
'App builder input persistence after reload',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
await comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true
)
})
test('persists selected inputs after save and reopen without visibility errors', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await setupBuilder(comfyPage, undefined, WIDGETS)
await appMode.steps.goToInputs()
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
const workflowName = `${Date.now()} input-persistence`
await saveCloseAndReopenInBuilder(comfyPage, appMode, workflowName)
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
for (const widget of WIDGETS) {
await expect(
appMode.select.getInputItemSubtitle(widget)
).not.toContainText('Widget not visible')
}
})
}
)

View File

@@ -5,18 +5,26 @@ import {
import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
saveCloseAndReopenInBuilder,
builderSaveAs,
openWorkflowFromSidebar,
setupBuilder
} from '@e2e/fixtures/utils/builderTestUtils'
const WIDGETS = ['seed', 'steps', 'cfg']
/** Save as app, close it by loading default, reopen from sidebar, enter app mode. */
async function saveCloseAndReopenAsApp(
comfyPage: ComfyPage,
appMode: AppModeHelper,
workflowName: string
) {
await saveCloseAndReopenInBuilder(comfyPage, appMode, workflowName)
await appMode.steps.goToPreview()
await builderSaveAs(appMode, workflowName)
await appMode.saveAs.closeButton.click()
await expect(appMode.saveAs.successDialog).toBeHidden()
await appMode.footer.exitBuilder()
await openWorkflowFromSidebar(comfyPage, workflowName)
await appMode.toggleAppMode()
}

View File

@@ -1,51 +1,20 @@
import { expect } from '@playwright/test'
import type { Route } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { Asset } from '@comfyorg/ingest-types'
import {
assetRequestIncludesTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import {
STABLE_CHECKPOINT,
STABLE_CHECKPOINT_2
} from '@e2e/fixtures/data/assetFixtures'
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
return { assets, total: assets.length, has_more: false }
}
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
const WAITING_FOR_WIDGET_TYPE = 'waiting:type'
const WAITING_FOR_WIDGET_VALUE = 'waiting:value'
// Stub /api/assets before the app loads. The local ComfyUI backend has no
// /api/assets endpoint (returns 503), which poisons the assets store on
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
//
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
const test = comfyPageFixture.extend<{
cloudAssetRequests: string[]
stubCloudAssets: void
}>({
cloudAssetRequests: async ({ page: _page }, use) => {
await use([])
},
stubCloudAssets: [
async ({ cloudAssetRequests, page }, use) => {
const pattern = /\/api\/assets(?:\?.*)?$/
const assetsRouteHandler = (route: Route) => {
cloudAssetRequests.push(route.request().url())
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
})
}
await page.route(pattern, assetsRouteHandler)
await use()
await page.unroute(pattern, assetsRouteHandler)
},
{ auto: true }
]
})
const test = createCloudAssetsFixture(CLOUD_ASSETS)
test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
test.afterEach(async ({ comfyPage }) => {
@@ -62,11 +31,9 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
// new nodes resolve against the cloud asset list after the fetch.
await expect
.poll(() =>
cloudAssetRequests.some((url) => {
const includeTags =
new URL(url).searchParams.get('include_tags') ?? ''
return includeTags.split(',').includes('checkpoints')
})
cloudAssetRequests.some((url) =>
assetRequestIncludesTag(url, 'checkpoints')
)
)
.toBe(true)

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
test.afterEach(async ({ comfyPage }) => {
@@ -12,15 +13,7 @@ test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
}) => {
await comfyPage.workflow.loadWorkflow('groups/single_group_only')
const titlePos = await comfyPage.page.evaluate(() => {
const app = window.app!
const group = app.graph.groups[0]
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
})
const titlePos = await getGroupTitlePosition(comfyPage, 'Group')
await comfyPage.canvas.click({ position: titlePos })
await comfyPage.nextFrame()

View File

@@ -2,29 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
/**
* Returns the client-space position of a group's title bar (for clicking).
*/
async function getGroupTitlePosition(
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number }> {
const pos = await comfyPage.page.evaluate((title) => {
const app = window.app!
const group = app.graph.groups.find(
(g: { title: string }) => g.title === title
)
if (!group) return null
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
}, title)
if (!pos) throw new Error(`Group "${title}" not found`)
return pos
}
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
/**
* Returns {selectedNodeCount, selectedGroupCount, selectedItemCount}

View File

@@ -0,0 +1,127 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
test.describe('Layout & sidebar settings', { tag: ['@settings'] }, () => {
test.describe('Comfy.Sidebar.Size', () => {
test('"small" applies small-sidebar class', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'small')
await expect(comfyPage.menu.sideToolbar).toContainClass('small-sidebar')
})
test('"normal" does not apply small-sidebar class', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
'small-sidebar'
)
})
})
test.describe('Comfy.Sidebar.Style', () => {
// `isConnected` overrides the Style setting when the toolbar overflows;
// small (48px) items keep content under the default viewport so Style
// actually drives rendering.
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'small')
})
test('"connected" applies connected-sidebar class', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'connected')
await expect(comfyPage.menu.sideToolbar).toContainClass(
'connected-sidebar'
)
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
'floating-sidebar'
)
})
test('"floating" applies floating-sidebar class', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'floating')
await expect(comfyPage.menu.sideToolbar).toContainClass(
'floating-sidebar'
)
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
'connected-sidebar'
)
})
test('"floating" + Size "normal" is overridden to connected by overflow', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'floating')
await expect(comfyPage.menu.sideToolbar).toContainClass(
'connected-sidebar'
)
await expect(comfyPage.menu.sideToolbar).toContainClass(
'overflowing-sidebar'
)
})
test('"floating" + Size "normal" renders floating in a viewport tall enough to fit', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'floating')
await comfyPage.page.setViewportSize({ width: 1280, height: 1500 })
await expect(comfyPage.menu.sideToolbar).toContainClass(
'floating-sidebar'
)
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
'overflowing-sidebar'
)
})
})
test.describe('Comfy.UI.TabBarLayout', () => {
test('"Default" renders integrated tab bar actions container', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UI.TabBarLayout', 'Default')
await expect(comfyPage.menu.topbar.integratedTabBarActions).toBeAttached()
})
test('"Legacy" does not render integrated tab bar actions container', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UI.TabBarLayout', 'Legacy')
await expect(comfyPage.menu.topbar.integratedTabBarActions).toHaveCount(0)
})
})
test.describe('Comfy.TreeExplorer.ItemPadding', () => {
// The setting writes a CSS var consumed by .p-tree-node-content,
// which only renders in the legacy PrimeVue Tree.
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
await comfyPage.menu.nodeLibraryTab.open()
})
test('low padding (0px) is applied to tree node content', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.TreeExplorer.ItemPadding', 0)
await expect(
comfyPage.menu.nodeLibraryTab.nodeLibraryTree
.locator('.p-tree-node-content')
.first()
).toHaveCSS('padding', '0px')
})
test('high padding (8px) is applied to tree node content', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.TreeExplorer.ItemPadding', 8)
await expect(
comfyPage.menu.nodeLibraryTab.nodeLibraryTree
.locator('.p-tree-node-content')
.first()
).toHaveCSS('padding', '8px')
})
})
})

View File

@@ -0,0 +1,32 @@
import { expect } from '@playwright/test'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
test.describe('Load3D LOD', () => {
test(
'canvas pixel dimensions scale with ComfyUI canvas zoom level',
{ tag: '@smoke' },
async ({ comfyPage, load3d }) => {
await expect(load3d.canvas).toBeVisible()
await expect
.poll(() => load3d.canvas.evaluate((el: HTMLCanvasElement) => el.width))
.toBeGreaterThan(0)
const initialWidth = await load3d.canvas.evaluate(
(el: HTMLCanvasElement) => el.width
)
await comfyPage.page.evaluate(() => {
const node = window.app!.graph!.nodes[0]
window.app!.canvas.ds.scale = 2.0
node.onResize?.(node.size)
})
await comfyPage.nextFrame()
await expect
.poll(() => load3d.canvas.evaluate((el: HTMLCanvasElement) => el.width))
.toBeGreaterThan(initialWidth)
}
)
})

View File

@@ -1,117 +1,13 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
async function loadImageOnNode(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const { x, y } = await loadImageNode.getPosition()
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
dropPosition: { x, y }
})
const imagePreview = comfyPage.page.locator('.image-preview')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.locator('img')).toBeVisible()
await expect(imagePreview).toContainText('x')
return {
imagePreview,
nodeId: String(loadImageNode.id)
}
}
async function openMaskEditorDialog(comfyPage: ComfyPage) {
const { imagePreview } = await loadImageOnNode(comfyPage)
await imagePreview.getByRole('region').hover()
await comfyPage.page.getByLabel('Edit or mask image').click()
const dialog = comfyPage.page.locator('.mask-editor-dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('heading', { name: 'Mask Editor' })
).toBeVisible()
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
await expect(canvasContainer).toBeVisible()
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
return dialog
}
async function getMaskCanvasPixelData(page: Page) {
return page.evaluate(() => {
const canvases = document.querySelectorAll(
'#maskEditorCanvasContainer canvas'
)
// The mask canvas is the 3rd canvas (index 2, z-30)
const maskCanvas = canvases[2] as HTMLCanvasElement
if (!maskCanvas) return null
const ctx = maskCanvas.getContext('2d')
if (!ctx) return null
const data = ctx.getImageData(0, 0, maskCanvas.width, maskCanvas.height)
let nonTransparentPixels = 0
for (let i = 3; i < data.data.length; i += 4) {
if (data.data[i] > 0) nonTransparentPixels++
}
return { nonTransparentPixels, totalPixels: data.data.length / 4 }
})
}
function pollMaskPixelCount(page: Page): Promise<number> {
return getMaskCanvasPixelData(page).then(
(d) => d?.nonTransparentPixels ?? 0
)
}
async function drawStrokeOnPointerZone(
page: Page,
dialog: ReturnType<typeof page.locator>
) {
const pointerZone = dialog.locator(
'.maskEditor-ui-container [class*="w-[calc"]'
)
await expect(pointerZone).toBeVisible()
const box = await pointerZone.boundingBox()
if (!box) throw new Error('Pointer zone bounding box not found')
const startX = box.x + box.width * 0.3
const startY = box.y + box.height * 0.5
const endX = box.x + box.width * 0.7
const endY = box.y + box.height * 0.5
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(endX, endY, { steps: 10 })
await page.mouse.up()
return { startX, startY, endX, endY, box }
}
async function drawStrokeAndExpectPixels(
comfyPage: ComfyPage,
dialog: ReturnType<typeof comfyPage.page.locator>
) {
await drawStrokeOnPointerZone(comfyPage.page, dialog)
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeGreaterThan(0)
}
test(
'opens mask editor from image preview button',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const { imagePreview } = await loadImageOnNode(comfyPage)
async ({ comfyPage, maskEditor }) => {
const { imagePreview } = await maskEditor.loadImageOnNode()
// Hover over the image panel to reveal action buttons
await imagePreview.getByRole('region').hover()
@@ -139,8 +35,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
test(
'opens mask editor from context menu',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const { nodeId } = await loadImageOnNode(comfyPage)
async ({ comfyPage, maskEditor }) => {
const { nodeId } = await maskEditor.loadImageOnNode()
const nodeHeader = comfyPage.vueNodes
.getNodeLocator(nodeId)
@@ -166,63 +62,61 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
}
)
test('draws a brush stroke on the mask canvas', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
test('draws a brush stroke on the mask canvas', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
const dataBefore = await maskEditor.getCanvasPixelData(2)
expect(dataBefore).not.toBeNull()
expect(dataBefore!.nonTransparentPixels).toBe(0)
await drawStrokeAndExpectPixels(comfyPage, dialog)
await maskEditor.drawStrokeAndExpectPixels(dialog)
})
test('undo reverts a brush stroke', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
test('undo reverts a brush stroke', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
await drawStrokeAndExpectPixels(comfyPage, dialog)
await maskEditor.drawStrokeAndExpectPixels(dialog)
const undoButton = dialog.locator('button[title="Undo"]')
await expect(undoButton).toBeVisible()
await undoButton.click()
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
})
test('redo restores an undone stroke', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
test('redo restores an undone stroke', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
await drawStrokeAndExpectPixels(comfyPage, dialog)
await maskEditor.drawStrokeAndExpectPixels(dialog)
const undoButton = dialog.locator('button[title="Undo"]')
await undoButton.click()
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
const redoButton = dialog.locator('button[title="Redo"]')
await expect(redoButton).toBeVisible()
await redoButton.click()
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeGreaterThan(0)
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBeGreaterThan(0)
})
test('clear button removes all mask content', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
test('clear button removes all mask content', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
await drawStrokeAndExpectPixels(comfyPage, dialog)
await maskEditor.drawStrokeAndExpectPixels(dialog)
const clearButton = dialog.getByRole('button', { name: 'Clear' })
await expect(clearButton).toBeVisible()
await clearButton.click()
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
})
test('cancel closes the dialog without saving', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
test('cancel closes the dialog without saving', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
await drawStrokeAndExpectPixels(comfyPage, dialog)
await maskEditor.drawStrokeAndExpectPixels(dialog)
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
await cancelButton.click()
@@ -230,10 +124,10 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await expect(dialog).toBeHidden()
})
test('invert button inverts the mask', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
test('invert button inverts the mask', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
const dataBefore = await maskEditor.getCanvasPixelData(2)
expect(dataBefore).not.toBeNull()
const pixelsBefore = dataBefore!.nonTransparentPixels
@@ -242,26 +136,29 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await invertButton.click()
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.poll(() => maskEditor.pollMaskPixelCount())
.toBeGreaterThan(pixelsBefore)
})
test('keyboard shortcut Ctrl+Z triggers undo', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
test('keyboard shortcut Ctrl+Z triggers undo', async ({
comfyPage,
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
await drawStrokeAndExpectPixels(comfyPage, dialog)
await maskEditor.drawStrokeAndExpectPixels(dialog)
const modifier = process.platform === 'darwin' ? 'Meta+z' : 'Control+z'
await comfyPage.page.keyboard.press(modifier)
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
})
test(
'tool panel shows all five tools',
{ tag: ['@smoke'] },
async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
const toolPanel = dialog.locator('.maskEditor-ui-container')
await expect(toolPanel).toBeVisible()
@@ -279,9 +176,9 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
)
test('switching tools updates the selected indicator', async ({
comfyPage
maskEditor
}) => {
const dialog = await openMaskEditorDialog(comfyPage)
const dialog = await maskEditor.openDialog()
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await expect(toolEntries).toHaveCount(5)
@@ -300,9 +197,9 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
})
test('brush settings panel is visible with thickness controls', async ({
comfyPage
maskEditor
}) => {
const dialog = await openMaskEditorDialog(comfyPage)
const dialog = await maskEditor.openDialog()
// The side panel should show brush settings by default
const thicknessLabel = dialog.getByText('Thickness')
@@ -315,8 +212,11 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await expect(hardnessLabel).toBeVisible()
})
test('save uploads all layers and closes dialog', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
test('save uploads all layers and closes dialog', async ({
comfyPage,
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
let maskUploadCount = 0
let imageUploadCount = 0
@@ -359,8 +259,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
).toBeGreaterThan(0)
})
test('save failure keeps dialog open', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
test('save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
const dialog = await maskEditor.openDialog()
// Fail all upload routes
await comfyPage.page.route('**/upload/mask', (route) =>
@@ -380,23 +280,23 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
test(
'eraser tool removes mask content',
{ tag: ['@screenshot'] },
async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
// Draw a stroke with the mask pen (default tool)
await drawStrokeAndExpectPixels(comfyPage, dialog)
await maskEditor.drawStrokeAndExpectPixels(dialog)
const pixelsAfterDraw = await getMaskCanvasPixelData(comfyPage.page)
const pixelsAfterDraw = await maskEditor.getCanvasPixelData(2)
// Switch to eraser tool (3rd tool, index 2)
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await toolEntries.nth(2).click()
// Draw over the same area with the eraser
await drawStrokeOnPointerZone(comfyPage.page, dialog)
await maskEditor.drawStrokeOnPointerZone(dialog)
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.poll(() => maskEditor.pollMaskPixelCount())
.toBeLessThan(pixelsAfterDraw!.nonTransparentPixels)
}
)

View File

@@ -0,0 +1,100 @@
import { expect } from '@playwright/test'
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
const RGB_PAINT_TOOL_INDEX = 1 // RGB / color paint tool
const ERASER_TOOL_INDEX = 2 // Eraser tool
test.describe(
'Mask Editor brush adjustment and layer management',
{ tag: '@vue-nodes' },
() => {
test.describe('Brush settings interaction', () => {
test('Adjusting brush thickness slider changes stroke output', async ({
comfyPage,
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
const thicknessInput = maskEditor.brushInput(dialog, 'thickness')
// Thin brush
await thicknessInput.fill('2')
await expect(thicknessInput).toHaveValue('2')
await maskEditor.drawStrokeOnPointerZone(dialog)
await expect
.poll(() => maskEditor.pollMaskPixelCount())
.toBeGreaterThan(0)
const thinPixels = await maskEditor.pollMaskPixelCount()
await comfyPage.page.keyboard.press('Control+z')
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
// Thick brush
await thicknessInput.fill('200')
await expect(thicknessInput).toHaveValue('200')
await maskEditor.drawStrokeOnPointerZone(dialog)
await expect
.poll(() => maskEditor.pollMaskPixelCount())
.toBeGreaterThan(thinPixels)
})
})
test.describe('Layer management', () => {
test('Drawing on different tools produces independent mask data', async ({
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeOnPointerZone(dialog)
await expect
.poll(() => maskEditor.pollMaskPixelCount())
.toBeGreaterThan(0)
const maskSnapshotAfterPen = await maskEditor.getCanvasSnapshot(2)
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await expect(toolEntries).toHaveCount(5)
await toolEntries.nth(RGB_PAINT_TOOL_INDEX).click()
await expect(toolEntries.nth(RGB_PAINT_TOOL_INDEX)).toHaveClass(
/Selected/
)
await maskEditor.drawStrokeOnPointerZone(dialog)
await expect
.poll(() => maskEditor.pollRgbPixelCount())
.toBeGreaterThan(0)
await expect
.poll(() => maskEditor.getCanvasSnapshot(2))
.toBe(maskSnapshotAfterPen)
})
test("Switching between tools preserves previous tool's mask data", async ({
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeOnPointerZone(dialog)
await expect
.poll(() => maskEditor.pollMaskPixelCount())
.toBeGreaterThan(0)
const maskSnapshot = await maskEditor.getCanvasSnapshot(2)
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await expect(toolEntries).toHaveCount(5)
await toolEntries.nth(ERASER_TOOL_INDEX).click()
await expect(toolEntries.nth(ERASER_TOOL_INDEX)).toHaveClass(/Selected/)
await toolEntries.nth(0).click()
await expect(toolEntries.nth(0)).toHaveClass(/Selected/)
await expect
.poll(() => maskEditor.getCanvasSnapshot(2))
.toBe(maskSnapshot)
})
})
}
)

View File

@@ -0,0 +1,168 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import {
mockNodeReplacements,
mockNodeReplacementsSingle
} from '@e2e/fixtures/data/nodeReplacements'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import {
getSwapNodesGroup,
setupNodeReplacement
} from '@e2e/fixtures/helpers/NodeReplacementHelper'
const renderModes = [
{ name: 'vue nodes', vueNodesEnabled: true },
{ name: 'litegraph', vueNodesEnabled: false }
] as const
test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
for (const mode of renderModes) {
test.describe(`(${mode.name})`, () => {
test.describe('Single replacement', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
mode.vueNodesEnabled
)
await setupNodeReplacement(comfyPage, mockNodeReplacementsSingle)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/node_replacement_simple'
)
})
test('Swap Nodes group appears in errors tab for replaceable nodes', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
await expect(swapGroup).toBeVisible()
await expect(swapGroup).toContainText('E2E_OldSampler')
await expect(
swapGroup.getByRole('button', { name: 'Replace All', exact: true })
).toBeVisible()
})
test('Replace Node replaces a single group in-place', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
await swapGroup.getByRole('button', { name: /replace node/i }).click()
await expect(swapGroup).toBeHidden()
const workflow = await comfyPage.workflow.getExportedWorkflow()
expect(
workflow.nodes,
'Node count should be unchanged after in-place replacement'
).toHaveLength(2)
const nodeTypes = workflow.nodes.map((n) => n.type)
expect(nodeTypes).not.toContain('E2E_OldSampler')
expect(nodeTypes).toContain('KSampler')
const ksampler = workflow.nodes.find((n) => n.type === 'KSampler')
expect(
ksampler?.id,
'Replaced node should keep the original id'
).toBe(1)
const linkFromReplacedToDecode = workflow.links?.find(
(l) => l[1] === 1 && l[3] === 2
)
expect(
linkFromReplacedToDecode,
'Output link from replaced node to VAEDecode should be preserved'
).toBeDefined()
})
test('Widget values are preserved after replacement', async ({
comfyPage
}) => {
await getSwapNodesGroup(comfyPage.page)
.getByRole('button', { name: /replace node/i })
.click()
const workflow = await comfyPage.workflow.getExportedWorkflow()
const ksampler = workflow.nodes.find((n) => n.type === 'KSampler')
expect(ksampler?.widgets_values).toBeDefined()
const widgetValues = ksampler!.widgets_values as unknown[]
expect(widgetValues).toEqual([
42,
'randomize',
20,
7,
'euler',
'normal',
1
])
})
test('Success toast is shown after replacement', async ({
comfyPage
}) => {
await getSwapNodesGroup(comfyPage.page)
.getByRole('button', { name: /replace node/i })
.click()
await expect(comfyPage.visibleToasts.first()).toContainText(
/replaced|swapped/i
)
})
})
test.describe('Multi-type replacement', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
mode.vueNodesEnabled
)
await setupNodeReplacement(comfyPage, mockNodeReplacements)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/node_replacement_multi'
)
})
test('Replace All replaces all groups across multiple types', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
await expect(swapGroup).toBeVisible()
await expect(swapGroup).toContainText('E2E_OldSampler')
await expect(swapGroup).toContainText('E2E_OldUpscaler')
await swapGroup
.getByRole('button', { name: 'Replace All', exact: true })
.click()
await expect(swapGroup).toBeHidden()
const workflow = await comfyPage.workflow.getExportedWorkflow()
const nodeTypes = workflow.nodes.map((n) => n.type)
expect(nodeTypes).not.toContain('E2E_OldSampler')
expect(nodeTypes).not.toContain('E2E_OldUpscaler')
expect(nodeTypes).toContain('KSampler')
expect(nodeTypes).toContain('ImageScaleBy')
})
test('Output connections are preserved across replacement with output mapping', async ({
comfyPage
}) => {
await getSwapNodesGroup(comfyPage.page)
.getByRole('button', { name: 'Replace All', exact: true })
.click()
const replacedNodeOutputLinkCount = await comfyPage.page.evaluate(
() =>
window.app!.graph!.getNodeById(2)?.outputs[0]?.links?.length ?? 0
)
expect(
replacedNodeOutputLinkCount,
'Replaced upscaler should still drive its downstream consumer'
).toBeGreaterThan(0)
})
})
})
}
})

View File

@@ -0,0 +1,93 @@
import { expect } from '@playwright/test'
import type { Asset } from '@comfyorg/ingest-types'
import {
countAssetRequestsByTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
const WORKFLOW = 'missing/nested_subgraph_installed_model'
const OUTER_SUBGRAPH_NODE_ID = '205'
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
const LOTUS_DIFFUSION_MODEL: Asset = {
id: 'test-lotus-depth-d-v1-1',
name: LOTUS_MODEL_NAME,
asset_hash:
'blake3:0000000000000000000000000000000000000000000000000000000000000203',
size: 1_024,
mime_type: 'application/octet-stream',
tags: ['models', 'diffusion_models'],
created_at: '2026-05-05T00:00:00Z',
updated_at: '2026-05-05T00:00:00Z',
last_access_time: '2026-05-05T00:00:00Z',
user_metadata: {
filename: LOTUS_MODEL_NAME
}
}
const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
test.describe(
'Errors tab - Cloud missing models',
{ tag: ['@cloud', '@vue-nodes'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
test('keeps installed models resolved after returning from a nested subgraph', async ({
cloudAssetRequests,
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const panel = new PropertiesPanelHelper(comfyPage.page)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
const errorsTab = panel.root.getByTestId(
TestIds.propertiesPanel.errorsTab
)
await expect
.poll(
() => countAssetRequestsByTag(cloudAssetRequests, 'diffusion_models'),
{ timeout: 10_000 }
)
.toBeGreaterThan(0)
await expect(errorOverlay).toBeHidden()
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(errorsTab).toBeHidden()
await panel.close()
await comfyPage.vueNodes.enterSubgraph(OUTER_SUBGRAPH_NODE_ID)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect(errorOverlay).toBeHidden()
const requestCountBeforeRootReturn = countAssetRequestsByTag(
cloudAssetRequests,
'diffusion_models'
)
await comfyPage.subgraph.exitViaBreadcrumb()
await panel.open(comfyPage.actionbar.propertiesButton)
await expect
.poll(
() =>
countAssetRequestsByTag(cloudAssetRequests, 'diffusion_models') >
requestCountBeforeRootReturn,
{ timeout: 10_000 }
)
.toBe(true)
await expect(errorsTab).toBeHidden()
})
}
)

View File

@@ -1,13 +1,19 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { JobEntry } from '@comfyorg/ingest-types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
import {
createMockJob,
createMockJobRecords
} from '@e2e/fixtures/utils/jobFixtures'
import { TestIds } from '@e2e/fixtures/selectors'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const test = mergeTests(comfyPageFixture, jobsApiMockFixture)
const now = Date.now()
const MOCK_JOBS: RawJobListItem[] = [
const MOCK_JOBS: JobEntry[] = [
createMockJob({
id: 'job-completed-1',
status: 'completed',
@@ -31,20 +37,25 @@ const MOCK_JOBS: RawJobListItem[] = [
execution_start_time: now - 30_000,
execution_end_time: now - 28_000,
outputs_count: 0
}),
createMockJob({
id: 'job-failed-bottom',
status: 'failed',
create_time: now - 180_000,
execution_start_time: now - 180_000,
execution_end_time: now - 178_000,
outputs_count: 0
})
]
test.describe('Queue overlay', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(MOCK_JOBS)
test.beforeEach(async ({ comfyPage, jobsApi }) => {
await jobsApi.mockJobs(createMockJobRecords(MOCK_JOBS))
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Toggle button opens expanded queue overlay', async ({ comfyPage }) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
@@ -106,4 +117,64 @@ test.describe('Queue overlay', () => {
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeHidden()
})
test('Job details popover stays inside the viewport for bottom rows', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 420 })
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
const bottomJob = comfyPage.page.locator(
'[data-job-id="job-failed-bottom"]'
)
await expect(bottomJob).toBeVisible()
await bottomJob.scrollIntoViewIfNeeded()
await expect(bottomJob).toBeVisible()
const viewportSize = comfyPage.page.viewportSize()
if (!viewportSize) throw new Error('Viewport must be available')
const rowBox = await bottomJob.boundingBox()
if (!rowBox) throw new Error('Bottom job row should be measurable')
expect(
rowBox.y + rowBox.height,
'Test row should be low enough to exercise bottom-edge collision handling'
).toBeGreaterThan(viewportSize.height * 0.55)
await expect
.poll(async () =>
bottomJob.evaluate((element) => {
const rect = element.getBoundingClientRect()
const hitTarget = document.elementFromPoint(
rect.x + rect.width / 2,
rect.y + rect.height / 2
)
return hitTarget ? element.contains(hitTarget) : false
})
)
.toBe(true)
await comfyPage.page.mouse.move(0, 0)
await comfyPage.page.mouse.move(
rowBox.x + rowBox.width / 2,
rowBox.y + rowBox.height / 2,
{ steps: 5 }
)
const popover = comfyPage.page.getByTestId(TestIds.queue.jobDetailsPopover)
await expect(popover).toBeVisible()
await expect
.poll(async () => {
const popoverBox = await popover.boundingBox()
if (!popoverBox) return false
return (
popoverBox.y >= 0 &&
popoverBox.y + popoverBox.height <= viewportSize.height
)
})
.toBe(true)
})
})

View File

@@ -15,6 +15,7 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const TOTAL_MOCK_JOBS = 20
const MAX_HISTORY_ITEMS_SETTING = 'Comfy.Queue.MaxHistoryItems'
const overflowJobsListRoutePattern = '**/api/jobs?*'
function isHistoryJobsRequest(url: string): boolean {
@@ -59,7 +60,7 @@ test.describe('Queue settings', { tag: '@canvas' }, () => {
}) => {
const TARGET_LIMIT = 6
await comfyPage.settings.setSetting(
'Comfy.Queue.MaxHistoryItems',
MAX_HISTORY_ITEMS_SETTING,
TARGET_LIMIT
)
@@ -106,7 +107,7 @@ test.describe('Queue settings', { tag: '@canvas' }, () => {
const VISIBLE_LIMIT = 6
await comfyPage.settings.setSetting(
'Comfy.Queue.MaxHistoryItems',
MAX_HISTORY_ITEMS_SETTING,
VISIBLE_LIMIT
)
const exec = new ExecutionHelper(comfyPage, await getWebSocket())

View File

@@ -2,21 +2,7 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
async function openMoreOptions(comfyPage: ComfyPage) {
await expect(comfyPage.selectionToolbox).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
// Wait for the context menu to appear by checking for 'Copy', which is
// always present regardless of single or multi-node selection.
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu.getByText('Copy', { exact: true })).toBeVisible()
}
import { openMoreOptions } from '@e2e/fixtures/utils/selectionToolbox'
test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
test.describe('Single node actions', () => {
@@ -34,14 +20,14 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
await expect(nodeRef).not.toBePinned()
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Pin', { exact: true }).click()
let menu = await openMoreOptions(comfyPage)
await menu.getByText('Pin', { exact: true }).click()
await comfyPage.nextFrame()
await expect(nodeRef).toBePinned()
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Unpin', { exact: true }).click()
menu = await openMoreOptions(comfyPage)
await menu.getByText('Unpin', { exact: true }).click()
await comfyPage.nextFrame()
await expect(nodeRef).not.toBePinned()
@@ -57,14 +43,14 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
await expect(nodeRef).not.toBeCollapsed()
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Minimize Node', { exact: true }).click()
let menu = await openMoreOptions(comfyPage)
await menu.getByText('Minimize Node', { exact: true }).click()
await comfyPage.nextFrame()
await expect(nodeRef).toBeCollapsed()
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Expand Node', { exact: true }).click()
menu = await openMoreOptions(comfyPage)
await menu.getByText('Expand Node', { exact: true }).click()
await comfyPage.nextFrame()
await expect(nodeRef).not.toBeCollapsed()
@@ -78,8 +64,8 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Copy', { exact: true }).click()
const menu = await openMoreOptions(comfyPage)
await menu.getByText('Copy', { exact: true }).click()
await comfyPage.nextFrame()
// Paste the copied node
@@ -99,8 +85,8 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Duplicate', { exact: true }).click()
const menu = await openMoreOptions(comfyPage)
await menu.getByText('Duplicate', { exact: true }).click()
await comfyPage.nextFrame()
await expect

View File

@@ -0,0 +1,96 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
import { openMoreOptions } from '@e2e/fixtures/utils/selectionToolbox'
test.describe('Selection toolbox rename', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
})
test.describe('Single rename', () => {
test('Rename via More Options opens title editor for single node', async ({
comfyPage
}) => {
const nodeRef = (
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
)[0]
await comfyPage.nodeOps.selectNodeWithPan(nodeRef)
const menu = await openMoreOptions(comfyPage)
await menu.getByText('Rename', { exact: true }).click()
await expect(comfyPage.page.getByTestId('node-title-input')).toHaveValue(
'KSampler'
)
})
test('Rename shows prompt dialog for group', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'LiteGraph.Group.SelectChildrenOnClick',
false
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
const outerGroupPos = await getGroupTitlePosition(
comfyPage,
'Outer Group'
)
await comfyPage.canvas.click({ position: outerGroupPos })
const menu = await openMoreOptions(comfyPage)
await menu.getByText('Rename', { exact: true }).click()
await expect(comfyPage.nodeOps.promptDialogInput).toBeVisible()
await comfyPage.nodeOps.promptDialogInput.fill('Renamed Group')
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.nodeOps.promptDialogInput).toBeHidden()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
return window.app!.graph.groups.some(
(g) => g.title === 'Renamed Group'
)
})
)
.toBe(true)
})
})
test.describe('Batch rename', () => {
test('Batch rename multiple selected nodes', async ({ comfyPage }) => {
const ksampler = (
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
)[0]
const emptyLatent = (
await comfyPage.nodeOps.getNodeRefsByTitle('Empty Latent Image')
)[0]
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
const menu = await openMoreOptions(comfyPage)
await menu.getByText('Rename', { exact: true }).click()
await expect(comfyPage.nodeOps.promptDialogInput).toBeVisible()
await comfyPage.nodeOps.promptDialogInput.fill('TestNode')
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.nodeOps.promptDialogInput).toBeHidden()
await expect
.poll(async () => {
const titles = await Promise.all([
ksampler.getProperty<string>('title'),
emptyLatent.getProperty<string>('title')
])
return [...titles].sort()
})
.toEqual(['TestNode 1', 'TestNode 2'])
})
})
})

View File

@@ -3,6 +3,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { getPromotedWidgets } from '@e2e/fixtures/utils/promotedWidgets'
test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
test.describe('Nested subgraph configure order', () => {
@@ -190,4 +191,106 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
})
}
)
test.describe(
'Nested subgraph input target resolution',
{ tag: ['@widget', '@vue-nodes'] },
() => {
const WORKFLOW = 'subgraphs/subgraph-nested-promotion'
const OUTER_NODE_ID = '5'
const INNER_SUBGRAPH_NODE_ID = '6'
test('Nested SubgraphNode promoted widgets render without resolution failures', async ({
comfyPage
}) => {
const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings(
comfyPage.page,
['No link found', 'Failed to resolve legacy -1']
)
try {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
await comfyExpect(outerNode).toBeVisible()
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
await comfyExpect(
widgets,
'asset has 4 promoted widgets on outer subgraph node'
).toHaveCount(4)
expect(warnings).toEqual([])
} finally {
dispose()
}
})
test('Promoted widgets from inner SubgraphNode are visible with correct values', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
await comfyExpect(outerNode).toBeVisible()
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
await comfyExpect(widgets).toHaveCount(4)
const valueWidget = outerNode
.getByRole('textbox', { name: 'value' })
.first()
await comfyExpect(valueWidget).toBeVisible()
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
})
test('Promoted widgets from inner SubgraphNode carry correct source identity', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
await expect
.poll(async () => {
const widgets = await getPromotedWidgets(comfyPage, OUTER_NODE_ID)
return widgets
.filter(
([sourceNodeId]) => sourceNodeId === INNER_SUBGRAPH_NODE_ID
)
.map(([, sourceWidgetName]) => sourceWidgetName)
})
.toContain('value')
})
test('Serialize and reload preserves nested promoted widget visibility', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
await comfyExpect(
widgets,
'asset has 4 promoted widgets on outer subgraph node'
).toHaveCount(4)
const initialCount = await widgets.count()
await comfyPage.subgraph.serializeAndReload()
await comfyPage.vueNodes.waitForNodes()
const outerNodeAfter = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget)
await comfyExpect(widgetsAfter).toHaveCount(initialCount)
const valueWidget = outerNodeAfter
.getByRole('textbox', { name: 'value' })
.first()
await comfyExpect(valueWidget).toBeVisible()
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
})
}
)
})

View File

@@ -535,6 +535,28 @@ test.describe(
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
.toBeLessThan(initialWidgetCount)
})
test('Does not cleanup unconfigured Primitive', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-link-and-proxied-primitive'
)
await expect
.poll(
() => getPromotedWidgetCount(comfyPage, '2'),
'Primitive widget is restored on load'
)
.toBe(2)
await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
const subgraphNode = await comfyPage.nodeOps.getFirstNodeRef()
const promotedPrimitive = await subgraphNode!.getWidget(1)
await expect
.poll(
() => promotedPrimitive.getValue(),
'Primitive widget is not in a disconnected state'
)
.toBe(0)
})
})
}
)

View File

@@ -125,6 +125,48 @@ test.describe('Workflow tabs', () => {
await expect(activeTab.locator('text=•')).toBeVisible()
})
test('Can drag tab to end', async ({ comfyPage }) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(3)
const [a, b, c] = await topbar.getTabNames()
await topbar.getTab(0).dragTo(topbar.getTab(2))
await expect.poll(() => topbar.getTabNames()).toEqual([b, c, a])
})
test('Can drag tab to start', async ({ comfyPage }) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(3)
const [a, b, c] = await topbar.getTabNames()
await topbar.getTab(2).dragTo(topbar.getTab(0))
await expect.poll(() => topbar.getTabNames()).toEqual([c, a, b])
})
test('Drag preserves active tab', async ({ comfyPage }) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(3)
const [, b] = await topbar.getTabNames()
await topbar.getTab(1).click()
await expect.poll(() => topbar.getActiveTabName()).toContain(b)
await topbar.getTab(0).dragTo(topbar.getTab(2))
await expect.poll(() => topbar.getActiveTabName()).toContain(b)
})
test('Multiple tabs can be created, switched, and closed', async ({
comfyPage
}) => {

View File

@@ -1,65 +1,18 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
clickExactMenuItem,
getNodeRef,
getNodeWrapper,
openContextMenu,
openMultiNodeContextMenu
} from '@e2e/fixtures/utils/contextMenuTestHelpers'
const BYPASS_CLASS = /before:bg-bypass\/60/
async function clickExactMenuItem(comfyPage: ComfyPage, name: string) {
await comfyPage.contextMenu.clickMenuItemExact(name)
await expect(comfyPage.contextMenu.primeVueMenu).toBeHidden()
}
async function openContextMenu(comfyPage: ComfyPage, nodeTitle: string) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
await comfyPage.contextMenu.openForVueNode(fixture.header)
return comfyPage.contextMenu.primeVueMenu
}
async function openMultiNodeContextMenu(
comfyPage: ComfyPage,
titles: string[]
) {
// deselectAll via evaluate — clearSelection() clicks at a fixed position
// which can hit nodes or the toolbar overlay
await comfyPage.page.evaluate(() => window.app!.canvas.deselectAll())
await comfyPage.nextFrame()
for (const title of titles) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
await fixture.header.click({ modifiers: ['ControlOrMeta'] })
}
await comfyPage.nextFrame()
const firstFixture = await comfyPage.vueNodes.getFixtureByTitle(titles[0])
const box = await firstFixture.header.boundingBox()
if (!box) throw new Error(`Header for "${titles[0]}" not found`)
await comfyPage.page.mouse.click(
box.x + box.width / 2,
box.y + box.height / 2,
{ button: 'right' }
)
const menu = comfyPage.contextMenu.primeVueMenu
await menu.waitFor({ state: 'visible' })
return menu
}
function getNodeWrapper(comfyPage: ComfyPage, nodeTitle: string): Locator {
return comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.getByTestId(TestIds.node.innerWrapper)
}
async function getNodeRef(comfyPage: ComfyPage, nodeTitle: string) {
const refs = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
return refs[0]
}
test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
test.describe('Single Node Actions', () => {
test('should rename node via context menu', async ({ comfyPage }) => {

View File

@@ -0,0 +1,280 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
clickExactMenuItem,
getNodeRef,
openContextMenu,
openMultiNodeContextMenu
} from '@e2e/fixtures/utils/contextMenuTestHelpers'
test.describe(
'Vue Node Context Menu — Extended Coverage',
{ tag: '@vue-nodes' },
() => {
test.describe('Single Node Actions', () => {
test('should open node info via context menu', async ({ comfyPage }) => {
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Node Info')
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
).toBeVisible()
})
test('should change node color via Color submenu', async ({
comfyPage
}) => {
const nodeRef = await getNodeRef(comfyPage, 'KSampler')
const initialColor = await nodeRef.getProperty<string | undefined>(
'color'
)
await openContextMenu(comfyPage, 'KSampler')
const menu = comfyPage.contextMenu.primeVueMenu
await menu.getByRole('menuitem', { name: 'Color', exact: true }).click()
const redSwatch = comfyPage.page.getByTitle('Red', { exact: true })
await expect(redSwatch.first()).toBeVisible()
await redSwatch.first().click()
await expect
.poll(() => nodeRef.getProperty<string | undefined>('color'))
.not.toBe(initialColor)
})
test('should change node shape via Shape submenu', async ({
comfyPage
}) => {
const nodeRef = await getNodeRef(comfyPage, 'KSampler')
await openContextMenu(comfyPage, 'KSampler')
const menu = comfyPage.contextMenu.primeVueMenu
await menu.getByRole('menuitem', { name: 'Shape', exact: true }).hover()
const boxItem = menu
.getByRole('menuitem', { name: 'Box', exact: true })
.last()
await expect(boxItem).toBeVisible()
await boxItem.click()
await expect.poll(() => nodeRef.getProperty<number>('shape')).toBe(1)
})
test('should delete node via Delete context menu', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Delete')
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount - 1)
})
test('should not show Run Branch for non-output nodes', async ({
comfyPage
}) => {
await openContextMenu(comfyPage, 'Load Checkpoint')
await expect(
comfyPage.contextMenu.primeVueMenu.getByRole('menuitem', {
name: 'Run Branch',
exact: true
})
).toBeHidden()
})
test('should show Run Branch for output nodes', async ({ comfyPage }) => {
await expect(
comfyPage.vueNodes.getNodeByTitle('Save Image'),
'Default workflow must contain Save Image node'
).toBeVisible()
await openContextMenu(comfyPage, 'Save Image')
await expect(
comfyPage.contextMenu.primeVueMenu.getByRole('menuitem', {
name: 'Run Branch',
exact: true
})
).toBeVisible()
})
})
test.describe('Image Node Actions', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes(1)
await comfyPage.page
.locator('[data-node-id] img')
.first()
.waitFor({ state: 'visible' })
const [loadImageNode] =
await comfyPage.nodeOps.getNodeRefsByTitle('Load Image')
if (!loadImageNode) throw new Error('Load Image node not found')
await expect
.poll(() =>
comfyPage.page.evaluate(
(nodeId) =>
window.app!.graph.getNodeById(nodeId)?.imgs?.length ?? 0,
loadImageNode.id
)
)
.toBeGreaterThan(0)
})
test('should open mask editor via context menu', async ({
comfyPage
}) => {
await openContextMenu(comfyPage, 'Load Image')
await clickExactMenuItem(comfyPage, 'Open in Mask Editor')
const maskEditorDialog = comfyPage.page.locator('.mask-editor-dialog')
await expect(maskEditorDialog).toBeVisible()
})
})
test.describe('Multi-Node Actions', () => {
const nodeTitles = ['Load Checkpoint', 'KSampler']
test('should align selected nodes via Align Selected To submenu', async ({
comfyPage
}) => {
const nodeRef0 = await getNodeRef(comfyPage, nodeTitles[0])
const nodeRef1 = await getNodeRef(comfyPage, nodeTitles[1])
const initialPos0 = await nodeRef0.getPosition()
const initialPos1 = await nodeRef1.getPosition()
expect(
initialPos0.y !== initialPos1.y,
'Nodes should start at different y positions'
).toBe(true)
await openMultiNodeContextMenu(comfyPage, nodeTitles)
const menu = comfyPage.contextMenu.primeVueMenu
await menu
.getByRole('menuitem', {
name: 'Align Selected To',
exact: true
})
.hover()
const topItem = menu
.getByRole('menuitem', { name: 'Top', exact: true })
.last()
await expect(topItem).toBeVisible()
await topItem.click()
await expect
.poll(async () => {
const pos0 = await nodeRef0.getPosition()
const pos1 = await nodeRef1.getPosition()
return Math.abs(pos0.y - pos1.y)
})
.toBeLessThanOrEqual(1)
})
test('should distribute selected nodes via Distribute Nodes submenu', async ({
comfyPage
}) => {
const threeNodes = ['Load Checkpoint', 'KSampler', 'Empty Latent Image']
await openMultiNodeContextMenu(comfyPage, threeNodes)
const menu = comfyPage.contextMenu.primeVueMenu
await menu
.getByRole('menuitem', {
name: 'Distribute Nodes',
exact: true
})
.hover()
const horizontalItem = menu
.getByRole('menuitem', {
name: 'Horizontal',
exact: true
})
.last()
await expect(horizontalItem).toBeVisible()
await horizontalItem.click()
const nodeRef0 = await getNodeRef(comfyPage, threeNodes[0])
const nodeRef1 = await getNodeRef(comfyPage, threeNodes[1])
const nodeRef2 = await getNodeRef(comfyPage, threeNodes[2])
await expect
.poll(async () => {
const bounds = await Promise.all([
nodeRef0.getBounding(),
nodeRef1.getBounding(),
nodeRef2.getBounding()
])
const sorted = bounds.toSorted((a, b) => a.x - b.x)
const gap1 = sorted[1].x - (sorted[0].x + sorted[0].width)
const gap2 = sorted[2].x - (sorted[1].x + sorted[1].width)
return Math.abs(gap1 - gap2)
})
.toBeLessThanOrEqual(1)
})
})
test.describe('Menu Visibility Invariants', () => {
test('should show Delete menu item for any node', async ({
comfyPage
}) => {
await openContextMenu(comfyPage, 'KSampler')
await expect(
comfyPage.contextMenu.primeVueMenu.getByRole('menuitem', {
name: 'Delete',
exact: true
})
).toBeVisible()
})
})
test.describe('Widget Extra Options', () => {
test('should show widget-specific options when right-clicking a named widget', async ({
comfyPage
}) => {
const widgetLocator = comfyPage.vueNodes.getWidgetByName(
'KSampler',
'seed'
)
await expect(
widgetLocator,
'KSampler must expose a "seed" widget'
).toBeVisible()
await widgetLocator.hover()
await widgetLocator.dispatchEvent('contextmenu', {
bubbles: true,
cancelable: true,
button: 2
})
const menu = comfyPage.contextMenu.primeVueMenu
await menu.waitFor({ state: 'visible' })
const menuItems = menu.getByRole('menuitem')
const labels = await menuItems.allTextContents()
const trimmedLabels = labels.map((l) => l.trim())
const hasFavoriteOrRename = trimmedLabels.some(
(label) =>
label.startsWith('Favorite Widget') ||
label.startsWith('Unfavorite Widget') ||
label.startsWith('Rename Widget')
)
expect(
hasFavoriteOrRename,
'Widget-specific menu options (Favorite/Unfavorite/Rename Widget) should appear for the "seed" widget'
).toBe(true)
})
})
}
)

View File

@@ -13,6 +13,7 @@ import {
ExecutionHelper,
buildKSamplerError
} from '@e2e/fixtures/helpers/ExecutionHelper'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
@@ -20,6 +21,7 @@ const test = mergeTests(comfyPageFixture, webSocketFixture)
const ERROR_CLASS = /ring-destructive-background/
const UNKNOWN_NODE_ID = '1'
const INNER_EXECUTION_ID = '2:1'
const KSAMPLER_MODEL_INPUT_NAME = 'model'
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
test('should display error state when node is missing (node from workflow is not installed)', async ({
@@ -71,6 +73,59 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
).toHaveClass(ERROR_CLASS)
})
test(
'highlights the missing required input slot',
{ tag: ['@screenshot', '@node'] },
async ({ comfyPage }) => {
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const ksamplerNode = comfyPage.vueNodes.getNodeLocator(ksamplerId)
const modelInputIndex = await comfyPage.page.evaluate(
({ nodeId, inputName }) => {
const node = window.app!.graph.getNodeById(nodeId)
const index =
node?.inputs?.findIndex((input) => input.name === inputName) ?? -1
if (index < 0) {
throw new Error(`Input slot "${inputName}" not found`)
}
return index
},
{ nodeId: ksamplerId, inputName: KSAMPLER_MODEL_INPUT_NAME }
)
const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
ksamplerId,
modelInputIndex
)
const modelInputSlotHighlight =
comfyPage.vueNodes.getInputSlotConnectionDot(
ksamplerId,
modelInputIndex
)
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[ksamplerId]: buildKSamplerError(
'required_input_missing',
KSAMPLER_MODEL_INPUT_NAME,
`Required input is missing: ${KSAMPLER_MODEL_INPUT_NAME}`
)
})
await comfyPage.runButton.click()
await dismissErrorOverlay(comfyPage)
await fitToViewInstant(comfyPage)
await expect(modelInputSlotRow).toBeVisible()
await expect(modelInputSlotRow).toBeInViewport()
await expect(modelInputSlotHighlight).toHaveClass(/before:ring-error/)
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
).toHaveClass(ERROR_CLASS)
await comfyPage.expectScreenshot(
ksamplerNode,
'vue-node-required-input-missing-slot-error.png'
)
}
)
test('clears error ring when user edits an out-of-range number widget back into range', async ({
comfyPage
}) => {

View File

@@ -0,0 +1,49 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
const WORKFLOW_NAME = 'test-confirm-delete'
async function startDeletingFromSidebar(comfyPage: ComfyPage) {
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab.getPersistedItem(WORKFLOW_NAME).click({ button: 'right' })
await comfyPage.contextMenu.clickMenuItem('Delete')
}
test.describe('Comfy.Workflow.ConfirmDelete', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.menu.topbar.saveWorkflowAs(WORKFLOW_NAME)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.workflow.setupWorkflowsDirectory({})
})
test('on (default): right-click → Delete prompts the confirm dialog', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Workflow.ConfirmDelete', true)
await startDeletingFromSidebar(comfyPage)
await expect(comfyPage.confirmDialog.root).toBeVisible()
await expect(comfyPage.confirmDialog.delete).toBeVisible()
})
test('off: right-click → Delete bypasses the confirm dialog', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Workflow.ConfirmDelete', false)
await startDeletingFromSidebar(comfyPage)
const { workflowsTab } = comfyPage.menu
await expect(comfyPage.confirmDialog.root).toBeHidden()
await expect
.poll(() => workflowsTab.getTopLevelSavedWorkflowNames())
.not.toContain(WORKFLOW_NAME)
})
})

View File

@@ -147,7 +147,7 @@ it('should subscribe to logs API', () => {
})
```
## Mocking Lodash Functions
## Mocking Utility Functions
Mocking utility functions like debounce:

View File

@@ -36,6 +36,7 @@ const settings = {
alwaysTryTypes: true,
project: [
'./tsconfig.json',
'./browser_tests/tsconfig.json',
'./apps/*/tsconfig.json',
'./packages/*/tsconfig.json'
],
@@ -250,7 +251,13 @@ export default defineConfig([
// @/utils/errorUtil instead — see issue #11429.
selector: "TSAsExpression TSTypeReference[typeName.name='Error']",
message:
'Do not use `as Error` assertions. Use `instanceof Error` narrowing or `toError()` from @/utils/errorUtil instead. See issue #11429.'
'Do not use Error type assertions. Use `instanceof Error` narrowing or `toError()` from @/utils/errorUtil instead. See issue #11429.'
},
{
// Bans `<Error>value` and `<Error & { ... }>value`.
selector: "TSTypeAssertion TSTypeReference[typeName.name='Error']",
message:
'Do not use Error type assertions. Use `instanceof Error` narrowing or `toError()` from @/utils/errorUtil instead. See issue #11429.'
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.15",
"version": "1.44.17",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -29,6 +29,17 @@ export type {
BillingStatus,
BillingStatusResponse,
BindingErrorResponse,
BulkRevokeApiKeysResponse,
BulkRevokeWorkspaceMemberApiKeysData,
BulkRevokeWorkspaceMemberApiKeysError,
BulkRevokeWorkspaceMemberApiKeysErrors,
BulkRevokeWorkspaceMemberApiKeysResponse,
BulkRevokeWorkspaceMemberApiKeysResponses,
CancelJobData,
CancelJobError,
CancelJobErrors,
CancelJobResponse,
CancelJobResponses,
CancelSubscriptionData,
CancelSubscriptionError,
CancelSubscriptionErrors,
@@ -307,6 +318,28 @@ export type {
GetJwksData,
GetJwksResponse,
GetJwksResponses,
GetLegacyAssetContentData,
GetLegacyAssetContentErrors,
GetLegacyHistoryByIdData,
GetLegacyHistoryByIdErrors,
GetLegacyHistoryData,
GetLegacyHistoryErrors,
GetLegacyJobByIdData,
GetLegacyJobByIdErrors,
GetLegacyJobOutputsData,
GetLegacyJobOutputsErrors,
GetLegacyModelsByFolderData,
GetLegacyModelsByFolderErrors,
GetLegacyModelsData,
GetLegacyModelsErrors,
GetLegacyObjectInfoByNodeClassData,
GetLegacyObjectInfoByNodeClassErrors,
GetLegacyPromptByIdData,
GetLegacyPromptByIdErrors,
GetLegacyUserdataV2Data,
GetLegacyUserdataV2Errors,
GetLegacyViewMetadataData,
GetLegacyViewMetadataErrors,
GetLogsData,
GetLogsError,
GetLogsErrors,
@@ -505,6 +538,7 @@ export type {
InterruptJobError,
InterruptJobErrors,
InterruptJobResponses,
JobCancelResponse,
JobDetailResponse,
JobEntry,
JobsListResponse,
@@ -719,6 +753,13 @@ export type {
SubscribeResponses,
SubscriptionDuration,
SubscriptionTier,
SyncApiKeyData,
SyncApiKeyError,
SyncApiKeyErrors,
SyncApiKeyRequest,
SyncApiKeyResponse,
SyncApiKeyResponse2,
SyncApiKeyResponses,
SystemStatsResponse,
TagInfo,
TagsModificationResponse,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2524,6 +2524,46 @@ export interface paths {
patch?: never;
trace?: never;
};
"/proxy/luma_2/generations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Create a Luma Agents generation
* @description Submit an image generation or edit job. Returns immediately with an opaque job ID to poll via GET /proxy/luma_2/generations/{id}.
*/
post: operations["lumaAgentsCreateGeneration"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/proxy/luma_2/generations/{generation_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get a Luma Agents generation
* @description Poll for generation status and output. On completion, the response includes presigned URLs to download the generated images.
*/
get: operations["lumaAgentsGetGeneration"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/proxy/pixverse/video/text/generate": {
parameters: {
query?: never;
@@ -10381,6 +10421,88 @@ export interface components {
/** @description The request of the generation */
request?: components["schemas"]["LumaGenerationRequest"] | components["schemas"]["LumaImageGenerationRequest"] | components["schemas"]["LumaUpscaleVideoGenerationRequest"] | components["schemas"]["LumaAudioGenerationRequest"];
};
/**
* @description Output aspect ratio
* @enum {string}
*/
LumaAgentsAspectRatio: "3:1" | "2:1" | "16:9" | "3:2" | "1:1" | "2:3" | "9:16" | "1:2" | "1:3";
/**
* @description Style preset
* @enum {string}
*/
LumaAgentsStyle: "auto" | "manga";
/**
* @description Output image format
* @enum {string}
*/
LumaAgentsOutputFormat: "png" | "jpeg";
/**
* @description The kind of generation to perform
* @enum {string}
*/
LumaAgentsGenerationType: "image" | "image_edit";
/**
* @description Current state of the generation
* @enum {string}
*/
LumaAgentsState: "queued" | "processing" | "completed" | "failed";
/**
* @description Machine-readable failure code for programmatic handling
* @enum {string}
*/
LumaAgentsFailureCode: "content_moderated" | "generation_failed" | "budget_exhausted" | "output_not_found";
/** @description Reference image for style/content guidance or guided generation */
LumaAgentsImageRef: {
/** @description Base64-encoded image data */
data?: string;
/** @description MIME type (e.g. image/jpeg). Required with data. */
media_type?: string;
/** @description Publicly accessible image URL */
url?: string;
};
/** @description The Luma Agents generation request object */
LumaAgentsGenerationRequest: {
/** @description Text prompt */
prompt: string;
aspect_ratio?: components["schemas"]["LumaAgentsAspectRatio"];
/** @description Reference images for style/content guidance. Up to 9 for type 'image', up to 8 for type 'image_edit'. */
image_ref?: components["schemas"]["LumaAgentsImageRef"][];
/** @description Model to use */
model?: string;
output_format?: components["schemas"]["LumaAgentsOutputFormat"];
source?: components["schemas"]["LumaAgentsImageRef"];
style?: components["schemas"]["LumaAgentsStyle"];
type?: components["schemas"]["LumaAgentsGenerationType"];
/** @description Enable web search grounding */
web_search?: boolean;
};
/** @description A generated output entry */
LumaAgentsGenerationOutput: {
/** @description Media type (e.g. image) */
type?: string;
/** @description Presigned URL (1hr expiry) */
url?: string;
};
/** @description Generation status and output */
LumaAgentsGeneration: {
/** @description Generation identifier */
id?: string;
/** @description Creation timestamp */
created_at?: string;
/** @description Model used */
model?: string;
state?: components["schemas"]["LumaAgentsState"];
type?: components["schemas"]["LumaAgentsGenerationType"];
failure_code?: components["schemas"]["LumaAgentsFailureCode"];
/** @description Human-readable failure description */
failure_reason?: string;
output?: components["schemas"]["LumaAgentsGenerationOutput"][];
};
/** @description The error object */
LumaAgentsError: {
/** @description The error message */
detail?: string;
};
PixverseTextVideoRequest: {
/** @enum {string} */
aspect_ratio: "16:9" | "4:3" | "1:1" | "3:4" | "9:16";
@@ -12453,12 +12575,16 @@ export interface components {
Rodin3DGenerateRequest: {
/** @description The reference images to generate 3D Assets. */
images: string;
/** @description Text prompt used by the upstream Rodin API. Required by upstream for text-to-3D requests (no images uploaded); optional for image-to-3D requests where it acts as additional guidance. */
prompt?: string;
/** @description Seed. */
seed?: number;
tier?: components["schemas"]["RodinTierType"];
material?: components["schemas"]["RodinMaterialType"];
quality?: components["schemas"]["RodinQualityType"];
mesh_mode?: components["schemas"]["RodinMeshModeType"];
/** @description Optional list of upstream addon flags (e.g. "HighPack"). */
addons?: string[];
};
/**
* @description Rodin Tier para options
@@ -26183,6 +26309,72 @@ export interface operations {
};
};
};
lumaAgentsCreateGeneration: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description The generation request object */
requestBody: {
content: {
"application/json": components["schemas"]["LumaAgentsGenerationRequest"];
};
};
responses: {
/** @description Generation accepted */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LumaAgentsGeneration"];
};
};
/** @description Error */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LumaAgentsError"];
};
};
};
};
lumaAgentsGetGeneration: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The ID of the generation */
generation_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Generation found */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LumaAgentsGeneration"];
};
};
/** @description Error */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LumaAgentsError"];
};
};
};
};
PixverseGenerateTextVideo: {
parameters: {
query?: never;

28
pnpm-lock.yaml generated
View File

@@ -160,8 +160,8 @@ catalogs:
specifier: ^7.7.0
version: 7.7.0
'@types/three':
specifier: ^0.169.0
version: 0.169.0
specifier: ^0.170.0
version: 0.170.0
'@vee-validate/zod':
specifier: ^4.15.1
version: 4.15.1
@@ -339,6 +339,9 @@ catalogs:
tailwindcss-primeui:
specifier: ^0.6.1
version: 0.6.1
three:
specifier: ^0.170.0
version: 0.170.0
tsx:
specifier: ^4.15.6
version: 4.19.4
@@ -698,7 +701,7 @@ importers:
version: 7.7.0
'@types/three':
specifier: 'catalog:'
version: 0.169.0
version: 0.170.0
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
@@ -964,6 +967,9 @@ importers:
posthog-js:
specifier: 'catalog:'
version: 1.358.1
three:
specifier: 'catalog:'
version: 0.170.0
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
@@ -4508,8 +4514,8 @@ packages:
'@types/stats.js@0.17.3':
resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
'@types/three@0.169.0':
resolution: {integrity: sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==}
'@types/three@0.170.0':
resolution: {integrity: sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==}
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
@@ -9883,8 +9889,8 @@ packages:
vue-component-type-helpers@3.2.6:
resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==}
vue-component-type-helpers@3.2.7:
resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==}
vue-component-type-helpers@3.2.8:
resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -13405,7 +13411,7 @@ snapshots:
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.3)
vue-component-type-helpers: 3.2.7
vue-component-type-helpers: 3.2.8
'@swc/helpers@0.5.17':
dependencies:
@@ -13834,7 +13840,7 @@ snapshots:
'@types/stats.js@0.17.3': {}
'@types/three@0.169.0':
'@types/three@0.170.0':
dependencies:
'@tweenjs/tween.js': 23.1.3
'@types/stats.js': 0.17.3
@@ -14189,7 +14195,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/utils@3.2.4':
dependencies:
@@ -20530,7 +20536,7 @@ snapshots:
vue-component-type-helpers@3.2.6: {}
vue-component-type-helpers@3.2.7: {}
vue-component-type-helpers@3.2.8: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
dependencies:

View File

@@ -54,7 +54,7 @@ catalog:
'@types/jsdom': ^21.1.7
'@types/node': ^24.1.0
'@types/semver': ^7.7.0
'@types/three': ^0.169.0
'@types/three': ^0.170.0
'@vee-validate/zod': ^4.15.1
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
@@ -113,6 +113,7 @@ catalog:
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.2.0
three: ^0.170.0
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6
tw-animate-css: ^1.3.8

View File

@@ -0,0 +1,119 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isEmbeddedWebView } from '@/base/webviewDetection'
describe('isEmbeddedWebView', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
describe('Android WebView', () => {
it('detects Android WebView with wv token', () => {
const ua =
'Mozilla/5.0 (Linux; Android 13; SM-G991B; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/120.0.0.0 Mobile Safari/537.36'
expect(isEmbeddedWebView(ua)).toBe(true)
})
it('does not flag regular Chrome on Android', () => {
const ua =
'Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'
expect(isEmbeddedWebView(ua)).toBe(false)
})
})
describe('iOS WKWebView', () => {
it('detects iOS WKWebView (AppleWebKit without Safari/)', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'
expect(isEmbeddedWebView(ua)).toBe(true)
})
it('does not flag regular Safari on iOS', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
expect(isEmbeddedWebView(ua)).toBe(false)
})
it('does not flag Chrome on iOS (CriOS)', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.0.0 Mobile/15E148'
expect(isEmbeddedWebView(ua)).toBe(false)
})
it('does not flag Firefox on iOS (FxiOS)', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/120.0 Mobile/15E148'
expect(isEmbeddedWebView(ua)).toBe(false)
})
})
describe('social app in-app browsers', () => {
it('detects Facebook (FBAN)', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/400.0]'
expect(isEmbeddedWebView(ua)).toBe(true)
})
it('detects Instagram', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Instagram 300.0'
expect(isEmbeddedWebView(ua)).toBe(true)
})
it('detects TikTok', () => {
const ua =
'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36 TikTok/30.0'
expect(isEmbeddedWebView(ua)).toBe(true)
})
it('detects Line', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Line/13.0'
expect(isEmbeddedWebView(ua)).toBe(true)
})
it('detects Snapchat', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Snapchat/12.0'
expect(isEmbeddedWebView(ua)).toBe(true)
})
})
describe('regular desktop browsers', () => {
it('does not flag Chrome desktop', () => {
const ua =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
expect(isEmbeddedWebView(ua)).toBe(false)
})
it('does not flag Firefox desktop', () => {
const ua =
'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0'
expect(isEmbeddedWebView(ua)).toBe(false)
})
it('does not flag Safari desktop', () => {
const ua =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'
expect(isEmbeddedWebView(ua)).toBe(false)
})
})
describe('edge cases', () => {
it('handles empty string', () => {
expect(isEmbeddedWebView('')).toBe(false)
})
})
describe('JS bridge detection', () => {
it('detects webkit.messageHandlers bridge', () => {
vi.stubGlobal('webkit', { messageHandlers: {} })
expect(isEmbeddedWebView('')).toBe(true)
})
it('detects ReactNativeWebView bridge', () => {
vi.stubGlobal('ReactNativeWebView', { postMessage: vi.fn() })
expect(isEmbeddedWebView('')).toBe(true)
})
})
})

View File

@@ -0,0 +1,72 @@
/**
* Detects whether the app is running inside an embedded webview.
*
* Google blocks OAuth via `signInWithPopup` in embedded webviews,
* returning a 403 `disallowed_useragent` error (policy since 2021).
* This utility is used to hide the Google SSO button in those contexts.
*
* Detection covers:
* • Android WebView (`wv` token in UA)
* • iOS WKWebView (has `AppleWebKit` but lacks `Safari/`)
* • Social app in-app browsers (Facebook, Instagram, TikTok, etc.)
* • JS bridge objects (`window.webkit.messageHandlers`, `ReactNativeWebView`)
*/
const SOCIAL_APP_PATTERNS =
/FBAN|FBAV|Instagram|Line\/|Snapchat|TikTok|musical_ly/i
function isAndroidWebView(ua: string): boolean {
return /\bwv\b/.test(ua) && /Android/.test(ua)
}
function isIOSWebView(ua: string): boolean {
if (!/AppleWebKit/i.test(ua)) return false
if (/Safari\//i.test(ua)) return false
if (/CriOS|FxiOS|OPiOS|EdgiOS/i.test(ua)) return false
return true
}
function isSocialAppBrowser(ua: string): boolean {
return SOCIAL_APP_PATTERNS.test(ua)
}
function hasWebViewBridge(): boolean {
try {
const win = globalThis as Record<string, unknown>
if (
typeof win.webkit === 'object' &&
win.webkit !== null &&
typeof (win.webkit as Record<string, unknown>).messageHandlers ===
'object'
) {
return true
}
if (win.ReactNativeWebView != null) return true
} catch {
// Access to bridge objects may throw in sandboxed contexts
}
return false
}
export function isEmbeddedWebView(ua: string = navigator.userAgent): boolean {
if (isSocialAppBrowser(ua)) return true
if (isAndroidWebView(ua)) return true
if (isIOSWebView(ua)) return true
if (hasWebViewBridge()) return true
return false
}
/**
* Reason why Google SSO is blocked in the current environment, or `null` if it
* is available. Modeled as a discriminated string so call sites read as
* "if blocked, here's why" rather than an opaque boolean. Extend this union
* (e.g. `'unauthorized-host'`) as new blocking conditions are detected.
*/
type GoogleSsoBlockedReason = 'embedded-webview' | null
export function getGoogleSsoBlockedReason(
ua: string = navigator.userAgent
): GoogleSsoBlockedReason {
if (isEmbeddedWebView(ua)) return 'embedded-webview'
return null
}

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