Compare commits

..

45 Commits

Author SHA1 Message Date
Michael B
22840c4190 chore: remove unused models showcase components
ModelsShowcaseSection and ShowcaseCard were superseded by AIModelsSection
on the models page and are no longer referenced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:06:35 -04:00
Michael B
7525220cd4 Trigger re-deploy 2026-05-22 18:06:35 -04:00
Michael B
023ab9614b feat: use shared AIModelsSection on models page
Swap ModelsShowcaseSection for the shared product/shared/AIModelsSection
on both the EN and zh-CN models pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:06:35 -04:00
Michael B
cd2e3219e3 refactor: extract ShowcaseCard component from models showcase
Move per-card markup out of ModelsShowcaseSection into a dedicated
ShowcaseCard component (mirroring GalleryCard). Switch the card to a
uniform h-80 height, animate the badge color on hover, and drop the
now-conflicting aspect-ratio classes that caused cards to overflow
their grid columns.
2026-05-22 18:06:34 -04:00
Michael B
cc8ccf42c5 feat: add featured AI models showcase to models page 2026-05-22 18:06:34 -04:00
Michael B
ceaa445a55 feat: add creations and product showcase sections to models page 2026-05-22 18:06:34 -04:00
Michael B
96107ee3bd feat: add models page hero section
Adds ModelsHeroSection component, models list pages (en and zh-CN),
and accompanying i18n translations.
2026-05-22 18:06:34 -04:00
imick-io
eeeacc9b03 feat(website): constrain sections to max-w-9xl on wide screens (#12428)
Add max-w-9xl mx-auto to section/container wrappers across the website
so layout stays centered and capped at 96rem on screens wider than
1536px.

## Summary

<!-- One sentence describing what changed and why. -->

## Changes

- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus

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

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

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:36:29 +00:00
Terry Jia
f744a4f1f8 chore: gitignore .nx daemon workspace data (#12427)
## Summary
add .nx to gitignore as it causes some workspace-cache now
2026-05-22 19:45:44 +00:00
Terry Jia
0588ca45b3 test: remove stale primevue mocks from load3d tests (#12350)
## Summary
The load3d components no longer import primevue/select or
primevue/checkbox, so the vi.mock blocks targeting them had no effect.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12350-test-remove-stale-primevue-mocks-from-load3d-tests-3666d73d365081f692dbe91e16d594c3)
by [Unito](https://www.unito.io)
2026-05-22 19:32:18 +00:00
Daxiong (Lin)
60ce0ee0c3 Add png and ico favicon for comfy.org (#12426) 2026-05-22 11:55:41 -07:00
jaeone94
91d2df45a1 Fix V2 draft lifecycle persistence (#12269)
## Summary

This PR fixes the remaining FE-367 workflow persistence gap by moving
the workflow draft lifecycle callers from the legacy V1 draft store to
`workflowDraftStoreV2`, following the core design from #10367 while
omitting unrelated changes.

It keeps the change focused on saved workflow tab restore and V2 draft
lifecycle behavior:

- save active workflow drafts through V2 before loading a new graph
- load, save, save-as, close, rename, and delete workflows against V2
draft storage
- prefer a fresh V2 draft when loading a saved workflow, and discard
stale drafts when the remote workflow is newer
- restore saved open tabs from persisted tab state instead of letting
stale active-path state win
- preserve V2 draft payload timestamps when moving or refreshing draft
recency
- remove the now-unused V1 draft store/cache implementation instead of
suppressing knip; the raw V1 on-disk migration path remains for existing
users

Co-authored-by: xmarre <xmarre@users.noreply.github.com>

## Test coverage

Added unit coverage for V2 draft load, stale draft discard, rename/close
lifecycle cleanup, tab restore ordering, metadata-load waiting/fallback,
draft recency updates, quota eviction retry, and persistence-disabled
reset behavior.

Updated the workflow persistence composable tests to use a real
`vue-i18n` plugin host instead of mocking `vue-i18n`.

Added an E2E regression test that saves two workflows, edits an inactive
saved tab draft, makes the active-path pointer stale, reloads, and
verifies the saved tab order, active tab, and inactive draft
restoration.

## Validation

- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm test:unit`
- pre-push `pnpm knip` (passes with the existing flac tag hint)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12269-Fix-V2-draft-lifecycle-persistence-3606d73d365081b4a84feb1696ed88bb)
by [Unito](https://www.unito.io)

---------

Co-authored-by: xmarre <xmarre@users.noreply.github.com>
2026-05-22 15:24:31 +00:00
Robin Huang
7b4fef5eca fix: show empty state when node library search has no matches (#12254)
The left-sidebar node library search fell back to rendering all visible
node defs whenever the filter returned zero hits, so gibberish queries
looked like the filter wasn't applied. Gates the fallback on the query
string and renders a "No nodes match" empty state across all tabs
(Essentials/All/Blueprints) when the active query has no results.

Before:


https://github.com/user-attachments/assets/ab11ef5e-c757-41f1-9e07-3427942b9929

After: 



https://github.com/user-attachments/assets/a724aaab-95a2-4832-a694-3d8e543fdabf


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12254-fix-show-empty-state-when-node-library-search-has-no-matches-3606d73d365081d19eaaff1095355072)
by [Unito](https://www.unito.io)
2026-05-22 10:15:26 +00:00
Alexander Brown
c703db5f6c chore: remove Nx and migrate monorepo workflows to pnpm (#12355)
## Summary
- remove Nx root/config artifacts and workspace 
x metadata
- replace Nx-based root scripts with direct pnpm workspace + native
Vite/Vitest/Playwright commands
- remove Nx dependencies/catalog entries and regenerate lockfile
- clean residual Nx references from CI workflows, tooling config, and
docs

## Validation
- pnpm install --frozen-lockfile
- pnpm lint
- pnpm typecheck
- pnpm build
- pnpm build:cloud
- pnpm build:desktop
- pnpm build:types
- pnpm test:browser -- --list
- pnpm test:unit *(4 pre-existing failing tests)*

ΓöåIssue is synchronized with this [Notion
page](https://www.notion.so/PR-12355-chore-remove-Nx-and-migrate-monorepo-workflows-to-pnpm-3666d73d3650817888b9e85e24a10b22)
by [Unito](https://www.unito.io)

---

**For keeping Nx**
- Better monorepo orchestration (task graph, affected runs, caching).
- Can reduce CI/runtime cost at larger scale.
- Unified interface across tools and packages.

**Against keeping Nx**
- Extra abstraction layer and config complexity.
- Higher maintenance/debugging cost (plugins, cache behavior, drift).
- Native `pnpm` + tool CLIs are simpler and clearer today.

**Bottom line**
- If current pain is low: prefer simplicity and remove Nx.
- If measured scale pain is high: orchestration may be worth it (Nx or
another tool later).

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-05-22 05:37:10 +00:00
skishore23
3011d3a60c feat: OAuth consent UI for authorization (BE-638) (#12159)
## Summary

Frontend half of MCP OAuth (BE-638) — `oauth_request_id` plumbing, Cloud
session cookie integration, and the consent screen that cloud's
`/oauth/authorize` hands off to the browser. Scoped strictly to
OAuth/consent code; local cloud-dev support is held back as local-only
changes.

## Changes

- **What**:
- `oauth_request_id` capture + `sessionStorage` preservation across
login / signup / SSO (`onboardingCloudRoutes.ts`,
`preservedQueryNamespaces.ts`, router guards). Cleared on success or
explicit logout. Never forwarded to a third-party redirect.
- `POST /api/auth/session` integration so the Cloud session cookie is
set before the consent fetch (`useSessionCookie.ts`).
- Consent route `/cloud/oauth/consent` — fetches the JSON challenge,
renders client display name + scopes + redirect URI + Native/Web
app-type badge, submits the user's decision.
- Workspace picker: inline radio list (mirrors the cloud workspace
switcher) using `WorkspaceProfilePic` avatars. Capped at `max-h-72` with
`scrollbar-custom` so 10+ workspaces stay discoverable.
- Cloud calls go via relative URLs (same-origin through Vite proxy /
prod reverse proxy) — fixes the cross-origin cookie loss that would
bounce the consent challenge back to login.
- 400 / 403 / 404 cloud errors map to user-facing copy (expired /
scope_broadening / feature_unavailable).
- `vite.config.mts` only adds the `/oauth` proxy (5 lines) — required
for same-origin OAuth calls in dev.
- **Breaking**: None.
- **Dependencies**: None added.

## Review Focus

- **Cross-origin cookie footgun** (`oauthApi.ts:54-67`): chose
same-origin relative URLs deliberately, comment captures why.
- **Deny + workspace_id fallback** (`OAuthConsentView.vue:312-313`): if
user denies without picking a workspace, we send `workspaces[0].id`.
Cloud team should confirm deny is workspace-independent.
- **Login-flow preservation**: confirm no third-party redirect ever sees
`oauth_request_id`.
- **`useSessionCookie` ordering**: session cookie must be set before any
OAuth resume navigation fires.
- **`labelFor()` uses computed i18n keys** (`oauth.workspace.${value}`):
static key analyzer flags `personal`/`team`/`owner`/`member` as unused,
but they're read at runtime.

## Commits

| | Commit | Scope |
|---|---|---|
| 1 | `<foundation>` | OAuth plumbing — `oauth_request_id`, session
cookie, consent route, API client (10 files) |
| 2 | `a973abec0` | UI polish — inline workspace picker, error mapping,
app-type badge, redirect URI (5 files) |

## Test plan

- [x] `pnpm test:unit src/platform/cloud/oauth/
src/platform/auth/session/` — 17/17 pass
- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — clean
- [ ] Manual staging E2E — blocked on cloud-side BE-633–BE-637

## Out of scope (kept as local-only changes)

Local cloud-dev fixes (Firebase auth emulator wiring, local API base,
Mixpanel disable, registry proxy, `__DEV_SERVER_COMFYUI_URL__` build
define) are useful for running the OAuth flow against a local cloud
backend, but aren't required for staging/prod. They're held back from
this PR and can ship separately if needed.

## Supersedes

Closes #12158.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12159-feat-OAuth-consent-UI-for-MCP-authorization-BE-638-35e6d73d3650811e956ff550995f40e6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-05-22 04:11:55 +00:00
Comfy Org PR Bot
6e31ce77c6 1.45.13 (#12412)
Patch version increment to 1.45.13

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-22 03:57:19 +00:00
AustinMroz
551c595bbb Remove template vram sorting (#12414)
With the dynamic vram changes, vram is both much more difficult to
measure, and much less useful of a metric. To prevent confusion, it has
been removed as a metric.

See also: #9074
2026-05-22 02:56:17 +00:00
AustinMroz
ee286291d4 Fix reactivity on matchType output slots (#12397)
Relevant: #9935. A PR claimed to solve the same issue (and was approved
by me), but the issue persists. Even when checking out that exact
commit, the issue does not appear affected.

This PR is somewhat heavier. It converts the outputs into
shallowReactive. Since there is no individual moment of registration for
outputs, this conversion happens on type change and leverages that
calling `shallowReactive` on a shallow reactive is low cost and
reflexive. It also adds a test to ensure that regression can not happen
in the future.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/3e4f4a0a-906f-4539-95b6-b2e80de7ceff"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/1a29ac66-ed5e-4874-82dc-ce9f6135dea5"
/>|
2026-05-22 02:56:02 +00:00
Daxiong (Lin)
efb214efe7 Update favicon and favicon progress with new logo (#12407)
Replace the favicon and favicon progress images with the new logo
2026-05-22 02:51:21 +00:00
Comfy Org PR Bot
9a2bea7283 chore(website): refresh Ashby and cloud nodes snapshots (#12410)
Automated refresh of remote-data snapshots used by the website
build:

- `apps/website/src/data/ashby-roles.snapshot.json` — Ashby job
  board API
- `apps/website/src/data/cloud-nodes.snapshot.json` — Comfy Cloud
  `/api/object_info`

**Flow:**
1. `Release: Website` workflow ran (manual trigger).
2. This PR opens with the regenerated snapshots.
3. `CI: Vercel Website Preview` deploys a preview for review.
4. Merging to `main` triggers the production Vercel deploy.

The snapshot fallback in `apps/website/src/utils/ashby.ts` and
`apps/website/src/utils/cloudNodes.ts` remains intact: builds
without the respective API keys continue to use the committed
snapshot (with a warning annotation in CI).

Triggered by workflow run `26260485885`.

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-05-22 00:21:09 +00:00
Christian Byrne
0a07781a76 fix(website): fetch cloud nodes from registry API instead of object_info (#12408)
## Summary

- Fixes cloud-nodes search not finding nodes like FaceDetailer
- The `/api/object_info` endpoint only returns a subset of nodes per
pack (~39 for Impact Pack), but the registry API has the full list (~197
nodes)
- Now fetches complete node list from registry API while still using
object_info to determine which packs are cloud-supported

## Changes

- Add `fetchRegistryPacksWithNodes()` to fetch full node list from
registry (`/nodes/{packId}/versions/{version}/comfy-nodes`)
- Keep using object_info to determine which packs are cloud-supported
- Prefer registry nodes when available, fall back to object_info nodes
- Add retry logic for comfy-nodes fetching
- Add comprehensive tests (13 new tests, 36 total)

## Test plan

- [x] All existing cloudNodes tests pass (36 tests)
- [x] New tests cover registry node fetching, pagination, retry logic
- [x] Type check passes
- [x] Lint passes
- [ ] Verify search for "FaceDetailer" returns Impact Pack on deployed
preview

## Related

- Fixes failing test in #12388 (the data refresh PR)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-21 23:46:33 +00:00
pythongosssss
b3ba6c9344 fix: select node after adding from library (#12404)
## Summary

When adding a node from the library sidebar, the node was not correctly
selected upon placing it. This was due to the canvas capturing the node
under the cursor on mouse down, however the node had not yet been
comitted to the graph at that point, and so selection was then cleared
on mouse up.

## Changes

- **What**: 
- add `blockCommitPointerDown` so if the cursor is over the canvas stop
propagation to prevent LiteGraph adding the mouse handler to clear the
selection

## Review Focus

Alternative approaches considered were blocking the event in endDrag
however this then required manual cleanup of LiteGraph handlers or
overriding the `pointer.onClick` function to force selection of our
node, both felt worse than this approach.

## Screenshots (if applicable)


https://github.com/user-attachments/assets/a2eb154e-5178-4a1e-b5c7-884efd7a10c6
2026-05-21 19:52:56 +00:00
AustinMroz
a50b3d16da Persist splash until graph load completes (#12387)
When an app mode workflow is opened on fresh page load, either from a
template url, or a persisted in browser cache, the UI would briefly
display the graph view prior to swapping to app mode. This is fixed by
continuing to display the splash screen until workflow state has loaded.

Share by url brings unique difficulties. The function call does not
return until a user has responded to a dialogue. If the splash screen
were blocked by this, the user would never be able to see the dialogue.
Consequentially, this change is not applied to shared workflow urls and
the (very unlikely) url including both a template url and a share url
will now prioritize the template url.

A best effort e2e test is included, but is a little clunky.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12387-Persist-splash-until-graph-load-completes-3666d73d3650813495e4ccad6052c1e4)
by [Unito](https://www.unito.io)
2026-05-21 19:44:12 +00:00
AustinMroz
3ce0c07af2 Use utility function to add node with V2 search (#12382)
Default search box settings are a little inconvenient to work with. This
PR introduces a new `addNode` utility function to the V2 search fixture
that handles all the steps of opening search and adding a node that a
user would perform.

It then migrates several PRs I have recently written to use this new
fixture.

See also #12029

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12382-Use-utility-function-to-add-node-with-V2-search-3666d73d3650817c8c73c9104b1113bf)
by [Unito](https://www.unito.io)
2026-05-21 19:25:26 +00:00
Terry Jia
52d77e6ee0 chore: upgrade sparkjs to 2.x and three to 0.184 (#12396)
## Summary
- Spark 2.x requires SparkRenderer in scene tree; add it in SceneManager
and protect it in clearModel so model reloads don't dispose the splat
renderer.
- three 0.184 OrbitControls listens on ownerDocument; drop redundant
pointermove/up .stop in Load3D containers so the document listener can
receive events.
- Narrow Texture.image type for 0.184 strict typing.
2026-05-21 10:06:20 -04:00
jaeone94
f1f65cff61 feat: select top asset widget FormDropdown result on Enter (#12209)
## Summary

Allow asset/media FormDropdown searches to select the top filtered
result when the user presses Enter. This covers image, video, audio,
mesh, model-like asset selects, and other `WidgetSelectDropdown`-backed
media widgets.

## Implementation Scope

This PR implements a **top-result Enter shortcut** for the custom
asset/media dropdown path only:

- In scope: `WidgetSelectDropdown` -> `FormDropdown` asset/media
widgets.
- In scope: while the dropdown is open, single-select, and the search
text is non-empty, the first current search result becomes the Enter
candidate.
- In scope: pressing Enter in the search input selects that
candidate/top result through the existing selection path.
- In scope: candidate feedback for this shortcut, including visual
candidate styling and a polite screen-reader announcement for the
current top result.
- In scope: stale async search protection, empty-query/no-result no-op
behavior, multi-select guard behavior, and focus return to the trigger
after Enter selection closes the menu.
- Out of scope: plain combo widgets (`WidgetSelectDefault` /
`SelectPlus`). That path is PrimeVue-based and should be handled
separately from this focused asset-widget PR.
- Out of scope: full combobox/listbox keyboard navigation, including
Tab-to-list focus, ArrowUp/ArrowDown candidate movement, Home/End
behavior, scroll-to-active-item behavior, and a full ARIA
combobox/listbox refactor.

Follow-up arrow-key navigation should validate the interaction model
separately. This PR keeps the candidate state narrow and localized so
that future work can either extend it into movable active-item state or
replace it as part of a fuller combobox/listbox implementation.

## Changes

- **What**: Added an explicit Enter event from `FormSearchInput`, routed
it through the FormDropdown menu actions, and selected the current top
search result in `FormDropdown`.
- **What**: Kept the existing `computedAsync` + debounced filtering path
for normal typing, while Enter performs a one-off search against the
latest input before selecting. Stale async Enter results are ignored if
the query or item source changes before resolution.
- **What**: Prevented closed FormDropdown state from treating the full
unfiltered list as current search results, limited Enter-to-select to
single-select dropdowns, and made empty search Enter a no-op.
- **What**: Returned focus to the dropdown trigger after single-select
selection closes the menu.
- **What**: Added candidate styling for the first current FormDropdown
result while a search query is active so the Enter target is visible to
users.
- **What**: Added a polite screen-reader announcement for the current
top result candidate.
- **What**: Fixed the FormDropdownMenuActions `baseModelSelected` model
default to use a `Set` factory instead of a shared instance.
- **What**: Added unit coverage for the search Enter event, FormDropdown
selection behavior, focus return, debounce/Enter behavior, stale async
Enter protection, empty-query no-op behavior, closed-state stale result
protection, multi-select guard behavior, and candidate announcement
behavior. Added App Mode E2E coverage for asset FormDropdown Enter
selection.
- **What**: Extracted reusable app-mode dropdown fixture helpers and
updated the existing FormDropdown clipping test to use the shared
helper.

## Review Focus

Please focus review on the asset/media FormDropdown path, especially
`getTopSearchResult()`, the single-select/empty-query guards, stale
async search protection, trigger focus return after selection, and
candidate feedback in grid/list layouts.

The plain combo path and full arrow-key navigation are intentionally
left for separate follow-up work.

## Screenshots (if applicable)



https://github.com/user-attachments/assets/3eb3456d-93a3-4959-91a3-188f8116ccc9



Validation performed:

- Latest final-commit validation:
- `pnpm test:unit
src/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.test.ts
src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.test.ts
src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuActions.test.ts
src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.test.ts`
- Commit hook: `pnpm exec stylelint ...`, `pnpm exec oxfmt --write ...`,
`pnpm exec oxlint --type-aware --fix ...`, `pnpm exec eslint --cache
--fix ...`, `pnpm typecheck`
  - Push hook: `pnpm knip --cache`
  - `git diff --check`
- Earlier branch validation for this flow:
  - `pnpm install`
  - `pnpm typecheck:browser`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm test:browser --
--project=chromium browser_tests/tests/appMode.spec.ts -g "Drag and
Drop|FormDropdown search Enter selects the top filtered item"
--reporter=list`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm test:browser --
--project=chromium browser_tests/tests/appMode.spec.ts -g "FormDropdown
search Enter selects the top filtered item" --reporter=list`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm test:browser --
--project=chromium browser_tests/tests/appModeDropdownClipping.spec.ts
-g "FormDropdown popup is not clipped" --reporter=list`
2026-05-21 02:26:26 +00:00
Alexander Brown
b0144db644 build: migrate pnpm config to v11 (#12195)
## Summary

Migrate pnpm configuration to the v11 layout and clean up stale v10-era
references.

## Changes

- **What**: Moves pnpm settings into `pnpm-workspace.yaml`, converts
build dependency policy to `allowBuilds`, removes stale workspace
`packageManager` pins, and updates global install commands in CI.
- **Dependencies**: No new dependencies.

## Review Focus

- Confirm pnpm v11 workspace settings match the former `.npmrc`
behavior.
- Confirm CI global install syntax is compatible with pnpm v11.

## Test Plan

- `pnpm install --frozen-lockfile`
- `pnpm exec oxfmt --check pnpm-workspace.yaml
packages/shared-frontend-utils/package.json
packages/registry-types/package.json packages/ingest-types/package.json
packages/design-system/package.json
.github/workflows/weekly-docs-check.yaml
.github/workflows/pr-claude-review.yaml`
- commit hook: `pnpm typecheck`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12195-build-migrate-pnpm-config-to-v11-35e6d73d36508116a821dbc71db94cd1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-05-21 01:53:09 +00:00
Comfy Org PR Bot
8ee8dd03c4 1.45.12 (#12393)
Patch version increment to 1.45.12

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-21 00:47:07 +00:00
Benjamin Lu
d472ca783b test: cover FE-130 assets sidebar route mocks (#12332)
## Summary

Adds a focused FE-130 assets sidebar browser-test slice without
extending the stateful asset helper path.

## Changes

- **What**: Extends `jobsRouteFixture` with job-detail and
history-delete helpers for generated asset flows.
- **What**: Adds an assets sidebar tab spec covering generated/imported
rendering, preview opening, generated selection footer actions, and
explicit delete refresh behavior.

## Review Focus

The delete test keeps backend state explicit: it captures the
`/api/history` request, then replaces the `/api/jobs` history mock with
the post-delete response. The imported-file and `/api/view` mocks stay
local to this focused spec instead of growing `AssetHelper` or adding a
sidebar-specific fixture.
2026-05-20 17:01:51 -07:00
Deep Mehta
e80ec6e3d4 feat: add model-to-node mappings for geometry_estimation and optical_flow (#12389)
## Summary

Add entries to `MODEL_NODE_MAPPINGS` so the model browser's "Use" button
creates the correct loader node for two model directories introduced in
recent node-pack updates.

## Changes

- **What**: 2 new entries in
`src/platform/assets/mappings/modelNodeMappings.ts`:
  - `geometry_estimation` → `LoadMoGeModel` / `model_name`
  - `optical_flow` → `OpticalFlowLoader` / `model_name`
- **Breaking**: none

## Review Focus

- Node class names and input keys cross-checked against the published
node definitions:
- `LoadMoGeModel` is the MoGe geometry-estimation loader (companion
nodes: `MoGeInference`, `MoGeRender`, `MoGeContextStrandModel`)
  - `OpticalFlowLoader` is the RAFT optical-flow loader
- Both directories accept a single model file via a COMBO widget on the
loader node

## Test plan

- [ ] Verify "Use" button works for each new model directory in the
model browser:
- One `geometry_estimation` model (e.g.
`moge_2_vitl_normal_fp16.safetensors`) → creates a `LoadMoGeModel` node
with the file preselected
- One `optical_flow` model → creates an `OpticalFlowLoader` node with
the file preselected

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12389-feat-add-model-to-node-mappings-for-geometry_estimation-and-optical_flow-3666d73d36508190981fcaf77f9d2ee4)
by [Unito](https://www.unito.io)
2026-05-20 23:26:40 +00:00
AustinMroz
2717d59451 Fix reactivity of vue subgraph price badges (#12029)
When a subgraph contains partner nodes with price badges, those badges
are also displayed on the subgraphNode. The reactivity here was spotty:
The price badges would fail to display unless the user had navigated
into the subgraph on the current page load. Fixing this is performed in
2 steps:
- Firing a `node:property:changed` event when the badges contained in a
subgraph are updated
- Extending the reactivity updates so that badges update in vue mode
despite using the litegraph badge getter.

This PR also includes a minor styling tweak to fix text alignment on
price badges
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/56a95cbe-12c9-43b0-8664-34e52b6415ac"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/bf4a0d81-21e4-4afc-946e-eba5967f1715"
/>|

Resolves FE-346

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12029-Fix-reactivity-of-vue-subgraph-price-badges-3586d73d3650813cb12fe265090940e4)
by [Unito](https://www.unito.io)
2026-05-20 11:22:42 -07:00
AustinMroz
d63b0f05bf Subgraph io fixes (#12281)
Fixes 3 different bugs when making links to and from subgraph IO from
vue nodes
- When dragging a link from a node to a subgraph IO, there is no
feedback if a slot is not a valid connection target or if a slot is
actively hovered
- When a link is made from a subgraph IO to a node, the reactivity is
not triggered on the node to indicate a change of link state.
- When dragging a link from a subgraph IO to a node, the link would not
snap to the valid connection targets on nodes
- The fix for this one is not as thorough as I would like. It only
allows connections to the slot, not connections to the hovered widget.
We have two deeply disconnected linking systems and properly reconciling
them would be a multi-week project.

Resolves FE-561

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12281-Subgraph-io-fixes-3606d73d365081089f7ef19331c6d70a)
by [Unito](https://www.unito.io)
2026-05-20 10:26:47 -07:00
Terry Jia
cd2f4677c2 FE-719 feat(load3d): add FBX export support (#12323)
## Summary
implement fbx export, using our own lib fbx-exporter-three

## Screenshots (if applicable)


https://github.com/user-attachments/assets/80012338-d065-4a00-a9a0-0a2e73d67db4

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12323-FE-719-feat-load3d-add-FBX-export-support-3656d73d365081ef901ffe880ae9568a)
by [Unito](https://www.unito.io)
2026-05-20 06:52:56 -04:00
Hunter
38fed22140 feat: env-var override for staging api/platform base URLs (#12221)
## Summary

Allow staging api/platform base URLs to be overridden by env vars so
non-cloud builds can target an alternate backend without source edits.

## Changes

- **What**: `BUILD_TIME_API_BASE_URL` / `BUILD_TIME_PLATFORM_BASE_URL`
in `src/config/comfyApi.ts` now read
`import.meta.env.VITE_STAGING_API_BASE_URL` /
`VITE_STAGING_PLATFORM_BASE_URL` first, falling back to the existing
`stagingapi.comfy.org` / `stagingplatform.comfy.org` constants. Vars
typed in `src/vite-env.d.ts` and documented in `.env_example`.
- **Breaking**: None. Defaults unchanged. The cloud-runtime override
path via the features endpoint (`comfy_api_base_url`,
`comfy_platform_base_url` in `RemoteConfig`) is untouched.

## Review Focus

Override only applies to the non-prod branch of the build-time ternary,
so prod builds (`USE_PROD_CONFIG=true`) cannot be redirected. Cloud
builds continue to resolve URLs at runtime via `remoteConfig` regardless
of these env vars.

## Note

Pre-commit `pnpm typecheck` fails on `origin/main` independently of this
change (`src/utils/nodeDefUtil.ts` and
`src/workbench/utils/nodeHelpUtil.ts` import non-existent exports from
`@/schemas/nodeDefSchema` / `@/types/nodeSource`). Verified by stashing
this PR's diff and re-running. Committed with `--no-verify`; please
address the underlying breakage separately.
2026-05-20 05:37:27 +00:00
AustinMroz
a95e53bf6d On subgraph conversion, always unpack group nodes (#12356)
This is a targeted small scope change to improve the availability for
converting group nodes into a subgraph.

The prior implementation would only apply on the litegraph context menu
option for converting a node to a subgraph. It failed to apply on any of
the other more common methods. The code for unpacking group nodes has
been moved directly into the setup for converting a group of nodes into
a subgraph and drastically simplified.

Of note, several other long lived bugs were found while working on this
fix, but they are out of scope for this targeted PR.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12356-On-subgraph-conversion-always-unpack-group-nodes-3666d73d365081d09774c00a851b8198)
by [Unito](https://www.unito.io)
2026-05-20 05:23:46 +00:00
AustinMroz
246b79dda9 Fix group selection selecting nodes (#12099)
Fix group selection incorrectly selecting nodes of equal id in vue mode.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12099-Fix-group-selection-selecting-nodes-35b6d73d365081e2bc73f16deb996f61)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-20 04:22:03 +00:00
Comfy Org PR Bot
7325c715c7 1.45.11 (#12349)
Patch version increment to 1.45.11

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12349-1-45-11-3666d73d3650812b899cd2c6177e9888)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-20 03:02:03 +00:00
jaeone94
98a8a614e8 fix: avoid false missing media errors after importing shared workflow assets (#12333)
## Summary

Import published media assets for shared workflows before loading the
graph so the first missing-media scan sees the user's newly imported
references instead of surfacing a false missing asset error. cc FE-773

## Changes

- **What**: Moves the shared workflow import step ahead of
`loadGraphData` for the copy-and-open flow, while still allowing the
workflow to open with a warning path if asset import fails.
- **What**: Clears the shared workflow URL intent consistently on
failure paths, including graph load failure after an import attempt, so
reloads do not repeatedly replay the same shared workflow side effects.
- **What**: Invalidates the input asset cache after published asset
import so graph loading and missing-media resolution can observe the
refreshed media state.
- **What**: Adds a global loading spinner while shared workflow asset
import and graph load are in progress, with `role="status"`,
`aria-live`, reduced-motion-safe animation, and body teleporting so it
stays visible above blocking UI.
- **What**: Adds stable TestIds for the shared workflow dialog and
updates existing shared workflow E2E selectors away from copy-dependent
role text.
- **What**: Adds a cloud E2E regression fixture and spec covering the
critical flow: shared URL opens the dialog, the user confirms asset
import, published media is imported before the public-inclusive input
asset scan, the workflow loads, the share query is removed, and missing
media UI is not surfaced.
- **Breaking**: None.
- **Dependencies**: None.

## Root Cause

Shared workflow graph loading triggered the missing-media pipeline
before the user-selected published media import had completed. Because
`include_public=true` does not include published assets, the pre-import
scan could classify shared media as missing even when the user was about
to import those assets into their own library.

## Review Focus

- The ordering in `useSharedWorkflowUrlLoader`: import published assets
first, then load the graph, while keeping import failure non-fatal for
workflow opening.
- The failure cleanup behavior: the shared URL/preserved query intent is
now cleared for graph load failures too, avoiding repeated
reload-triggered imports.
- The spinner behavior in `App.vue`: it uses the existing
`workspaceStore.spinner` boolean and intentionally keeps broader
ref-counted spinner ownership as follow-up work.
- The E2E sentinel in `sharedWorkflowMissingMedia.spec.ts`: it asserts
no public-inclusive input asset scan occurs before `/api/assets/import`,
then waits for a settling window to ensure the missing-media overlay
does not appear.

## Validation

- `pnpm format`
- `pnpm lint` (passed with existing unrelated warnings only)
- `pnpm typecheck`
- `pnpm test:unit`
- Commit hook: lint-staged formatting/linting, `pnpm typecheck`, `pnpm
typecheck:browser`
- Push hook: `pnpm knip --cache` (passed with existing tag hint only)

## Follow-Up

- Consider a ref-counted or scoped global spinner API so long-running
flows do not directly toggle `workspaceStore.spinner`.
- Consider separating shared workflow load status into orthogonal result
fields instead of encoding partial success in a single string union.
- Consider moving published asset import/cache invalidation behind an
asset-service-owned API boundary.
- Backend follow-up remains needed for `include_public=true` not
including published assets; this PR only removes the frontend false
positive when the user explicitly imports the shared media.

## Screenshots

Before 


https://github.com/user-attachments/assets/dc790046-237c-4dd8-b773-2507f9a66650

After 


https://github.com/user-attachments/assets/6517cd38-2c3d-4bfe-a990-35892b7e50ae



https://github.com/user-attachments/assets/d89dc3d3-75d9-4251-998b-0c354414e25b




┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12333-fix-avoid-false-missing-media-errors-after-importing-shared-workflow-assets-3656d73d365081b38634dcb7625cfc32)
by [Unito](https://www.unito.io)
2026-05-20 02:59:44 +00:00
Dante
95b5207c06 fix: stabilize multi-output expansion + simplify cloud output fetch (FE-227) (#12006)
## Summary

Two fixes for the cloud LoadImage form dropdown:

1. **Cloud root-cause fix** — outputs now come from a single
`getAssetsByTag('output')` call instead of walking the jobs API and
per-job `resolveOutputAssetItems` detail fetches. Per Christian's [Slack
feedback](https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1778051260476369?thread_ts=1776716352.588229&cid=C0A4XMHANP3):
*"on cloud, we can just grab the assets with a single GET, filtering
with input or output tag."* Sidebar's job-stack UX is untouched.
2. **Local / defense-in-depth** — even when the watch+expansion path is
in play (still used by local), batch all in-flight
`resolveOutputAssetItems` for the current `media` snapshot via
`Promise.all`, committing once into `resolvedByJobId`. This kills the
progressive head-shift symptom even on the legacy path.

The first attempt at (1) (`6a1a083c9`, reverted in `c175962e8`) broke
select+load on cloud prod because the dropdown wrote `asset.name` (human
filename) into the widget value, but cloud's `/api/view` resolves output
files by **`asset_hash`** (the blake3-keyed filename). Verified against
cloud prod that every output row carries `asset_hash` and that cloud's
own `preview_url` is hash-keyed, not name-keyed. Re-introduced in
`d7693377` with the dropdown value derived from `asset.asset_hash ||
asset.name`, with the human filename retained as the display label.

- Fixes FE-227

## Cloud / local divergence — what this PR clarifies

| | input | output (this PR) |
| ------------ | ---------------------------------------------- |
-------------------------------------------------------- |
| **cloud** | `getAssetsByTag('input')` (already correct) |
**`getAssetsByTag('output')` (new)** |
| **local** | `/files/input` (FS-listing) | `getHistory` + per-job
expansion (unchanged) |

Both directions are now symmetric on cloud: tag-based listing,
hash-keyed values. Local stays on the legacy path because core ComfyUI
doesn't have the assets/tags model — that's the deeper convergence
Jacob/Luke flagged in the FE-556 thread (now BE-757), which is BE/Core
work and not this PR.

## Red-Green verification

| Commit | CI: Tests Unit | Purpose |
|--------|----------------|---------|
| `3e8d42e7` test | 🔴 [failure
(25413987208)](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25413987208)
| Asserts the head of the list does not shift while one of two
multi-output jobs is still resolving. |
| `fe2608d4` fix (atomic batch) | 🟢 [success
(25414246791)](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25414246791)
| Resolutions awaited via `Promise.all` and merged in one
`resolvedByJobId` update. |
| `6a1a083c` simplification (broken) | — | First attempt — used
`asset.name`, broke select+load on cloud prod. |
| `c175962e` revert | — | Rolled back the broken simplification while
diagnosis was in flight. |
| `d7693377` simplification (fixed) | pending | Re-introduces
`useFlatOutputAssets` and uses `asset.asset_hash` for the dropdown
value. Adds 7 unit tests covering hash-as-value, name-fallback,
pagination, dedupe, and error path. |



## screenshot/ video 

### before


https://github.com/user-attachments/assets/239aa447-a260-4713-926c-04dd80a30408



### after 


https://github.com/user-attachments/assets/d68228c6-33f5-4bf0-ad24-bb83c876fdc2



## Test plan

- [x] New `useFlatOutputAssets.test.ts` — 7 tests for tag-based
fetching, pagination, dedupe, error path.
- [x] `useWidgetSelectItems.test.ts` — atomic-batching regression test +
new tests asserting hash-as-value and local name-fallback. 35 tests
pass.
- [x] `WidgetSelectDropdown.test.ts` — 5 tests pass with the new
conditional source.
- [x] CI red on test-only commit, CI green on first fix commit.
- [ ] CI green on the simplification (re-introduce) commit.
- [ ] Manual verification on cloud build: open LoadImage → switch to
Outputs → scroll → list head stays stable; select an output → LoadImage
preview loads (was broken in `6a1a083c`, restored in `d7693377`).
2026-05-20 01:33:47 +00:00
Deep Mehta
2ab1abb898 Revert "fix(cloud): stop bouncing working users to /cloud/survey mid-session" (#12344)
Reverts Comfy-Org/ComfyUI_frontend#12301

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12344-Revert-fix-cloud-stop-bouncing-working-users-to-cloud-survey-mid-session-3656d73d365081119ebad749a2e0d403)
by [Unito](https://www.unito.io)
2026-05-20 07:58:12 +09:00
Benjamin Lu
64c75bfce5 test: avoid job history double setup (#12324)
## Summary

Avoid a second `comfyPage.setup()` in the job history browser tests by
registering the initial jobs route mocks before the normal page boot.

## Changes

- **What**: Adds an `initialJobsScenario` Playwright option with an auto
fixture for the job-history spec, moves QPOV2 setup into
`initialSettings`, and keeps the sidebar helper focused on UI
navigation.
- **Dependencies**: None

## Review Focus

Confirm the auto fixture ordering matches the intended browser-test
setup: initial `/api/jobs` mocks should be installed before the
`comfyPage` fixture performs its normal setup.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12324-test-avoid-job-history-double-setup-3656d73d365081778e24c11d3b65cbef)
by [Unito](https://www.unito.io)
2026-05-19 20:09:01 +00:00
Benjamin Lu
cfd3f9e67b fix: classify PLY assets as 3D media (#12319)
## Summary

Classify `.ply` as 3D media so PLY outputs are surfaced by queue/assets
preview flows.

## Changes

- **What**: adds `.ply` to shared 3D extension detection and falls back
to the asset `/view` URL when opening 3D assets without `preview_url`.
- **Breaking**: none.
- **Dependencies**: none.

## Review Focus

- This is the tactical FE fix for FE-129; it intentionally does not
solve the broader 3D media vs load3d-loadable split.
- Assets sidebar 3D viewer still prefers `preview_url`, but now has a
usable fallback for assets that only have the normal asset URL.

FE-129

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12319-fix-classify-PLY-assets-as-3D-media-3646d73d365081218f0bde401b1601bd)
by [Unito](https://www.unito.io)
2026-05-19 08:09:02 -07:00
pythongosssss
8af8a5f0b1 test: add tests for workflow extraction util (#12218)
## Summary

Adds test coverage for workflow extraction utils

## Changes

- **What**: 
- tests!

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12218-test-add-tests-for-workflow-extraction-util-35f6d73d36508189b15accc9f724d348)
by [Unito](https://www.unito.io)
2026-05-19 12:28:17 +00:00
Dante
3b37488eee fix: keep node context menu overflow visible when content fits (#12035)
## Summary

Stops the Shape submenu (and any other PrimeVue nested submenu) from
being clipped behind the node context menu when the menu fits in the
viewport.

## Changes

- **What**: `constrainMenuHeight` in `NodeContextMenu.vue` now applies
`max-height` + `overflow-y: auto` to the root `<ul>` only when
`scrollHeight > availableHeight`. The common case keeps `overflow:
visible`.
- Added `browser_tests/tests/nodeContextMenuShapeSubmenu.spec.ts`
regression spec.

## Review Focus

Root cause: setting only `overflow-y: auto` on a `<ul>` coerces
`overflow-x` to a non-visible value per CSS spec (`If one of
overflow-x/overflow-y is visible and the other isn't, the visible value
is computed as auto`). PrimeVue `ContextMenuSub` renders submenus
in-tree as a nested `<ul>` with `position: absolute; left: 100%`, so the
implicit horizontal clip hides them entirely.

The pre-existing overflow scenario (#10824 / #10854) is unchanged — when
the menu actually overflows, the clamp still applies and
`nodeContextMenuOverflow.spec.ts` continues to verify scroll. Submenu
clipping in that overflow case is a known limitation, not introduced by
this PR.

Fixes FE-570


## screenshot

### AS IS
<img width="788" height="505" alt="Screenshot 2026-05-07 at 12 43 26 PM"
src="https://github.com/user-attachments/assets/36d34070-0c57-4385-a130-0394f22f282e"
/>


### TO BE

<img width="779" height="627" alt="Screenshot 2026-05-07 at 12 42 44 PM"
src="https://github.com/user-attachments/assets/00956729-763b-4787-822f-209e8ea42331"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12035-fix-keep-node-context-menu-overflow-visible-when-content-fits-3586d73d365081ad9aaec82f220d401c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-05-19 10:56:41 +00:00
Comfy Org PR Bot
42dcb1cf7b 1.45.10 (#12321)
Patch version increment to 1.45.10

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12321-1-45-10-3656d73d365081c684e2c76ce92e2ff8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-19 08:29:48 +00:00
259 changed files with 20957 additions and 5248 deletions

View File

@@ -41,6 +41,10 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# Enable PostHog debug logging in the browser console.
# VITE_POSTHOG_DEBUG=true
# Override staging comfy-api / comfy-platform base URLs.
# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org
# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org
# Sentry ENV vars replace with real ones for debugging
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org

View File

@@ -45,12 +45,8 @@ jobs:
path: dist/
retention-days: 1
# Build cloud distribution for @cloud tagged tests
# NX_SKIP_NX_CACHE=true is required because `nx build` was already run
# for the OSS distribution above. Without skipping cache, Nx returns
# the cached OSS build since env vars aren't part of the cache key.
- name: Build cloud frontend
run: NX_SKIP_NX_CACHE=true pnpm build:cloud
run: pnpm build:cloud
- name: Upload cloud frontend
uses: actions/upload-artifact@v6

View File

@@ -39,7 +39,7 @@ jobs:
- name: Install dependencies for analysis tools
run: |
pnpm install -g typescript @vue/compiler-sfc
pnpm add -g typescript @vue/compiler-sfc
- name: Run Claude PR Review
uses: anthropics/claude-code-action@ff34ce0ff04a470bd3fa56c1ef391c8f1c19f8e9 # v1.0.38

View File

@@ -59,7 +59,7 @@ jobs:
pnpm zipdist ./dist ./dist-desktop.zip
# Default release artifact for core/PyPI.
NX_SKIP_NX_CACHE=true pnpm build
pnpm build
pnpm zipdist
- name: Upload dist artifact
uses: actions/upload-artifact@v6

View File

@@ -40,11 +40,11 @@ jobs:
- name: Install dependencies for analysis tools
run: |
# Check if packages are already available locally
if ! pnpm list typescript @vue/compiler-sfc >/dev/null 2>&1; then
if ! pnpm list -g typescript @vue/compiler-sfc >/dev/null 2>&1; then
echo "Installing TypeScript and Vue compiler globally..."
pnpm install -g typescript @vue/compiler-sfc
pnpm add -g typescript @vue/compiler-sfc
else
echo "TypeScript and Vue compiler already available locally"
echo "TypeScript and Vue compiler already available globally"
fi
- name: Run Claude Documentation Review

5
.gitignore vendored
View File

@@ -19,6 +19,7 @@ yarn.lock
node_modules
.pnpm-store
.nx
dist
dist-ssr
*.local
@@ -89,10 +90,6 @@ storybook-static
# MCP Servers
.playwright-mcp/*
.nx/cache
.nx/workspace-data
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
vite.config.*.timestamp*
vitest.config.*.timestamp*

3
.npmrc
View File

@@ -1,3 +0,0 @@
ignore-workspace-root-check=true
catalog-mode=prefer
public-hoist-pattern[]=@parcel/watcher

View File

@@ -1 +0,0 @@
.claude/worktrees

View File

@@ -2,7 +2,6 @@
"$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": [
".i18nrc.cjs",
".nx/*",
"**/vite.config.*.timestamp*",
"**/vitest.config.*.timestamp*",
"components.d.ts",

View File

@@ -35,7 +35,7 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
## Monorepo Architecture
The project uses **Nx** for build orchestration and task management
The project uses **pnpm workspaces** for monorepo organization and native tool CLIs for task execution
## Package Manager
@@ -237,7 +237,6 @@ See @docs/testing/\*.md for detailed patterns.
- ComfyUI: <https://docs.comfy.org>
- Electron: <https://www.electronjs.org/docs/latest/>
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
- Nx: <https://nx.dev/docs/reference/nx-commands>
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
## Architecture Decision Records

View File

@@ -7,7 +7,7 @@ This guide helps you resolve common issues when developing ComfyUI Frontend.
```mermaid
flowchart TD
A[Having Issues?] --> B{What's the problem?}
B -->|Dev server stuck| C[nx serve hangs]
B -->|Dev server stuck| C[pnpm dev hangs]
B -->|Build errors| D[Check build issues]
B -->|Lint errors| Q[Check linting issues]
B -->|Dependency issues| E[Package problems]
@@ -23,7 +23,7 @@ flowchart TD
G -->|No| H[Run: pnpm i]
G -->|Still stuck| I[Run: pnpm clean]
I --> J{Still stuck?}
J -->|Yes| K[Nuclear option:<br/>pnpm dlx rimraf node_modules<br/>&& pnpm i]
J -->|Yes| K[Nuclear option:<br/>pnpm clean:all<br/>&& pnpm i]
J -->|No| L[Fixed!]
H --> L
@@ -41,11 +41,11 @@ flowchart TD
### Development Server Issues
#### Q: `pnpm dev` or `nx serve` gets stuck and won't start
#### Q: `pnpm dev` gets stuck and won't start
**Symptoms:**
- Command hangs on "nx serve"
- Command hangs during Vite startup
- Dev server doesn't respond
- Terminal appears frozen
@@ -65,7 +65,7 @@ flowchart TD
3. **Last resort - Full node_modules reset:**
```bash
pnpm dlx rimraf node_modules && pnpm i
pnpm clean:all && pnpm i
```
**Why this happens:**
@@ -73,7 +73,7 @@ flowchart TD
- Corrupted dependency cache
- Outdated lock files after branch switching
- Incomplete previous installations
- NX cache corruption
- stale local build cache
---

View File

@@ -3,8 +3,11 @@
"version": "0.0.6",
"type": "module",
"scripts": {
"lint": "nx run @comfyorg/desktop-ui:lint",
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
"dev": "pnpm -w exec vite --config apps/desktop-ui/vite.config.mts",
"build": "pnpm -w exec vite build --config apps/desktop-ui/vite.config.mts",
"preview": "pnpm -w exec vite preview --config apps/desktop-ui/vite.config.mts",
"lint": "eslint src --cache",
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
"test:unit": "vitest run --config vitest.config.mts",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build -o dist/storybook"
@@ -33,88 +36,5 @@
"vite-plugin-html": "catalog:",
"vite-plugin-vue-devtools": "catalog:",
"vue-tsc": "catalog:"
},
"nx": {
"tags": [
"scope:desktop",
"type:app"
],
"targets": {
"dev": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "vite --config vite.config.mts"
}
},
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "vite --config vite.config.mts"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"^build"
],
"options": {
"command": "vite build --config apps/desktop-ui/vite.config.mts"
},
"outputs": [
"{projectRoot}/dist"
]
},
"preview": {
"executor": "nx:run-commands",
"continuous": true,
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/desktop-ui",
"command": "vite preview --config vite.config.mts"
}
},
"storybook": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "storybook dev -p 6007"
}
},
"build-storybook": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "storybook build -o dist/storybook"
},
"outputs": [
"{projectRoot}/dist/storybook"
]
},
"lint": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "eslint src --cache"
}
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "vue-tsc --noEmit -p tsconfig.json"
}
}
}
}
}

View File

@@ -45,88 +45,5 @@
"tsx": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"nx": {
"tags": [
"scope:website",
"type:app"
],
"targets": {
"dev": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"^build"
],
"options": {
"cwd": "apps/website",
"command": "astro build"
},
"outputs": [
"{projectRoot}/dist"
]
},
"preview": {
"executor": "nx:run-commands",
"continuous": true,
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "astro preview"
}
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "astro check"
}
},
"test:unit": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run"
}
},
"test:coverage": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run --coverage"
}
},
"test:e2e": {
"executor": "nx:run-commands",
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "playwright test"
}
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<GlassCard
class="mx-auto mt-20 flex flex-col gap-12 lg:flex-row lg:items-stretch lg:gap-8"
>

View File

@@ -74,7 +74,7 @@ useHeroAnimation({
</div>
<!-- Video -->
<div ref="videoRef" class="px-4 pb-20 lg:px-20 lg:pb-40">
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 lg:px-20 lg:pb-40">
<VideoPlayer
src="https://media.comfy.org/website/about/co-founders.webm"
poster="https://media.comfy.org/website/about/co-founders-poster.webp"

View File

@@ -33,7 +33,7 @@ const values: {
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<div class="mx-auto max-w-5xl text-center">
<SectionLabel>
{{ t('about.values.label', locale) }}

View File

@@ -16,7 +16,7 @@ const investors = [
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<div class="mx-auto text-center">
<span
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"

View File

@@ -14,7 +14,7 @@ const reasons: TranslationKey[] = [
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<WireNodeLayout :reasons right-card-padding="p-6" :locale="locale">
<template #right-card>
<img

View File

@@ -41,7 +41,7 @@ function toggle(index: number) {
</script>
<template>
<section class="px-4 py-24 md:px-20 md:py-40">
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div

View File

@@ -46,7 +46,9 @@ const cards = excludeProduct
</script>
<template>
<section class="bg-primary-comfy-ink px-0 py-20 lg:px-20 lg:py-24">
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-0 py-20 lg:px-20 lg:py-24"
>
<!-- Header -->
<div class="flex flex-col items-center px-4 text-center">
<SectionLabel v-if="labelKey">

View File

@@ -45,11 +45,11 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
</script>
<template>
<section class="px-6 py-16 lg:px-16 lg:py-24">
<section class="max-w-9xl mx-auto px-6 py-16 lg:px-16 lg:py-24">
<!-- Scrollable track -->
<div
ref="trackRef"
class="scrollbar-none flex snap-x snap-mandatory gap-12 overflow-x-auto lg:gap-20"
class="flex snap-x snap-mandatory scrollbar-none gap-12 overflow-x-auto lg:gap-20"
>
<div
v-for="(fb, i) in feedbacks"

View File

@@ -72,7 +72,7 @@ function handleLogoLoad() {
</div>
<!-- Video -->
<div ref="videoRef" class="px-4 pb-20 lg:px-20 lg:pb-40">
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 lg:px-20 lg:pb-40">
<VideoPlayer
src="https://media.comfy.org/website/customers/blackmath/video.webm"
poster="https://media.comfy.org/website/customers/blackmath/poster.webp"

View File

@@ -10,7 +10,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
<template>
<section
class="grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
class="max-w-9xl mx-auto grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
>
<a
v-for="story in customerStories"

View File

@@ -7,7 +7,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="px-6 py-16 lg:px-20 lg:py-40">
<section class="max-w-9xl mx-auto px-6 py-16 lg:px-20 lg:py-40">
<VideoPlayer
src="https://media.comfy.org/website/customers/silverside/video.webm"
poster="https://media.comfy.org/website/customers/silverside/poster.webp"

View File

@@ -7,7 +7,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<template>
<section
class="flex flex-col items-center px-4 pt-16 pb-24 text-center lg:px-20 lg:pt-20 lg:pb-40"
class="max-w-9xl mx-auto flex flex-col items-center px-4 pt-16 pb-24 text-center lg:px-20 lg:pt-20 lg:pb-40"
>
<span
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"

View File

@@ -223,7 +223,10 @@ while (idx < items.length) {
</script>
<template>
<section data-testid="gallery-grid" class="px-4 pb-20 lg:px-20">
<section
data-testid="gallery-grid"
class="max-w-9xl mx-auto px-4 pb-20 lg:px-20"
>
<!-- Desktop grid -->
<div
class="rounded-5xl bg-transparency-white-t4 hidden flex-col gap-2 p-2 lg:flex"

View File

@@ -8,7 +8,9 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="flex flex-col items-center px-6 pt-36 pb-16 text-center">
<section
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-36 pb-16 text-center"
>
<SectionLabel>
{{ t('gallery.label', locale) }}
</SectionLabel>

View File

@@ -15,7 +15,7 @@ const row2 = [
<template>
<section
class="bg-primary-comfy-ink flex flex-col items-center px-4 py-24 lg:px-6 lg:py-32"
class="bg-primary-comfy-ink max-w-9xl mx-auto flex flex-col items-center px-4 py-24 lg:px-6 lg:py-32"
>
<!-- Node rows -->
<div

View File

@@ -12,7 +12,9 @@ const routes = getRoutes(locale)
</script>
<template>
<section class="bg-primary-comfy-ink px-4 py-20 lg:px-20 lg:py-24">
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-20 lg:px-20 lg:py-24"
>
<GlassCard
class="flex flex-col gap-12 lg:flex-row lg:items-stretch lg:gap-8"
>

View File

@@ -36,7 +36,9 @@ const steps = [
</script>
<template>
<section class="bg-primary-comfy-ink px-4 py-20 lg:px-20 lg:py-24">
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-20 lg:px-20 lg:py-24"
>
<div class="flex flex-col gap-12 lg:flex-row lg:gap-8">
<!-- Left heading -->
<div

View File

@@ -15,7 +15,7 @@ const { loaded: logoLoaded } = useHeroLogo(logoContainer)
<template>
<section
class="relative flex min-h-auto flex-col lg:flex-row lg:items-center"
class="max-w-9xl relative mx-auto flex min-h-auto flex-col lg:flex-row lg:items-center"
>
<div
ref="logoContainer"

View File

@@ -55,7 +55,10 @@ watch(activeIndex, (current, previous) => {
</script>
<template>
<section ref="sectionRef" class="px-4 py-20 lg:px-20 lg:py-24">
<section
ref="sectionRef"
class="max-w-9xl mx-auto px-4 py-20 lg:px-20 lg:py-24"
>
<!-- Section header -->
<div class="flex flex-col items-center text-center">
<NodeBadge :segments="badgeSegments" segment-class="" />

View File

@@ -0,0 +1,136 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import type { GalleryItem } from '../gallery/GallerySection.vue'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import GalleryCard from '../gallery/GalleryCard.vue'
import GalleryDetailModal from '../gallery/GalleryDetailModal.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const modelName = 'Grok'
const ctaHref = 'https://comfy.org/workflows/model/grok'
const items: GalleryItem[] = [
{
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
title: 'Until Our Eye Interlink harajuku',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'Grok Imagine',
href: 'https://www.thinkdiffusion.com/studio#success-stories-anta'
},
{
image: 'https://media.comfy.org/website/gallery/gallery.webp',
title: 'Amber Astronaut',
userAlias: 'Yogo',
teamAlias: '',
tool: 'Grok Imagine',
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
},
{
video: 'https://media.comfy.org/videos/compressed_512/cigarette.webm',
title: 'Autopoiesis',
userAlias: 'Yogo',
teamAlias: 'Visual Frisson',
tool: 'Grok Imagine',
href: 'https://www.instagram.com/visualfrisson/?hl=en'
},
{
video: 'https://media.comfy.org/videos/compressed_512/kyrie.webm',
title: 'Origins',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'Grok Imagine',
href: 'https://vimeo.com/1021360563'
},
{
image: 'https://media.comfy.org/website/gallery/desert.webp',
title: 'Desert Landing',
userAlias: 'Yogo',
teamAlias: '',
tool: 'Grok Imagine',
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
}
]
const modalOpen = ref(false)
const modalIndex = ref(0)
function openDetail(index: number) {
modalIndex.value = index
modalOpen.value = true
}
const title = t('models.list.creations.title', locale).replace(
'{name}',
modelName
)
const ctaLabel = t('models.list.creations.cta', locale)
</script>
<template>
<section
data-testid="model-creations"
class="flex flex-col items-center px-4 pt-12 pb-20 lg:px-20"
>
<h2
class="text-primary-comfy-canvas max-w-4xl text-center text-3xl font-light tracking-tight lg:text-5xl"
>
{{ title }}
</h2>
<BrandButton
:href="ctaHref"
variant="solid"
size="lg"
class="mt-16 px-8 py-4 uppercase"
>
{{ ctaLabel }}
</BrandButton>
<div class="mt-20 hidden w-full flex-col gap-2 lg:flex">
<div class="grid grid-cols-2 gap-2">
<GalleryCard
v-for="(item, i) in items.slice(0, 2)"
:key="i"
:item
:locale
@click="openDetail(i)"
/>
</div>
<div v-if="items.length > 2" class="grid grid-cols-3 gap-2">
<GalleryCard
v-for="(item, i) in items.slice(2, 5)"
:key="i + 2"
:item
:locale
@click="openDetail(i + 2)"
/>
</div>
</div>
<div
class="rounded-5xl bg-transparency-white-t4 mt-12 flex w-full flex-col gap-6 p-2 lg:hidden"
>
<GalleryCard
v-for="(item, i) in items"
:key="i"
:item
:locale
mobile
@click="openDetail(i)"
/>
</div>
<GalleryDetailModal
v-if="modalOpen"
:items
:initial-index="modalIndex"
:locale
@close="modalOpen = false"
/>
</section>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const {
locale = 'en',
modelName,
ctaHref,
mediaSrc,
mediaAlt = ''
} = defineProps<{
locale?: Locale
modelName: string
ctaHref: string
mediaSrc: string
mediaAlt?: string
}>()
</script>
<template>
<section class="flex flex-col items-center px-6 pt-36 pb-16 text-center">
<h1
class="text-primary-comfy-canvas max-w-4xl text-4xl font-light tracking-tight lg:text-6xl"
>
{{ modelName }} in <span class="text-primary-comfy-yellow">ComfyUI</span>
</h1>
<p
class="text-primary-comfy-canvas mt-6 max-w-2xl text-sm text-pretty lg:text-base"
>
{{ t('hero.subtitle', locale) }}
</p>
<BrandButton
:href="ctaHref"
variant="solid"
size="lg"
class="mt-10 px-8 py-4 uppercase"
>
{{ t('models.list.heroCta', locale).replace('{name}', modelName) }}
</BrandButton>
<div class="mt-16 w-full max-w-5xl">
<img
:src="mediaSrc"
:alt="mediaAlt"
class="rounded-4.5xl size-full object-cover"
/>
</div>
</section>
</template>

View File

@@ -121,7 +121,7 @@ const activePlanIndex = ref(0)
</script>
<template>
<section class="px-4 py-16 lg:px-20 lg:py-14">
<section class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-14">
<!-- Header -->
<div class="mx-auto mb-8 max-w-3xl text-center lg:mb-10">
<h1
@@ -135,7 +135,7 @@ const activePlanIndex = ref(0)
</div>
<!-- Mobile plan tabs -->
<div class="scrollbar-none mb-6 flex gap-2 overflow-x-auto lg:hidden">
<div class="mb-6 flex scrollbar-none gap-2 overflow-x-auto lg:hidden">
<button
v-for="(plan, index) in plans"
:key="plan.id"

View File

@@ -60,7 +60,7 @@ const features: IncludedFeature[] = [
</script>
<template>
<section class="px-4 py-16 lg:px-20 lg:py-24">
<section class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-24">
<div class="mx-auto w-full lg:grid lg:grid-cols-[280px_1fr] lg:gap-x-16">
<!-- Heading -->
<div

View File

@@ -25,7 +25,7 @@ const cards = [
</script>
<template>
<section class="px-4 pt-24 lg:px-20 lg:pt-40">
<section class="max-w-9xl mx-auto px-4 pt-24 lg:px-20 lg:pt-40">
<h2
class="text-primary-comfy-canvas text-3.5xl/tight mx-auto max-w-3xl text-center font-light lg:text-5xl/tight"
>

View File

@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<template>
<section
class="bg-transparency-white-t4 rounded-5xl mx-4 mt-4 mb-24 p-2 lg:mx-20 lg:mt-8 lg:mb-40"
class="bg-transparency-white-t4 rounded-5xl max-w-9xl mx-auto mt-4 mb-24 p-2 px-4 lg:mt-8 lg:mb-40 lg:px-20"
>
<div
class="bg-primary-comfy-yellow flex flex-col gap-24 rounded-4xl p-8 lg:flex-row lg:items-end lg:justify-between"

View File

@@ -442,7 +442,7 @@ onBeforeUnmount(() => {
</script>
<template>
<section class="px-4 py-24 lg:px-20">
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
<GlassCard
class="flex flex-col gap-8 lg:flex-row lg:items-stretch lg:gap-16"
>

View File

@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="px-4 py-24 lg:px-20 lg:py-40">
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40">
<div
class="bg-transparency-white-t4 rounded-5xl flex flex-col-reverse items-stretch gap-10 p-2 lg:flex-row lg:gap-8"
>

View File

@@ -77,7 +77,9 @@ function getCardClass(layoutClass: string): string {
</script>
<template>
<section class="bg-primary-comfy-ink px-4 py-24 lg:px-20 lg:py-40">
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40"
>
<div class="mx-auto flex w-full max-w-7xl flex-col items-center">
<p
class="text-primary-comfy-yellow text-center text-sm font-bold tracking-widest uppercase"

View File

@@ -11,7 +11,7 @@ defineProps<{
</script>
<template>
<section class="px-4 py-24 lg:px-20">
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
<SectionHeader>
{{ heading }}
<template v-if="subtitle" #subtitle>

View File

@@ -22,7 +22,7 @@ defineProps<{
</script>
<template>
<section class="px-4 py-24 lg:px-20">
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
<SectionHeader>
{{ heading }}
<template #subtitle>

View File

@@ -29,7 +29,7 @@ const {
<template>
<section
class="flex flex-col gap-4 px-4 py-24 lg:flex-row lg:gap-16 lg:px-20 lg:py-40"
class="max-w-9xl mx-auto flex flex-col gap-4 px-4 py-24 lg:flex-row lg:gap-16 lg:px-20 lg:py-40"
>
<!-- Left heading -->
<div

View File

@@ -1,5 +1,5 @@
{
"fetchedAt": "2026-05-12T16:10:34.114Z",
"fetchedAt": "2026-05-22T00:07:48.353Z",
"departments": [
{
"name": "DESIGN",
@@ -36,14 +36,14 @@
"id": "6a6d865eeb3c10a8",
"title": "Senior Software Engineer, Frontend",
"department": "Engineering",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2"
},
{
"id": "1b4f7f1da9616e14",
"title": "Senior Software Engineer, Backend Generalist",
"department": "Engineering",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e"
},
{
@@ -71,14 +71,14 @@
"id": "91604c4182a1bc3c",
"title": "Software Engineer, Core ComfyUI Contributor",
"department": "Engineering",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f"
},
{
"id": "a1dbc0576ab14034",
"title": "Software Engineer, ComfyUI Desktop",
"department": "Engineering",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0"
},
{
@@ -105,21 +105,21 @@
"id": "23dd98cab77ff459",
"title": "Freelance Motion Designer",
"department": "Marketing",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b"
},
{
"id": "a998b9fc973ff3c0",
"title": "Creative Artist",
"department": "Marketing",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d"
},
{
"id": "3e730938026d6e70",
"title": "Graphic Designer",
"department": "Marketing",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f"
},
{
@@ -135,6 +135,20 @@
"department": "Marketing",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c"
},
{
"id": "e11f8b9e58dbea81",
"title": "Creative Producer",
"department": "Marketing",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7be2d690-7a2b-4ebf-b1c4-6907b273d3d9"
},
{
"id": "6eac654593208ec3",
"title": "Forward Deployed Creative Technologist",
"department": "Marketing",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/af49c05f-dcd8-4c3d-a464-43eb3b1c6efc"
}
]
},

File diff suppressed because one or more lines are too long

View File

@@ -4194,6 +4194,76 @@ const translations = {
'zh-CN': '支持的模型'
},
// Models list page (/models)
'models.list.label': { en: 'MODELS', 'zh-CN': '模型' },
'models.list.heroCta': {
en: 'Try {name} Now',
'zh-CN': '立即试用 {name}'
},
'models.list.creations.title': {
en: '{name} Image and Video Creations',
'zh-CN': '{name} 图像与视频创作'
},
'models.list.creations.cta': {
en: 'Explore Workflows',
'zh-CN': '探索工作流'
},
'models.list.heroTitle.before': {
en: 'Run the worlds leading AI models in',
'zh-CN': '在以下平台运行世界领先的 AI 模型'
},
'models.list.heroSubtitle': {
en: 'From open-source diffusion checkpoints to partner APIs — every major model, with community workflow templates ready to run.',
'zh-CN':
'从开源扩散模型到合作伙伴 API涵盖每一个主流模型并附带可直接运行的社区工作流模板。'
},
'models.list.card.workflows': {
en: '{count} workflows',
'zh-CN': '{count} 个工作流'
},
'models.list.contact.label': {
en: 'COMFY HUB',
'zh-CN': 'COMFY HUB'
},
'models.showcase.label': { en: 'AI MODELS', 'zh-CN': 'AI 模型' },
'models.showcase.heading': {
en: 'Run the worlds\nleading AI models',
'zh-CN': '运行全球领先的\nAI 模型'
},
'models.showcase.subtitle': {
en: 'New models are added as they launch.',
'zh-CN': '新模型发布后会第一时间上线。'
},
'models.showcase.cta': {
en: 'EXPLORE WORKFLOWS',
'zh-CN': '探索工作流'
},
'models.showcase.card.grokImagine': {
en: 'Grok Imagine',
'zh-CN': 'Grok Imagine'
},
'models.showcase.card.nanoBananaPro': {
en: 'Nano Banana Pro',
'zh-CN': 'Nano Banana Pro'
},
'models.showcase.card.ltx23': {
en: 'LTX 2.3',
'zh-CN': 'LTX 2.3'
},
'models.showcase.card.qwenAdvancedEdit': {
en: 'Advanced image\nediting with Qwen',
'zh-CN': '使用 Qwen 进行\n高级图像编辑'
},
'models.showcase.card.wan22TextToVideo': {
en: 'Wan 2.2\ntext to video',
'zh-CN': 'Wan 2.2\n文字转视频'
},
'models.list.contact.heading': {
en: 'Pick a model and explore what the community has built. <a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Browse Comfy Hub</a> for the newest workflows.',
'zh-CN':
'选择一个模型,浏览社区的创作成果。<a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">访问 Comfy Hub</a> 查看最新工作流。'
},
// Payment status pages
'payment.success.label': {
en: 'PAYMENT',

View File

@@ -72,6 +72,9 @@ const websiteJsonLd = {
<title>{title}</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.png" type="image/png" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="canonical" href={canonicalURL.href} />
<link rel="preconnect" href="https://www.googletagmanager.com" />
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />

View File

@@ -0,0 +1,22 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import ModelsHeroSection from '../components/models/ModelsHeroSection.vue'
import ModelCreationsSection from '../components/models/ModelCreationsSection.vue'
import AIModelsSection from '../components/product/shared/AIModelsSection.vue'
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
---
<BaseLayout
title="Models — Comfy"
description="Run the world's leading AI models in ComfyUI. Browse every supported model with community workflow templates ready to run."
>
<ModelsHeroSection
modelName="Grok Imagine"
ctaHref="/p/supported-models/grok-image"
mediaSrc="https://media.comfy.org/website/gallery/gallery.webp"
mediaAlt="Grok Imagine output created with ComfyUI"
/>
<ModelCreationsSection client:load />
<AIModelsSection client:load />
<ProductShowcaseSection client:load />
</BaseLayout>

View File

@@ -0,0 +1,23 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ModelsHeroSection from '../../components/models/ModelsHeroSection.vue'
import ModelCreationsSection from '../../components/models/ModelCreationsSection.vue'
import AIModelsSection from '../../components/product/shared/AIModelsSection.vue'
import ProductShowcaseSection from '../../components/home/ProductShowcaseSection.vue'
---
<BaseLayout
title="模型 — Comfy"
description="在 ComfyUI 中运行世界领先的 AI 模型。浏览所有支持的模型及社区工作流模板。"
>
<ModelsHeroSection
locale="zh-CN"
modelName="Grok Imagine"
ctaHref="/zh-CN/p/supported-models/grok-image"
mediaSrc="https://media.comfy.org/website/gallery/gallery.webp"
mediaAlt="Grok Imagine output created with ComfyUI"
/>
<ModelCreationsSection client:load locale="zh-CN" />
<AIModelsSection client:load locale="zh-CN" />
<ProductShowcaseSection client:load locale="zh-CN" />
</BaseLayout>

View File

@@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
import {
DEFAULT_REGISTRY_BASE_URL,
fetchRegistryPacks
fetchRegistryPacks,
fetchRegistryPacksWithNodes
} from './cloudNodes.registry'
function jsonResponse(
@@ -142,3 +143,315 @@ describe('fetchRegistryPacks', () => {
expect(result.size).toBe(0)
})
})
describe('fetchRegistryPacksWithNodes', () => {
it('fetches pack metadata and comfy nodes for each pack', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
// Pack metadata request
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack',
latest_version: { version: '8.0.0', createdAt: '2026-01-01' }
}
]
})
}
// Comfy nodes request
if (url.pathname.includes('/comfy-nodes')) {
return jsonResponse({
comfy_nodes: [
{ comfy_node_name: 'FaceDetailer', category: 'detailer' },
{ comfy_node_name: 'DetailerForEach', category: 'detailer' }
],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['comfyui-impact-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(result.size).toBe(1)
const packData = result.get('comfyui-impact-pack')
expect(packData).not.toBeNull()
expect(packData?.pack.name).toBe('ComfyUI Impact Pack')
expect(packData?.nodes).toHaveLength(2)
expect(packData?.nodes[0]?.comfy_node_name).toBe('FaceDetailer')
})
it('handles pagination for comfy nodes', async () => {
let comfyNodesCallCount = 0
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'big-pack',
name: 'Big Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
comfyNodesCallCount++
const page = Number(url.searchParams.get('page') ?? '1')
if (page === 1) {
return jsonResponse({
comfy_nodes: [
{ comfy_node_name: 'Node1', category: 'cat1' },
{ comfy_node_name: 'Node2', category: 'cat1' }
],
totalNumberOfPages: 2
})
} else {
return jsonResponse({
comfy_nodes: [{ comfy_node_name: 'Node3', category: 'cat2' }],
totalNumberOfPages: 2
})
}
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['big-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(comfyNodesCallCount).toBe(2)
const packData = result.get('big-pack')
expect(packData?.nodes).toHaveLength(3)
})
it('returns null for packs without latest_version', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'no-version-pack',
name: 'No Version Pack',
latest_version: null
}
]
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['no-version-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(result.get('no-version-pack')).toBeNull()
})
it('returns empty nodes array when comfy-nodes request fails', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'failing-pack',
name: 'Failing Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
return new Response('Server error', { status: 500 })
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['failing-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
const packData = result.get('failing-pack')
expect(packData).not.toBeNull()
expect(packData?.pack.name).toBe('Failing Pack')
expect(packData?.nodes).toHaveLength(0)
})
it('handles null comfy_nodes in response', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'null-nodes-pack',
name: 'Null Nodes Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
return jsonResponse({
comfy_nodes: null,
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['null-nodes-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
const packData = result.get('null-nodes-pack')
expect(packData?.nodes).toHaveLength(0)
})
it('fetches nodes for multiple packs in parallel', async () => {
const packIds = ['pack-a', 'pack-b', 'pack-c']
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
const requestedIds = url.searchParams.getAll('node_id')
return jsonResponse({
nodes: requestedIds.map((id) => ({
id,
name: id.toUpperCase(),
latest_version: { version: '1.0.0' }
}))
})
}
if (url.pathname.includes('/comfy-nodes')) {
const packId = url.pathname.split('/nodes/')[1]?.split('/')[0]
return jsonResponse({
comfy_nodes: [
{ comfy_node_name: `${packId}-node`, category: 'test' }
],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(packIds, {
fetchImpl: fetchImpl as typeof fetch
})
expect(result.size).toBe(3)
for (const packId of packIds) {
const packData = result.get(packId)
expect(packData).not.toBeNull()
expect(packData?.nodes[0]?.comfy_node_name).toBe(`${packId}-node`)
}
})
it('retries comfy-nodes fetch once on failure', async () => {
let comfyNodesAttempts = 0
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'retry-pack',
name: 'Retry Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
comfyNodesAttempts++
if (comfyNodesAttempts === 1) {
return new Response('Server error', { status: 500 })
}
return jsonResponse({
comfy_nodes: [{ comfy_node_name: 'RetryNode', category: 'test' }],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['retry-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(comfyNodesAttempts).toBe(2)
const packData = result.get('retry-pack')
expect(packData?.nodes).toHaveLength(1)
expect(packData?.nodes[0]?.comfy_node_name).toBe('RetryNode')
})
it('normalizes null boolean fields in comfy nodes', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'bool-pack',
name: 'Bool Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
return jsonResponse({
comfy_nodes: [
{
comfy_node_name: 'TestNode',
category: 'test',
deprecated: null,
experimental: null
}
],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['bool-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
const packData = result.get('bool-pack')
expect(packData?.nodes[0]?.deprecated).toBeUndefined()
expect(packData?.nodes[0]?.experimental).toBeUndefined()
})
})

View File

@@ -5,8 +5,10 @@ import type { components } from '@comfyorg/registry-types'
export const DEFAULT_REGISTRY_BASE_URL = 'https://api.comfy.org'
const DEFAULT_TIMEOUT_MS = 5_000
const BATCH_SIZE = 50
const COMFY_NODES_PAGE_SIZE = 500
export type RegistryPack = components['schemas']['Node']
export type RegistryComfyNode = components['schemas']['ComfyNode']
function nullToUndefined<T>(value: T | null | undefined): T | undefined {
return value ?? undefined
@@ -58,6 +60,29 @@ const RegistryListResponseSchema = z
})
.passthrough()
const RegistryComfyNodeSchema = z
.object({
comfy_node_name: optionalString,
category: optionalString,
description: optionalString,
deprecated: z
.boolean()
.nullish()
.transform((v) => v ?? undefined),
experimental: z
.boolean()
.nullish()
.transform((v) => v ?? undefined)
})
.passthrough()
const RegistryComfyNodesResponseSchema = z
.object({
comfy_nodes: z.array(RegistryComfyNodeSchema).nullish(),
totalNumberOfPages: z.number().nullish()
})
.passthrough()
interface FetchRegistryOptions {
baseUrl?: string
timeoutMs?: number
@@ -122,6 +147,142 @@ export async function fetchRegistryPacks(
return resolved
}
export interface RegistryPackWithNodes {
pack: RegistryPack
nodes: RegistryComfyNode[]
}
export async function fetchRegistryPacksWithNodes(
packIds: readonly string[],
options: FetchRegistryOptions = {}
): Promise<Map<string, RegistryPackWithNodes | null>> {
const packs = await fetchRegistryPacks(packIds, options)
const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_BASE_URL
const timeoutMs = clampTimeoutMs(options.timeoutMs)
const fetchImpl = options.fetchImpl ?? fetch
const entries = await Promise.all(
[...packs.entries()].map(
async ([packId, pack]): Promise<
[string, RegistryPackWithNodes | null]
> => {
if (!pack?.latest_version?.version) {
return [packId, null]
}
const nodes = await fetchComfyNodesForPack(
fetchImpl,
baseUrl,
packId,
pack.latest_version.version,
timeoutMs
)
return [packId, { pack, nodes }]
}
)
)
return new Map(entries)
}
async function fetchComfyNodesForPack(
fetchImpl: typeof fetch,
baseUrl: string,
packId: string,
version: string,
timeoutMs: number
): Promise<RegistryComfyNode[]> {
const allNodes: RegistryComfyNode[] = []
let page = 1
let totalPages = 1
while (page <= totalPages) {
const result = await fetchComfyNodesPageWithRetry(
fetchImpl,
baseUrl,
packId,
version,
page,
timeoutMs
)
if (!result) break
allNodes.push(...result.nodes)
totalPages = result.totalPages
page++
}
return allNodes
}
async function fetchComfyNodesPageWithRetry(
fetchImpl: typeof fetch,
baseUrl: string,
packId: string,
version: string,
page: number,
timeoutMs: number
): Promise<{ nodes: RegistryComfyNode[]; totalPages: number } | null> {
const firstAttempt = await fetchComfyNodesPage(
fetchImpl,
baseUrl,
packId,
version,
page,
timeoutMs
)
if (firstAttempt) return firstAttempt
// Retry once on failure
return fetchComfyNodesPage(
fetchImpl,
baseUrl,
packId,
version,
page,
timeoutMs
)
}
async function fetchComfyNodesPage(
fetchImpl: typeof fetch,
baseUrl: string,
packId: string,
version: string,
page: number,
timeoutMs: number
): Promise<{ nodes: RegistryComfyNode[]; totalPages: number } | null> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const url = `${baseUrl}/nodes/${encodeURIComponent(packId)}/versions/${encodeURIComponent(version)}/comfy-nodes?limit=${COMFY_NODES_PAGE_SIZE}&page=${page}`
const res = await fetchImpl(url, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: controller.signal
})
if (!res.ok) return null
const rawBody: unknown = await res.json()
const parsed = RegistryComfyNodesResponseSchema.safeParse(rawBody)
if (!parsed.success) return null
return {
nodes: (parsed.data.comfy_nodes ?? []) as RegistryComfyNode[],
totalPages: parsed.data.totalNumberOfPages ?? 1
}
} catch {
return null
} finally {
clearTimeout(timer)
}
}
async function fetchBatchWithRetry(
fetchImpl: typeof fetch,
baseUrl: string,

View File

@@ -8,12 +8,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodesSnapshot } from '../data/cloudNodes'
import type * as ObjectInfoParser from '@comfyorg/object-info-parser'
const fetchRegistryPacksMock = vi.hoisted(() => vi.fn(async () => new Map()))
import type { RegistryPackWithNodes } from './cloudNodes.registry'
const fetchRegistryPacksWithNodesMock = vi.hoisted(() =>
vi.fn(async () => new Map<string, RegistryPackWithNodes | null>())
)
const sanitizeCallSpy = vi.hoisted(() => vi.fn())
vi.mock('./cloudNodes.registry', () => ({
DEFAULT_REGISTRY_BASE_URL: 'https://api.comfy.org',
fetchRegistryPacks: fetchRegistryPacksMock
fetchRegistryPacksWithNodes: fetchRegistryPacksWithNodesMock
}))
vi.mock('@comfyorg/object-info-parser', async (importOriginal) => {
@@ -90,8 +94,8 @@ describe('fetchCloudNodesForBuild', () => {
beforeEach(() => {
resetCloudNodesFetcherForTests()
fetchRegistryPacksMock.mockReset()
fetchRegistryPacksMock.mockResolvedValue(new Map())
fetchRegistryPacksWithNodesMock.mockReset()
fetchRegistryPacksWithNodesMock.mockResolvedValue(new Map())
sanitizeCallSpy.mockReset()
delete process.env.WEBSITE_CLOUD_API_KEY
})
@@ -102,14 +106,21 @@ describe('fetchCloudNodesForBuild', () => {
})
it('returns fresh when API succeeds', async () => {
fetchRegistryPacksMock.mockResolvedValue(
new Map([
fetchRegistryPacksWithNodesMock.mockResolvedValue(
new Map<string, RegistryPackWithNodes | null>([
[
'comfyui-impact-pack',
{
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
pack: {
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack',
latest_version: { version: '1.0.0' }
},
nodes: [
{ comfy_node_name: 'FaceDetailer', category: 'detailer' },
{ comfy_node_name: 'DetailerForEach', category: 'detailer' }
]
}
]
])
@@ -129,6 +140,10 @@ describe('fetchCloudNodesForBuild', () => {
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
)
// Nodes should come from registry, not object_info
expect(outcome.snapshot.packs[0]?.nodes).toHaveLength(2)
expect(outcome.snapshot.packs[0]?.nodes[0]?.name).toBe('DetailerForEach')
expect(outcome.snapshot.packs[0]?.nodes[1]?.name).toBe('FaceDetailer')
})
it('drops invalid nodes individually and keeps valid nodes', async () => {
@@ -297,7 +312,7 @@ describe('fetchCloudNodesForBuild', () => {
})
it('returns fresh even when registry enrichment fails', async () => {
fetchRegistryPacksMock.mockResolvedValue(new Map())
fetchRegistryPacksWithNodesMock.mockResolvedValue(new Map())
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
@@ -305,5 +320,8 @@ describe('fetchCloudNodesForBuild', () => {
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
// Falls back to object_info nodes when registry fails
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.packs[0]?.nodes[0]?.name).toBe('ImpactNode')
})
})

View File

@@ -6,12 +6,15 @@ import {
validateComfyNodeDef
} from '@comfyorg/object-info-parser'
import type { RegistryPack } from './cloudNodes.registry'
import type {
RegistryComfyNode,
RegistryPackWithNodes
} from './cloudNodes.registry'
import type { NodesSnapshot, Pack, PackNode } from '../data/cloudNodes'
import bundledSnapshot from '../data/cloud-nodes.snapshot.json' with { type: 'json' }
import { isNodesSnapshot } from '../data/cloudNodes'
import { fetchRegistryPacks } from './cloudNodes.registry'
import { fetchRegistryPacksWithNodes } from './cloudNodes.registry'
import { CloudNodesEnvelopeSchema } from './cloudNodes.schema'
const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
@@ -235,26 +238,28 @@ async function parseCloudNodes(
const sanitizedDefs = sanitizeUserContent(
validDefs as Record<string, NonNullable<(typeof validDefs)[string]>>
)
const grouped = groupNodesByPack(sanitizedDefs)
let registryMap = new Map<string, RegistryPack | null>()
// Use object_info to determine which packs are cloud-supported
const grouped = groupNodesByPack(sanitizedDefs)
const packIds = grouped.map((pack) => pack.id)
// Fetch full pack metadata and node list from registry
let registryMap = new Map<string, RegistryPackWithNodes | null>()
try {
registryMap = await fetchRegistryPacks(
grouped.map((pack) => pack.id),
{ fetchImpl: options.fetchImpl }
)
registryMap = await fetchRegistryPacksWithNodes(packIds, {
fetchImpl: options.fetchImpl
})
} catch {
registryMap = new Map()
}
const packs = grouped.map((pack) =>
toDomainPack(
pack.id,
pack.displayName,
pack.nodes,
registryMap.get(pack.id)
)
)
const packs = grouped
.map((pack) => {
const registryData = registryMap.get(pack.id)
// Use registry nodes if available, otherwise fall back to object_info nodes
return toDomainPack(pack.id, pack.displayName, pack.nodes, registryData)
})
.filter((pack) => pack.nodes.length > 0)
return { kind: 'ok', packs, droppedNodes }
}
@@ -274,7 +279,7 @@ function safeExternalUrl(value: string | undefined): string | undefined {
function toDomainPack(
packId: string,
fallbackDisplayName: string,
nodes: Array<{
objectInfoNodes: Array<{
className: string
def: {
display_name: string
@@ -284,8 +289,18 @@ function toDomainPack(
experimental?: boolean
}
}>,
registryPack: RegistryPack | null | undefined
registryData: RegistryPackWithNodes | null | undefined
): Pack {
const registryPack = registryData?.pack
// Prefer registry nodes if available, fall back to object_info nodes
const nodes =
registryData?.nodes && registryData.nodes.length > 0
? registryData.nodes
.map((node) => toDomainNodeFromRegistry(node))
.filter((n): n is PackNode => n !== null)
: objectInfoNodes.map((node) => toDomainNode(node.className, node.def))
return {
id: packId,
registryId: registryPack?.id,
@@ -308,9 +323,20 @@ function toDomainPack(
registryPack?.latest_version?.createdAt ?? registryPack?.created_at,
supportedOs: registryPack?.supported_os,
supportedAccelerators: registryPack?.supported_accelerators,
nodes: nodes
.map((node) => toDomainNode(node.className, node.def))
.sort((a, b) => a.displayName.localeCompare(b.displayName))
nodes: nodes.sort((a, b) => a.displayName.localeCompare(b.displayName))
}
}
function toDomainNodeFromRegistry(node: RegistryComfyNode): PackNode | null {
if (!node.comfy_node_name) return null
return {
name: node.comfy_node_name,
displayName: node.comfy_node_name,
category: node.category || '',
description: node.description || undefined,
deprecated: node.deprecated,
experimental: node.experimental
}
}

View File

@@ -1,8 +1,10 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import type { Position } from '@e2e/fixtures/types'
const { searchBoxV2 } = TestIds
@@ -84,11 +86,12 @@ export class ComfyNodeSearchBoxV2 {
await this.input.waitFor({ state: 'visible' })
}
async openByDoubleClickCanvas(): Promise<void> {
async openByDoubleClickCanvas(position?: Position) {
const { x, y } = position ?? { x: 200, y: 200 }
// Use page.mouse.dblclick (not canvas.dblclick) so the z-999 Vue overlay
// does not intercept; coords target a viewport spot that is on the canvas
// and clear of both the side toolbar and any default-graph nodes.
await this.comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
await this.comfyPage.page.mouse.dblclick(x, y, { delay: 5 })
}
async ensureV2Search(): Promise<void> {
@@ -109,4 +112,14 @@ export class ComfyNodeSearchBoxV2 {
'search box'
)
}
async addNode(query: string, options: { position?: Position } = {}) {
const position = options.position ?? { x: 200, y: 200 }
await this.openByDoubleClickCanvas(position)
await this.input.fill(query)
await expect(this.results.first()).toContainText(query)
await this.comfyPage.page.keyboard.press('Enter')
await expect(this.dialog).toBeHidden()
await this.comfyPage.page.mouse.click(position.x, position.y)
}
}

View File

@@ -2,10 +2,24 @@ import type { Locator } from '@playwright/test'
export class WidgetSelectDropdownFixture {
public readonly selection: Locator
public readonly trigger: Locator
constructor(public readonly root: Locator) {
this.trigger = root.locator('button:has(> span)').first()
this.selection = root.locator('button span span')
}
async open(): Promise<void> {
await this.trigger.click()
}
async searchAndSelectTop(popover: Locator, query: string): Promise<void> {
await this.open()
const searchInput = popover.getByRole('textbox')
await searchInput.fill(query)
await searchInput.press('Enter')
}
async selectedItem(): Promise<string> {
return await this.selection.innerText()
}

View File

@@ -1,6 +1,7 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
/**
* Helper for interacting with widgets rendered in app mode (linear view).
@@ -24,6 +25,11 @@ export class AppModeWidgetHelper {
return this.container.locator(`[data-widget-key="${key}"]`)
}
/** Get a FormDropdown widget by its key (e.g. "10:image"). */
getSelectDropdown(key: string): WidgetSelectDropdownFixture {
return new WidgetSelectDropdownFixture(this.getWidgetItem(key))
}
/** Fill a textarea widget (e.g. CLIP Text Encode prompt). */
async fillTextarea(key: string, value: string) {
const widget = this.getWidgetItem(key)

View File

@@ -11,6 +11,7 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor'
import { TestIds } from '@e2e/fixtures/selectors'
import type { Position, Size } from '@e2e/fixtures/types'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
@@ -241,6 +242,17 @@ export class SubgraphHelper {
return new SubgraphSlotReference('output', slotName || '', this.comfyPage)
}
async getInputBounds(): Promise<Position & Size> {
return await this.comfyPage.page.evaluate(() => {
const graph = app!.canvas.graph as Subgraph
const inputNode = graph.inputNode
const [x, y] = app!.canvas.ds.convertOffsetToCanvas(inputNode.pos)
const width = inputNode.size[0] * app!.canvas.ds.scale
const height = inputNode.size[1] * app!.canvas.ds.scale
return { x, y, width, height }
})
}
/**
* Connect a regular node output to a subgraph input.
* This creates a new input slot on the subgraph if targetInputName is not provided.

View File

@@ -62,12 +62,39 @@ export class WorkflowHelper {
async waitForDraftPersisted() {
await this.comfyPage.page.waitForFunction(() =>
Object.keys(localStorage).some((k) =>
k.startsWith('Comfy.Workflow.Draft.v2:')
Object.keys(localStorage).some((key) =>
key.startsWith('Comfy.Workflow.Draft.v2:')
)
)
}
/** Waits for V2 draft index recency, not payload content freshness. */
async waitForDraftIndexUpdatedSince(updatedSince: number) {
await this.comfyPage.page.waitForFunction((indexUpdatedSince) => {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i)
if (!key?.startsWith('Comfy.Workflow.DraftIndex.v2:')) continue
const json = window.localStorage.getItem(key)
if (!json) continue
try {
const index = JSON.parse(json)
if (
typeof index.updatedAt === 'number' &&
index.updatedAt >= indexUpdatedSince
) {
return true
}
} catch {
// Ignore malformed storage while waiting for persistence.
}
}
return false
}, updatedSince)
}
/**
* Reloads the current page and waits for the app to initialize.
* Unlike ComfyPage.setup(), this preserves localStorage (drafts) and

View File

@@ -8,6 +8,7 @@ import {
} from '@comfyorg/ingest-types/zod'
import type {
JobDetail,
JobStatus,
RawJobListItem,
zJobsListResponse
@@ -40,7 +41,7 @@ interface JobsListRoute {
responseLimit?: number
}
interface JobsScenario {
export interface JobsScenario {
history?: readonly RawJobListItem[]
queue?: readonly RawJobListItem[]
}
@@ -182,6 +183,24 @@ export class JobsRouteMocker {
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
}
async mockDeleteHistory(): Promise<HistoryManageRequest[]> {
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
}
async mockJobDetail(jobId: string, detail: JobDetail): Promise<void> {
await this.page.route(
(url) => url.pathname.endsWith(`/api/jobs/${encodeURIComponent(jobId)}`),
async (requestRoute) => {
if (requestRoute.request().method().toUpperCase() !== 'GET') {
await requestRoute.fallback()
return
}
await requestRoute.fulfill({ json: detail })
}
)
}
private async mockPostManageRoute<TRequest>(
type: 'queue' | 'history',
requestSchema: z.ZodType<TRequest>,

View File

@@ -76,7 +76,15 @@ export const TestIds = {
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',
updatePassword: 'update-password-dialog',
cloudNotification: 'cloud-notification-dialog'
cloudNotification: 'cloud-notification-dialog',
openSharedWorkflow: 'open-shared-workflow-dialog',
openSharedWorkflowTitle: 'open-shared-workflow-title',
openSharedWorkflowClose: 'open-shared-workflow-close',
openSharedWorkflowErrorClose: 'open-shared-workflow-error-close',
openSharedWorkflowCancel: 'open-shared-workflow-cancel',
openSharedWorkflowOpenWithoutImporting:
'open-shared-workflow-open-without-importing',
openSharedWorkflowConfirm: 'open-shared-workflow-confirm'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'

View File

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

View File

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

View File

@@ -2,16 +2,10 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('App mode usage', () => {
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
test('Drag and Drop @vue-nodes', async ({ comfyPage, comfyFiles }) => {
const { centerPanel } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(centerPanel, 'Enter app mode').toBeVisible()
@@ -25,15 +19,12 @@ test.describe('App mode usage', () => {
//prep a load image
await test.step('Add a load image node', async () => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
await comfyPage.searchBoxV2.addNode('Load Image')
const loadImage = await comfyPage.vueNodes.getNodeLocator('10')
await expect(loadImage).toBeVisible()
})
const imageInput = new WidgetSelectDropdownFixture(
comfyPage.appMode.linearWidgets.locator('.lg-node-widget')
)
const imageInput = comfyPage.appMode.widgets.getSelectDropdown('10:image')
await test.step('Enter app mode with image input', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
@@ -107,6 +98,45 @@ test.describe('App mode usage', () => {
//verify values are consistent with litegraph
})
test('FormDropdown search Enter selects the top filtered item', async ({
comfyPage
}) => {
await comfyPage.appMode.enableLinearMode()
const loadImageNode = await comfyPage.nodeOps.addNode('LoadImage')
await comfyPage.nextFrame()
const fileComboWidget = await loadImageNode.getWidget(0)
const targetImage = String(await fileComboWidget.getValue())
const initialImage = 'not-selected.png'
await comfyPage.page.evaluate(
([nodeId, value]) => {
const node = window.app!.graph!.getNodeById(nodeId)
const widget = node?.widgets?.[0]
if (!widget) throw new Error(`Image widget not found: ${nodeId}`)
widget.value = value
},
[loadImageNode.id, initialImage] as const
)
await expect.poll(() => fileComboWidget.getValue()).toBe(initialImage)
await comfyPage.appMode.enterAppModeWithInputs([
[String(loadImageNode.id), 'image']
])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
const imageInput = comfyPage.appMode.widgets.getSelectDropdown(
`${loadImageNode.id}:image`
)
const popover = comfyPage.appMode.imagePickerPopover
await expect(imageInput.root).toBeVisible()
await imageInput.searchAndSelectTop(popover, targetImage)
await expect(popover).toBeHidden()
await expect(imageInput.selection).toHaveText(targetImage)
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode

View File

@@ -75,33 +75,28 @@ test.describe('App mode builder selection', () => {
})
test('Marks canvas readOnly', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await expect(
comfyPage.searchBox.input,
comfyPage.searchBoxV2.input,
'Canvas is initially editable'
).toHaveCount(1)
).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await expect(
comfyPage.searchBox.input,
comfyPage.searchBoxV2.input,
'Entering builder makes the canvas readonly'
).toHaveCount(0)
).toBeHidden()
await comfyPage.page.keyboard.press('Space')
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await expect(
comfyPage.searchBox.input,
comfyPage.searchBoxV2.input,
'Canvas remains readonly after pressing space'
).toHaveCount(0)
).toBeHidden()
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
@@ -112,10 +107,10 @@ test.describe('App mode builder selection', () => {
).toBeHidden()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await expect(
comfyPage.searchBox.input,
comfyPage.searchBoxV2.input,
'Canvas is no longer readonly after exiting'
).toHaveCount(1)
).toBeVisible()
})
})

View File

@@ -131,13 +131,10 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
)
// Click the FormDropdown trigger button for the image widget.
// The button emits 'select-click' which toggles the Popover.
const imageRow = widgetList.locator(
'div:has(> div > span:text-is("image"))'
const imageInput = comfyPage.appMode.widgets.getSelectDropdown(
`${loadImageId}:image`
)
const dropdownButton = imageRow.locator('button:has(> span)').first()
await dropdownButton.click()
await imageInput.open()
// The unstyled PrimeVue Popover renders with role="dialog".
// Locate the one containing the image grid (filter buttons like "All", "Inputs").

View File

@@ -361,3 +361,15 @@ test.describe('Group Node', { tag: '@node' }, () => {
})
})
})
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
await (await comfyPage.vueNodes.getFixtureByTitle('hello')).title.click()
await comfyPage.page.keyboard.press('Control+Shift+e')
await expect(comfyPage.vueNodes.getNodeByTitle('New Subgraph')).toBeVisible()
await comfyPage.vueNodes.enterSubgraph()
await expect(comfyPage.vueNodes.getNodeByTitle('')).toHaveCount(2)
})

View File

@@ -1,4 +1,5 @@
import {
ComfyPage,
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
@@ -43,4 +44,45 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
await expect(comfyPage.page.getByTestId('linear-widgets')).toBeVisible()
await expect(comfyPage.canvas).toBeHidden()
})
test('Spinner persists until workflow loaded', async ({
page,
request
}, testInfo) => {
const comfyPage = new ComfyPage(page, request)
const { parallelIndex } = testInfo
const username = `playwright-test-${parallelIndex}`
const userId = await comfyPage.setupUser(username)
comfyPage.userIds[parallelIndex] = userId
await page.goto(`${comfyPage.url}/api/users`)
await page.evaluate((id) => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
}, comfyPage.id)
const splash = page.locator('#splash-loader')
let notifyWorkflowRequested!: () => void
const workflowRequested = new Promise<void>(
(r) => (notifyWorkflowRequested = r)
)
let unblockRequest!: () => void
const requestUnblocked = new Promise<void>((r) => (unblockRequest = r))
await page.route('**/templates/default.json', async (route) => {
notifyWorkflowRequested()
await requestUnblocked
return route.continue()
})
await comfyPage.goto({ url: `${comfyPage.url}/?template=default` })
await workflowRequested
await comfyPage.nextFrame()
await expect(splash).toBeVisible()
unblockRequest()
await expect(splash).toBeHidden()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 55 KiB

View File

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

View File

@@ -0,0 +1,38 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
const apiNodeName = 'Node With Price Badge'
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode(apiNodeName)
await expect(apiNode, 'Add partner node').toBeVisible()
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
await comfyPage.contextMenu
.openForVueNode(apiNode)
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
const nodePrice = subgraphNode.locator(priceBadge)
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
const initialPrice = Number(await nodePrice.innerText())
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
nodeName: apiNodeName,
widgetName: 'price',
toState: true
})
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
await expect(nodePrice, 'Price is reactive').toHaveText(
String(initialPrice * 2)
)
})

View File

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

View File

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

View File

@@ -10,6 +10,9 @@ import type {
RawJobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
// Legacy coverage backed by AssetsHelper's shadow backend. New assets-sidebar
// browser coverage should use typed route mocks in assetsSidebarTab.spec.ts.
// ---------------------------------------------------------------------------
// Shared fixtures
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,278 @@
import { expect, mergeTests } from '@playwright/test'
import type { Page } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
createRouteMockJob,
jobsRouteFixture,
routeMockJobTimestamp
} from '@e2e/fixtures/jobsRouteFixture'
import type {
JobDetail,
RawJobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
interface ViewFile {
body?: Buffer | string
contentType?: string
}
type ViewFilesByName = Readonly<Record<string, ViewFile>>
const transparentPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lwPIRwAAAABJRU5ErkJggg==',
'base64'
)
const alphaJob = createRouteMockJob({
id: 'alpha',
create_time: routeMockJobTimestamp - 1_000,
execution_start_time: routeMockJobTimestamp - 1_000,
execution_end_time: routeMockJobTimestamp,
preview_output: {
filename: 'alpha.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
})
const betaJob = createRouteMockJob({
id: 'beta',
create_time: routeMockJobTimestamp - 2_000,
execution_start_time: routeMockJobTimestamp - 2_000,
execution_end_time: routeMockJobTimestamp,
preview_output: {
filename: 'beta.png',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'images'
}
})
const multiOutputJob = createRouteMockJob({
id: 'multi-output',
create_time: routeMockJobTimestamp - 3_000,
execution_start_time: routeMockJobTimestamp - 3_000,
execution_end_time: routeMockJobTimestamp,
preview_output: {
filename: 'multi-output-a.png',
subfolder: '',
type: 'output',
nodeId: '3',
mediaType: 'images'
},
outputs_count: 2
})
const multiOutputJobDetail: JobDetail = {
...multiOutputJob,
outputs: {
'3': {
images: [
{
filename: 'multi-output-a.png',
subfolder: '',
type: 'output'
},
{
filename: 'multi-output-b.png',
subfolder: '',
type: 'output'
}
]
}
}
}
const generatedJobs: RawJobListItem[] = [alphaJob, betaJob]
const viewFiles = {
'alpha.png': {},
'beta.png': {},
'imported.png': {},
'multi-output-a.png': {},
'multi-output-b.png': {}
}
async function mockInputFiles(page: Page, files: readonly string[]) {
await page.route('**/internal/files/input**', async (route) => {
if (route.request().method().toUpperCase() !== 'GET') {
await route.fallback()
return
}
await route.fulfill({ json: [...files] })
})
}
async function mockViewFiles(page: Page, filesByName: ViewFilesByName) {
await page.route('**/api/view**', async (route) => {
if (route.request().method().toUpperCase() !== 'GET') {
await route.fallback()
return
}
const url = new URL(route.request().url())
const filename = url.searchParams.get('filename')
if (!filename) {
await route.fulfill({
status: 400,
json: { error: 'Missing filename' } satisfies { error: string }
})
return
}
const file = filesByName[filename]
if (!file) {
await route.fulfill({
status: 404,
json: {
error: `Unknown filename: ${filename}`
} satisfies { error: string }
})
return
}
await route.fulfill({
body: file.body ?? transparentPng,
contentType: file.contentType ?? 'image/png'
})
})
}
test.describe('FE-130 assets sidebar route mocks', () => {
test.beforeEach(async ({ jobsRoutes, page }) => {
await jobsRoutes.mockJobsQueue([])
await jobsRoutes.mockJobsHistory(generatedJobs)
await mockInputFiles(page, ['imported.png'])
await mockViewFiles(page, viewFiles)
})
test('renders generated and imported assets with image previews', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await expect(tab.getAssetCardByName('alpha')).toBeVisible()
await expect(tab.getAssetCardByName('beta')).toBeVisible()
await expect(
comfyPage.page.getByRole('img', { name: 'alpha.png' })
).toHaveJSProperty('naturalWidth', 1)
await tab.switchToImported()
await expect(tab.getAssetCardByName('imported')).toBeVisible()
await expect(
comfyPage.page.getByRole('img', { name: 'imported.png' })
).toHaveJSProperty('naturalWidth', 1)
})
test('opens previews for generated and imported images', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await comfyPage.page.getByRole('img', { name: 'alpha.png' }).dblclick()
await expect(comfyPage.mediaLightbox.root).toBeVisible()
await expect(
comfyPage.mediaLightbox.root.getByRole('img', {
name: 'alpha.png'
})
).toBeVisible()
await comfyPage.mediaLightbox.closeButton.click()
await expect(comfyPage.mediaLightbox.root).toBeHidden()
await tab.switchToImported()
await comfyPage.page.getByRole('img', { name: 'imported.png' }).dblclick()
await expect(comfyPage.mediaLightbox.root).toBeVisible()
await expect(
comfyPage.mediaLightbox.root.getByRole('img', {
name: 'imported.png'
})
).toBeVisible()
})
test('shows footer actions for single and multiple generated selections', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await tab.getAssetCardByName('alpha').click()
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*1\b/)
await expect(tab.deleteSelectedButton).toBeVisible()
await expect(tab.downloadSelectedButton).toBeVisible()
await comfyPage.page.keyboard.down('Control')
await tab.getAssetCardByName('beta').click()
await comfyPage.page.keyboard.up('Control')
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
await expect(tab.deleteSelectedButton).toBeVisible()
await expect(tab.downloadSelectedButton).toBeVisible()
})
test('loads full generated job outputs from job detail', async ({
comfyPage,
jobsRoutes
}) => {
const tab = comfyPage.menu.assetsTab
await jobsRoutes.mockJobsHistory([multiOutputJob])
await jobsRoutes.mockJobDetail('multi-output', multiOutputJobDetail)
await comfyPage.setup()
await tab.open()
await tab
.getAssetCardByName('multi-output-a')
.getByRole('button', { name: 'See more outputs' })
.click()
await expect(tab.backToAssetsButton).toBeVisible()
await expect(tab.getAssetCardByName('multi-output-b')).toBeVisible()
await expect(
comfyPage.page.getByRole('img', { name: 'multi-output-b.png' })
).toHaveJSProperty('naturalWidth', 1)
})
test('deletes a generated output asset through explicit history refresh', async ({
comfyPage,
jobsRoutes
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await expect(tab.getAssetCardByName('alpha')).toBeVisible()
const deleteRequests = await jobsRoutes.mockDeleteHistory()
await jobsRoutes.mockJobsHistory([betaJob])
await tab.getAssetCardByName('alpha').click({ button: 'right' })
await tab.contextMenuItem('Delete').click()
await comfyPage.confirmDialog.delete.click()
await expect.poll(() => deleteRequests).toHaveLength(1)
expect(deleteRequests[0]).toEqual({ delete: ['alpha'] })
await expect(tab.getAssetCardByName('alpha')).toHaveCount(0)
await expect(comfyPage.toast.toastSuccesses).toContainText(
'Asset deleted successfully'
)
})
})

View File

@@ -7,12 +7,10 @@ import {
jobsRouteFixture,
routeMockJobTimestamp
} from '@e2e/fixtures/jobsRouteFixture'
import type { JobsRouteMocker } from '@e2e/fixtures/jobsRouteFixture'
import type { JobsScenario } from '@e2e/fixtures/jobsRouteFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
const historyJobs: RawJobListItem[] = [
createRouteMockJob({
id: 'history-completed',
@@ -75,21 +73,24 @@ const activeJobs: RawJobListItem[] = [
]
const runningOnlyJobs = activeJobs.filter((job) => job.status !== 'pending')
async function setupJobHistorySidebar(
comfyPage: ComfyPage,
jobsRoutes: JobsRouteMocker,
{
history = historyJobs,
queue = activeJobs
}: {
history?: readonly RawJobListItem[]
queue?: readonly RawJobListItem[]
} = {}
) {
await jobsRoutes.mockJobsScenario({ history, queue })
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', true)
await comfyPage.setup()
const test = mergeTests(comfyPageFixture, jobsRouteFixture).extend<{
initialJobsScenario: JobsScenario
mockInitialJobsScenario: void
}>({
initialJobsScenario: [
{ history: historyJobs, queue: activeJobs },
{ option: true }
],
mockInitialJobsScenario: [
async ({ jobsRoutes, initialJobsScenario }, use) => {
await jobsRoutes.mockJobsScenario(initialJobsScenario)
await use()
},
{ auto: true }
]
})
async function openJobHistorySidebar(comfyPage: ComfyPage) {
await comfyPage.page
.getByTestId(TestIds.sidebar.toolbar)
.getByRole('button', { name: 'Job History', exact: true })
@@ -122,159 +123,152 @@ async function openSidebarClearHistoryDialog(comfyPage: ComfyPage) {
}
test.describe('Job history sidebar', { tag: '@ui' }, () => {
test('opens from the queue overlay docked history action', async ({
comfyPage,
jobsRoutes
}) => {
await jobsRoutes.mockJobsScenario({
history: historyJobs,
queue: activeJobs
test.describe('docked overlay action', () => {
test.use({ initialSettings: { 'Comfy.Queue.QPOV2': false } })
test('opens from the queue overlay docked history action', async ({
comfyPage
}) => {
await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click()
await comfyPage.queuePanel.moreOptionsButton.click()
await comfyPage.page
.getByTestId(TestIds.queue.dockedJobHistoryAction)
.click()
await expect(jobHistorySidebar(comfyPage)).toBeVisible()
await expect(jobRow(comfyPage)('history-completed')).toBeVisible()
await expect(jobRow(comfyPage)('queue-pending')).toBeVisible()
})
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
await comfyPage.setup()
await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click()
await comfyPage.queuePanel.moreOptionsButton.click()
await comfyPage.page
.getByTestId(TestIds.queue.dockedJobHistoryAction)
.click()
await expect(jobHistorySidebar(comfyPage)).toBeVisible()
await expect(jobRow(comfyPage)('history-completed')).toBeVisible()
await expect(jobRow(comfyPage)('queue-pending')).toBeVisible()
})
test('shows terminal and active job states', async ({
comfyPage,
jobsRoutes
}) => {
await setupJobHistorySidebar(comfyPage, jobsRoutes)
test.describe('expanded history tab', () => {
test.use({ initialSettings: { 'Comfy.Queue.QPOV2': true } })
const row = jobRow(comfyPage)
await expect(row('queue-pending')).toBeVisible()
await expect(row('queue-running')).toBeVisible()
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeVisible()
await expect(row('history-cancelled')).toBeVisible()
test('shows terminal and active job states', async ({ comfyPage }) => {
await openJobHistorySidebar(comfyPage)
await expect(clearQueueButton(comfyPage)).toBeEnabled()
})
const row = jobRow(comfyPage)
await expect(row('queue-pending')).toBeVisible()
await expect(row('queue-running')).toBeVisible()
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeVisible()
await expect(row('history-cancelled')).toBeVisible()
test('filters completed and failed history jobs', async ({
comfyPage,
jobsRoutes
}) => {
await setupJobHistorySidebar(comfyPage, jobsRoutes)
await comfyPage.page
.getByRole('button', { name: 'Completed', exact: true })
.click()
const row = jobRow(comfyPage)
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeHidden()
await expect(row('queue-running')).toBeHidden()
await comfyPage.page
.getByRole('button', { name: 'Failed', exact: true })
.click()
await expect(row('history-failed')).toBeVisible()
await expect(row('history-cancelled')).toBeVisible()
await expect(row('history-completed')).toBeHidden()
})
test('searches by job id and output filename', async ({
comfyPage,
jobsRoutes
}) => {
await setupJobHistorySidebar(comfyPage, jobsRoutes)
const row = jobRow(comfyPage)
const searchInput = comfyPage.page.getByPlaceholder('Search...')
await searchInput.fill('history-failed')
await expect(row('history-failed')).toBeVisible()
await expect(row('history-completed')).toBeHidden()
await expect(row('queue-running')).toBeHidden()
await searchInput.fill('completed-output')
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeHidden()
await searchInput.clear()
await expect(row('history-completed')).toBeVisible()
await expect(row('queue-running')).toBeVisible()
})
test('disables clear queue when there are no pending jobs', async ({
comfyPage,
jobsRoutes
}) => {
await setupJobHistorySidebar(comfyPage, jobsRoutes, {
queue: runningOnlyJobs
await expect(clearQueueButton(comfyPage)).toBeEnabled()
})
await expect(clearQueueButton(comfyPage)).toBeDisabled()
})
test('filters completed and failed history jobs', async ({ comfyPage }) => {
await openJobHistorySidebar(comfyPage)
test('clears pending queue jobs and leaves running/history jobs', async ({
comfyPage,
jobsRoutes
}) => {
await setupJobHistorySidebar(comfyPage, jobsRoutes)
await comfyPage.page
.getByRole('button', { name: 'Completed', exact: true })
.click()
const row = jobRow(comfyPage)
await expect(row('queue-pending')).toBeVisible()
const row = jobRow(comfyPage)
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeHidden()
await expect(row('queue-running')).toBeHidden()
const clearQueueRequests = await jobsRoutes.mockClearQueue()
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
await jobsRoutes.mockJobsScenario({
history: historyJobs,
queue: runningOnlyJobs
await comfyPage.page
.getByRole('button', { name: 'Failed', exact: true })
.click()
await expect(row('history-failed')).toBeVisible()
await expect(row('history-cancelled')).toBeVisible()
await expect(row('history-completed')).toBeHidden()
})
await clearQueueButton(comfyPage).click()
test('searches by job id and output filename', async ({ comfyPage }) => {
await openJobHistorySidebar(comfyPage)
await expect.poll(() => clearQueueRequests.length).toBe(1)
expect(clearQueueRequests).toContainEqual({ clear: true })
await expect(row('queue-pending')).toBeHidden()
await expect(row('queue-running')).toBeVisible()
await expect(row('history-completed')).toBeVisible()
await expect(clearQueueButton(comfyPage)).toBeDisabled()
expect(clearHistoryRequests).toHaveLength(0)
})
const row = jobRow(comfyPage)
const searchInput = comfyPage.page.getByPlaceholder('Search...')
test('clears history from the sidebar menu and keeps active jobs', async ({
comfyPage,
jobsRoutes
}) => {
await setupJobHistorySidebar(comfyPage, jobsRoutes)
await searchInput.fill('history-failed')
await expect(row('history-failed')).toBeVisible()
await expect(row('history-completed')).toBeHidden()
await expect(row('queue-running')).toBeHidden()
const row = jobRow(comfyPage)
await expect(row('history-completed')).toBeVisible()
await searchInput.fill('completed-output')
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeHidden()
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
const clearQueueRequests = await jobsRoutes.mockClearQueue()
await jobsRoutes.mockJobsScenario({
history: [],
queue: activeJobs
await searchInput.clear()
await expect(row('history-completed')).toBeVisible()
await expect(row('queue-running')).toBeVisible()
})
await openSidebarClearHistoryDialog(comfyPage)
await expect(
comfyPage.page.getByText('Clear your job queue history?')
).toBeVisible()
await comfyPage.page
.getByRole('button', { name: 'Clear', exact: true })
.click()
test('clears pending queue jobs and leaves running/history jobs', async ({
comfyPage,
jobsRoutes
}) => {
await openJobHistorySidebar(comfyPage)
await expect.poll(() => clearHistoryRequests.length).toBe(1)
expect(clearHistoryRequests).toContainEqual({ clear: true })
await expect(row('history-completed')).toBeHidden()
await expect(row('history-failed')).toBeHidden()
await expect(row('queue-running')).toBeVisible()
await expect(row('queue-pending')).toBeVisible()
expect(clearQueueRequests).toHaveLength(0)
const row = jobRow(comfyPage)
await expect(row('queue-pending')).toBeVisible()
const clearQueueRequests = await jobsRoutes.mockClearQueue()
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
await jobsRoutes.mockJobsScenario({
history: historyJobs,
queue: runningOnlyJobs
})
await clearQueueButton(comfyPage).click()
await expect.poll(() => clearQueueRequests.length).toBe(1)
expect(clearQueueRequests).toContainEqual({ clear: true })
await expect(row('queue-pending')).toBeHidden()
await expect(row('queue-running')).toBeVisible()
await expect(row('history-completed')).toBeVisible()
await expect(clearQueueButton(comfyPage)).toBeDisabled()
expect(clearHistoryRequests).toHaveLength(0)
})
test('clears history from the sidebar menu and keeps active jobs', async ({
comfyPage,
jobsRoutes
}) => {
await openJobHistorySidebar(comfyPage)
const row = jobRow(comfyPage)
await expect(row('history-completed')).toBeVisible()
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
const clearQueueRequests = await jobsRoutes.mockClearQueue()
await jobsRoutes.mockJobsScenario({
history: [],
queue: activeJobs
})
await openSidebarClearHistoryDialog(comfyPage)
await expect(
comfyPage.page.getByText('Clear your job queue history?')
).toBeVisible()
await comfyPage.page
.getByRole('button', { name: 'Clear', exact: true })
.click()
await expect.poll(() => clearHistoryRequests.length).toBe(1)
expect(clearHistoryRequests).toContainEqual({ clear: true })
await expect(row('history-completed')).toBeHidden()
await expect(row('history-failed')).toBeHidden()
await expect(row('queue-running')).toBeVisible()
await expect(row('queue-pending')).toBeVisible()
expect(clearQueueRequests).toHaveLength(0)
})
})
test.describe('without pending queue jobs', () => {
test.use({
initialJobsScenario: { history: historyJobs, queue: runningOnlyJobs },
initialSettings: { 'Comfy.Queue.QPOV2': true }
})
test('disables clear queue', async ({ comfyPage }) => {
await openJobHistorySidebar(comfyPage)
await expect(clearQueueButton(comfyPage)).toBeDisabled()
})
})
})

View File

@@ -129,4 +129,26 @@ test.describe('Node library sidebar V2', () => {
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
await expect(tab.nodePreview).toContainText('Inverts the image')
})
test('Click-to-place from sidebar selects the newly added node', async ({
comfyPage
}) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await comfyPage.nodeOps.clearGraph()
await tab.expandFolder('sampling')
const canvasBox = (await comfyPage.canvas.boundingBox())!
const target = {
x: canvasBox.width / 2,
y: canvasBox.height / 2
}
await tab.getNode('KSampler (Advanced)').click()
await comfyPage.canvas.click({ position: target })
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(1)
})
})

View File

@@ -5,10 +5,6 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
})
test.describe('Subgraph Clipboard Operations', () => {
@@ -58,8 +54,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.canvasOps.doubleClick()
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
await comfyPage.searchBoxV2.addNode('Note')
await comfyPage.nextFrame()
const initialCount = await comfyPage.subgraph.getNodeCount()

View File

@@ -745,20 +745,19 @@ test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
})
test('Can promote multiple previews @vue-nodes', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await test.step('Add and rename a Load Image node', async () => {
await comfyPage.page.mouse.dblclick(300, 300, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
const position = { x: 300, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await loadImage.setTitle('Character Reference')
})
await test.step('Add a second Load Image node', async () => {
await comfyPage.page.mouse.dblclick(600, 300, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
const position = { x: 600, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
})
await test.step('Convert both nodes to subgraph', async () => {

View File

@@ -632,3 +632,72 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
})
})
})
test(
'link interactions',
{ tag: ['@vue-nodes', '@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const seedSlot = ksampler.getSlot('seed')
const seedIOSlot = await comfyPage.subgraph.getInputSlot('seed')
await test.step('Make second INT typed connection', async () => {
const toPos = await seedIOSlot.getOpenSlotPosition()
await seedSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(seedSlot)
await expect.poll(isConnected).toBe(true)
})
const stepsSlot = ksampler.getSlot('steps')
await test.step('Node -> I/O hover effect', async () => {
await stepsSlot.hover()
await stepsSlot.click({ trial: true })
await comfyPage.page.mouse.down()
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
const rawClip = await comfyPage.subgraph.getInputBounds()
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
const clip = { ...rawClip, ...absolutePos }
await expect(comfyPage.page).toHaveScreenshot('vue-io-highlight.png', {
clip
})
//cancel link operation
await stepsSlot.hover()
await comfyPage.page.mouse.up()
})
await ksampler.title.hover()
const slotParent = stepsSlot.locator('../..')
await expect(slotParent, 'unconnected slot is hidden').toHaveCSS(
'opacity',
'0'
)
await test.step('Connect I/O to node with snap', async () => {
const hasSnap = () =>
comfyPage.page.evaluate(() => !!app!.canvas._highlight_pos)
expect(await hasSnap()).toBe(false)
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
await comfyPage.canvas.hover({ position: emptySlotPos })
await comfyPage.page.mouse.down()
await stepsSlot.hover()
await expect.poll(hasSnap).toBe(true)
await comfyPage.page.mouse.up()
//move hover off the slot
await ksampler.title.hover()
})
await expect(slotParent, 'connected slot is visible').not.toHaveCSS(
'opacity',
'0'
)
}
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -143,7 +143,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
})
await expect(
comfyPage.page.getByRole('heading', { name: 'Open shared workflow' })
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
await expect(comfyPage.templates.content).toBeHidden()

View File

@@ -1082,17 +1082,10 @@ test.describe(
comfyPage,
comfyMouse
}) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
// Setup workflow with a KSampler node
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nodeOps.waitForGraphNodes(0)
await comfyPage.command.executeCommand('Workspace.SearchBox.Toggle')
await comfyPage.nextFrame()
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
await comfyPage.searchBoxV2.addNode('KSampler')
await comfyPage.nodeOps.waitForGraphNodes(1)
// Convert the KSampler node to a subgraph

View File

@@ -19,3 +19,19 @@ test('Can display a slot mismatched from widget type', async ({
await expect(width.locator('path[fill*="INT"]')).toBeVisible()
await expect(width.locator('path[fill*="FLOAT"]')).toBeVisible()
})
test('MatchType updates output color @vue-nodes', async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Load Image')
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await comfyPage.searchBoxV2.addNode('Switch', {
position: { x: 600, y: 200 }
})
const switchNode = await comfyPage.vueNodes.getFixtureByTitle('switch')
await loadImage.getSlot('MASK').dragTo(switchNode.getSlot('on_false'))
const slotEl = switchNode.getSlot('output').locator('.slot-dot')
await expect.poll(() => slotEl.getAttribute('style')).toContain('MASK')
})

View File

@@ -9,8 +9,6 @@ const file1 = 'workflow.mp4' as const
const file2 = 'workflow.webm' as const
test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
const loadVideoNode = comfyPage.vueNodes.getNodeByTitle('Load Video')
const loadVideo = new VideoPreview(loadVideoNode)
@@ -18,9 +16,7 @@ test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.page.mouse.dblclick(500, 300, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Video')
await comfyPage.searchBoxV2.addNode('Load Video')
await expect(loadVideoNode).toHaveCount(1)
await expect(loadVideoNode).toBeVisible()
})

View File

@@ -5,8 +5,6 @@ import {
} from '@e2e/fixtures/ComfyPage'
test('@vue-nodes Audio Widget', async ({ comfyPage, comfyFiles }) => {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
const loadAudioNode = comfyPage.vueNodes.getNodeByTitle('Load Audio')
const audioPreview = new AudioPreview(loadAudioNode)
@@ -14,9 +12,7 @@ test('@vue-nodes Audio Widget', async ({ comfyPage, comfyFiles }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
//await comfyPage.canvasOps.doubleClick()
await comfyPage.page.mouse.dblclick(500, 500, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Audio')
await comfyPage.searchBoxV2.addNode('Load Audio')
await expect(loadAudioNode).toBeVisible()
})

View File

@@ -52,42 +52,4 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
})
await expect(loadCheckpointNode).toHaveCount(3)
})
test('Can load dynamic combos', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await test.step('Add node with dynamic combo', async () => {
await comfyPage.page.mouse.dblclick(500, 500, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Resize Image/Mask')
await expect(comfyPage.searchBox.input).toBeHidden()
})
const widget = comfyPage.vueNodes.getWidgetByName(
'Resize Image/Mask',
'resize_type'
)
await test.step('Update value of the dynamic combo widget', async () => {
await widget.click()
await comfyPage.page.getByRole('searchbox').fill('scale width')
await comfyPage.page.keyboard.press('ArrowDown')
await comfyPage.page.keyboard.press('Enter')
await expect(widget.locator('input')).toBeHidden()
await expect(widget).toHaveText('scale width')
})
await test.step('Swap to a different workflow and back', async () => {
await comfyPage.menu.topbar.getTab(0).click()
await expect(widget).toBeHidden()
await comfyPage.menu.topbar.getTab(1).click()
await expect(widget).toBeVisible()
})
await expect(widget, 'Widget has restored value').toHaveText('scale width')
})
})

View File

@@ -4,6 +4,103 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
const generateUniqueFilename = () =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
const waitForWorkflowTabState = async (comfyPage: ComfyPage, minPaths = 2) => {
await comfyPage.page.waitForFunction((expectedMinPaths) => {
let hasActivePath = false
let hasOpenPaths = false
for (let i = 0; i < window.sessionStorage.length; i++) {
const key = window.sessionStorage.key(i)
if (key?.startsWith('Comfy.Workflow.ActivePath:')) {
hasActivePath = true
}
if (!key?.startsWith('Comfy.Workflow.OpenPaths:')) {
continue
}
const raw = window.sessionStorage.getItem(key)
if (!raw) continue
try {
const state = JSON.parse(raw) as { paths?: unknown[] }
hasOpenPaths =
Array.isArray(state.paths) && state.paths.length >= expectedMinPaths
if (hasActivePath && hasOpenPaths) return true
} catch {
return false
}
}
return hasActivePath && hasOpenPaths
}, minPaths)
}
type NodeRef = NonNullable<
Awaited<ReturnType<ComfyPage['nodeOps']['getFirstNodeRef']>>
>
const getRequiredFirstNodeRef = async (
comfyPage: ComfyPage,
message: string
): Promise<NodeRef> => {
const node = await comfyPage.nodeOps.getFirstNodeRef()
expect(node, message).toBeDefined()
if (!node) throw new Error(message)
return node
}
const makeActivePathStale = async (
comfyPage: ComfyPage,
staleWorkflowName: string,
activeWorkflowName: string
) => {
// Intentionally desync ActivePath from OpenPaths to exercise stale pointer recovery.
await comfyPage.page.evaluate(
([staleName, activeName]) => {
const findStorageKey = (prefix: string) => {
for (let i = 0; i < window.sessionStorage.length; i++) {
const key = window.sessionStorage.key(i)
if (key?.startsWith(prefix)) return key
}
throw new Error(`Missing ${prefix} persistence key`)
}
const activePathKey = findStorageKey('Comfy.Workflow.ActivePath:')
const openPathsKey = findStorageKey('Comfy.Workflow.OpenPaths:')
const activePointer = JSON.parse(
window.sessionStorage.getItem(activePathKey)!
) as { path: string }
const openPointer = JSON.parse(
window.sessionStorage.getItem(openPathsKey)!
) as { paths: string[]; activeIndex: number }
const pathForName = (name: string) => {
const path = openPointer.paths.find((candidate) =>
candidate.endsWith(`${name}.json`)
)
if (!path) throw new Error(`Missing stored path for ${name}`)
return path
}
const stalePath = pathForName(staleName)
const activePath = pathForName(activeName)
activePointer.path = stalePath
openPointer.paths = [stalePath, activePath]
openPointer.activeIndex = 1
window.sessionStorage.setItem(
activePathKey,
JSON.stringify(activePointer)
)
window.sessionStorage.setItem(openPathsKey, JSON.stringify(openPointer))
},
[staleWorkflowName, activeWorkflowName]
)
}
async function getNodeOutputImageCount(
comfyPage: ComfyPage,
@@ -103,9 +200,11 @@ test.describe('Workflow Persistence', () => {
await comfyPage.menu.topbar.saveWorkflow('outputs-test')
const firstNode = await comfyPage.nodeOps.getFirstNodeRef()
expect(firstNode).toBeTruthy()
const nodeId = String(firstNode!.id)
const firstNode = await getRequiredFirstNodeRef(
comfyPage,
'First node should be available after loading the default workflow'
)
const nodeId = String(firstNode.id)
// Simulate node outputs as if execution completed
await comfyPage.page.evaluate((id) => {
@@ -382,6 +481,59 @@ test.describe('Workflow Persistence', () => {
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(nodeCountB)
})
test('Restores saved workflow drafts from inactive restored tabs', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', true)
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
const workflowA = generateUniqueFilename()
const workflowB = generateUniqueFilename()
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await fitToViewInstant(comfyPage)
await comfyPage.menu.topbar.saveWorkflow(workflowA)
const firstNode = await getRequiredFirstNodeRef(
comfyPage,
'First node should be available after loading single_ksampler'
)
await firstNode.centerOnNode()
const draftSaveStartedAt = Date.now()
await firstNode.toggleCollapse()
expect(await firstNode.isCollapsed()).toBe(true)
await comfyPage.workflow.waitForDraftIndexUpdatedSince(draftSaveStartedAt)
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
await waitForWorkflowTabState(comfyPage)
await makeActivePathStale(comfyPage, workflowA, workflowB)
await comfyPage.workflow.reloadAndWaitForApp()
await expect
.poll(() => comfyPage.menu.topbar.getActiveTabName())
.toBe(workflowB)
const tabs = await comfyPage.menu.topbar.getTabNames()
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
await comfyPage.menu.topbar.getWorkflowTab(workflowA).click()
await comfyPage.workflow.waitForWorkflowIdle()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
const restoredNode = await getRequiredFirstNodeRef(
comfyPage,
'Restored node should be available after switching back to workflow A'
)
expect(await restoredNode.isCollapsed()).toBe(true)
await expect(comfyPage.toast.toastErrors).toHaveCount(0)
})
test('Closing an inactive tab with save preserves its own content', async ({
comfyPage
}) => {

View File

@@ -4,7 +4,7 @@ Date: 2025-08-25
## Status
Proposed
Accepted (Nx tooling choice superseded by [ADR-0010](0010-remove-nx-orchestration.md))
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
@@ -31,6 +31,8 @@ For more information on Monorepos, check out [monorepo.tools](https://monorepo.t
For monorepo management, I'd probably go with [Nx](https://nx.dev/), but I could be conviced otherwise.
There's a [whole list here](https://monorepo.tools/#tools-review) if you're interested.
> **Update:** The Nx tooling choice has since been reversed. See [ADR-0010: Remove Nx Orchestration](0010-remove-nx-orchestration.md) for the migration to direct pnpm workspace scripts and native tool CLIs.
## Consequences
### Positive

View File

@@ -0,0 +1,92 @@
# 10. Remove Nx Orchestration
Date: 2026-05-19
## Status
Accepted
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
## Context
[ADR-0002](0002-monorepo-conversion.md) adopted [Nx](https://nx.dev/) as a tooling option for managing the
ComfyUI Frontend monorepo on top of pnpm workspaces. Nx was introduced as task
orchestration to coordinate builds, tests, lints, and types across the apps and
packages workspaces.
In practice, Nx provided little value beyond what pnpm workspaces and the
underlying native tool CLIs (Vite, Vitest, Playwright, ESLint, oxlint, oxfmt,
TypeScript) already offer:
- pnpm's `--filter` and `--recursive` flags already provide topological,
parallel, and selective execution across workspaces.
- Each underlying tool already has fast, well-supported caching (Vite, Vitest,
ESLint, oxlint, TS incremental builds, etc.).
- Nx added an extra configuration surface (`nx.json`, `.nxignore`, per-package
`nx` blocks), an extra cache layer, an extra `node_modules/.cache/nx`
artifact, and an extra CI dimension to debug.
- Contributors and AI agents had to learn the Nx mental model in addition to
pnpm and the individual tool CLIs.
- The Nx daemon and remote-cache features were not in use, so the runtime
benefit was limited to local task graph caching, which is largely redundant
with the per-tool caches.
The cost (configuration, mental overhead, surprise behavior, occasional
cache-related failures) exceeded the benefit.
## Decision
Remove Nx from the repository and run monorepo tasks using:
- pnpm workspace scripts (`pnpm -r run <script>`,
`pnpm --filter <pkg> run <script>`).
- Each tool's native CLI (Vite, Vitest, Playwright, ESLint, oxlint, oxfmt,
`vue-tsc`, etc.) invoked directly from the relevant workspace.
Concretely, this change:
- Deletes `nx.json` and `.nxignore`.
- Removes `nx` entries from root and per-package `package.json` files (the
`nx` block on each `package.json`, the dev dependency, and Nx-specific
scripts).
- Removes `nx`-related entries from `pnpm-workspace.yaml`'s `allowBuilds`.
- Rewrites the affected CI workflows (`.github/workflows/ci-tests-e2e.yaml`,
`.github/workflows/release-draft-create.yaml`) to call pnpm/native CLIs
directly.
- Updates `AGENTS.md`, `TROUBLESHOOTING.md`, and
[ADR-0002](0002-monorepo-conversion.md) to reflect the new tooling story.
- Cleans up Nx-specific lint/format/ignore rules in `.oxlintrc.json`,
`eslint.config.ts`, `vite.config.mts`, and `.gitignore`.
## Consequences
### Positive
- Fewer moving parts: no `nx.json`, no `.nx/` cache, no Nx daemon, no
Nx-specific scripts to maintain.
- Easier onboarding for contributors and AI agents: pnpm + each tool's CLI is
the only required knowledge.
- CI logs and failures are easier to read because tasks run directly under the
tool that owns them, instead of being wrapped by Nx.
- Faster, more predictable cache invalidation behavior — each tool owns its
own cache and we no longer hit Nx-cache edge cases.
- Smaller dependency tree (~2k fewer lines in `pnpm-lock.yaml`).
### Negative
- We lose Nx's unified task graph and project graph commands; coordination
across workspaces now relies on pnpm filters and explicit script wiring.
- We lose Nx's remote/distributed caching as a future option without
re-adopting Nx (or a comparable tool like Turborepo).
- Contributors who already knew Nx workflows need to relearn the equivalent
pnpm invocations.
## Notes
- The migration is purely a tooling change; no application behavior, public
API, or build output changes.
- If we later need more sophisticated task orchestration (e.g. distributed
remote cache, fine-grained affected-graph queries), revisit this decision and
evaluate Nx, Turborepo, or Moon at that time, with concrete CI/perf data to
justify the additional complexity.

View File

@@ -8,16 +8,18 @@ An Architecture Decision Record captures an important architectural decision mad
## ADR Index
| ADR | Title | Status | Date |
| -------------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| ADR | Title | Status | Date |
| ----------------------------------------------------------- | ------------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| [0009](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Subgraph Promoted Widgets Use Linked Inputs | Proposed | 2026-05-05 |
| [0010](0010-remove-nx-orchestration.md) | Remove Nx Orchestration | Accepted | 2026-05-19 |
## Creating a New ADR

View File

@@ -76,7 +76,6 @@ export default defineConfig([
{
ignores: [
'.i18nrc.cjs',
'.nx/*',
'**/vite.config.*.timestamp*',
'**/vitest.config.*.timestamp*',
'components.d.ts',

41
nx.json
View File

@@ -1,41 +0,0 @@
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"plugins": [
{
"plugin": "@nx/eslint/plugin",
"options": {
"targetName": "lint"
}
},
{
"plugin": "@nx/storybook/plugin",
"options": {
"serveStorybookTargetName": "storybook",
"buildStorybookTargetName": "build-storybook",
"testStorybookTargetName": "test-storybook",
"staticStorybookTargetName": "static-storybook"
}
},
{
"plugin": "@nx/vite/plugin",
"options": {
"buildTargetName": "build",
"testTargetName": "test",
"serveTargetName": "serve",
"devTargetName": "dev",
"previewTargetName": "preview",
"serveStaticTargetName": "serve-static",
"typecheckTargetName": "typecheck",
"buildDepsTargetName": "build-deps",
"watchDepsTargetName": "watch-deps"
}
},
{
"plugin": "@nx/playwright/plugin",
"options": {
"targetName": "e2e"
}
}
],
"analytics": false
}

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.45.9",
"version": "1.45.13",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -8,20 +8,22 @@
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"type": "module",
"scripts": {
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' nx build",
"build:desktop": "nx build @comfyorg/desktop-ui",
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' vite build --config vite.config.mts",
"build:desktop": "pnpm --filter @comfyorg/desktop-ui run build",
"build-storybook": "storybook build",
"build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' vite build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && vite build --config vite.config.mts",
"clean": "pnpm dlx rimraf dist dist-ssr coverage playwright-report blob-report test-results node_modules/.vite apps/desktop-ui/dist apps/website/dist",
"clean:all": "pnpm clean && pnpm dlx rimraf node_modules",
"size:collect": "node scripts/size-collect.js",
"size:report": "node scripts/size-report.js",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' nx serve",
"dev:desktop": "nx dev @comfyorg/desktop-ui",
"dev:electron": "cross-env DISTRIBUTION=desktop nx serve --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
"dev": "nx serve",
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' vite --config vite.config.mts",
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",
"dev": "vite --config vite.config.mts",
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
"format:check": "oxfmt --check",
"format": "oxfmt --write",
@@ -34,32 +36,32 @@
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
"lint": "pnpm stylelint && oxlint src browser_tests --type-aware && eslint src --cache",
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
"lint:desktop": "pnpm --filter @comfyorg/desktop-ui run lint",
"locale": "lobe-i18n locale",
"oxlint": "oxlint src browser_tests --type-aware",
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",
"storybook": "nx storybook",
"storybook:desktop": "nx run @comfyorg/desktop-ui:storybook",
"prepare": "pnpm exec husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "vite preview --config vite.config.mts",
"storybook": "storybook dev -p 6006",
"storybook:desktop": "pnpm --filter @comfyorg/desktop-ui run storybook",
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
"test:browser": "pnpm exec nx e2e",
"test:browser": "pnpm exec playwright test",
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:coverage": "vitest run --coverage",
"test:unit": "nx run test",
"test:unit": "vitest run",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
"typecheck:desktop": "nx run @comfyorg/desktop-ui:typecheck",
"typecheck:website": "nx run @comfyorg/website:typecheck",
"zipdist": "node scripts/zipdist.js",
"clean": "nx reset"
"typecheck:desktop": "pnpm --filter @comfyorg/desktop-ui run typecheck",
"typecheck:website": "pnpm --filter @comfyorg/website run typecheck",
"zipdist": "node scripts/zipdist.js"
},
"dependencies": {
"@alloc/quick-lru": "catalog:",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/fbx-exporter-three": "^1.0.1",
"@comfyorg/object-info-parser": "workspace:*",
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
@@ -112,7 +114,7 @@
"primevue": "catalog:",
"reka-ui": "catalog:",
"semver": "^7.7.2",
"three": "^0.170.0",
"three": "catalog:",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",
"vee-validate": "catalog:",
@@ -130,10 +132,6 @@
"@eslint/js": "catalog:",
"@intlify/eslint-plugin-vue-i18n": "catalog:",
"@lobehub/i18n-cli": "catalog:",
"@nx/eslint": "catalog:",
"@nx/playwright": "catalog:",
"@nx/storybook": "catalog:",
"@nx/vite": "catalog:",
"@pinia/testing": "catalog:",
"@playwright/test": "catalog:",
"@sentry/vite-plugin": "catalog:",
@@ -179,7 +177,6 @@
"markdown-table": "catalog:",
"mixpanel-browser": "catalog:",
"monocart-coverage-reports": "catalog:",
"nx": "catalog:",
"oxfmt": "catalog:",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",
@@ -210,20 +207,7 @@
},
"engines": {
"node": "24.x",
"pnpm": ">=10"
"pnpm": ">=11"
},
"packageManager": "pnpm@10.33.0",
"pnpm": {
"overrides": {
"vite": "catalog:"
},
"ignoredBuiltDependencies": [
"@firebase/util",
"core-js",
"protobufjs",
"sharp",
"unrs-resolver",
"vue-demi"
]
}
"packageManager": "pnpm@11.1.1"
}

View File

@@ -19,12 +19,5 @@
"devDependencies": {
"tailwindcss": "catalog:",
"typescript": "catalog:"
},
"packageManager": "pnpm@10.17.1",
"nx": {
"tags": [
"scope:shared",
"type:design"
]
}
}

View File

@@ -15,12 +15,5 @@
},
"devDependencies": {
"@hey-api/openapi-ts": "0.93.0"
},
"packageManager": "pnpm@10.17.1",
"nx": {
"tags": [
"scope:shared",
"type:types"
]
}
}

View File

@@ -20,11 +20,5 @@
"devDependencies": {
"typescript": "catalog:",
"vitest": "catalog:"
},
"nx": {
"tags": [
"scope:shared",
"type:util"
]
}
}

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