Compare commits

...

47 Commits

Author SHA1 Message Date
christian-byrne
17cae6497a chore(website): refresh Ashby and cloud nodes snapshots 2026-05-14 14:48:03 +00:00
Christian Byrne
f176d18fe0 fix(website): refresh cloud nodes snapshot in release workflow + strict production builds (#12219)
*PR Created by the Glary-Bot Agent*

---

## Summary

`Release: Website` only refreshed the Ashby snapshot, so the cloud-nodes
snapshot (`apps/website/src/data/cloud-nodes.snapshot.json`) was stale
on every release. `loadPacksForBuild()` then silently fell back to that
snapshot because `WEBSITE_CLOUD_API_KEY` was never plumbed through CI or
Vercel, leaving production at `/cloud/supported-nodes` with placeholder
data (e.g. `rgthree-comfy` listed as supported when it isn't — visible
at line 104 of the committed snapshot, last fetched 2026-05-04).

## Changes

- **New composite action `.github/actions/cloud-nodes-pull`** mirroring
`ashby-pull`: runs `pnpm --filter @comfyorg/website
cloud-nodes:refresh-snapshot` with `WEBSITE_CLOUD_API_KEY`. The script
already `process.exit(1)`s on any non-`fresh` outcome, so refresh
failures are loud.
- **`release-website.yaml`** now runs both refreshes and opens a single
PR with both updated snapshots. Renamed the job to `refresh-snapshots`,
updated branch/commit/title/body for the wider scope, and kept the
existing `Release:Website` label so downstream automation is unaffected.
- **`cloudNodes.build.ts`** throws when the outcome is `'stale'` **and**
`VERCEL_ENV === 'production'`. Preview / local builds keep the snapshot
fallback so contributors without key access are unaffected. The CI
reporter still runs first so the GitHub annotation explaining *why* it's
stale is visible in the failed job.
- **`ci-vercel-website-preview.yaml`**: passes `WEBSITE_CLOUD_API_KEY`
to `vercel build` in both preview and production jobs, and adds a
preflight step on `deploy-production` that hard-fails before `vercel
build --prod` if the secret is missing — surfacing config drift with a
maintainer-friendly error annotation instead of mid-build.
- **`apps/website/README.md`**: documents the production-strictness
behavior, the new required secret (GitHub Actions + Vercel env), and the
manual refresh path.
- **New unit tests** in `cloudNodes.build.test.ts` (6 cases): fresh,
stale-no-VERCEL_ENV, stale-on-preview, stale-on-production,
failed-regardless, and "still reports on stale-in-production before
throwing".

## Manual / one-time steps required before merging

This PR cannot finish the job alone. A maintainer must also:

1. Add `WEBSITE_CLOUD_API_KEY` as a **GitHub Actions repo secret** in
`Comfy-Org/ComfyUI_frontend`.
2. Add `WEBSITE_CLOUD_API_KEY` to the **Vercel project environment**
(`production` env at minimum; `preview` recommended).
3. Investigate why `rgthree-comfy` is in the current snapshot — either
the Cloud API was actually returning it on 2026-05-04, the snapshot was
generated against a non-production environment, or it was hand-edited.
The first manual run of `Release: Website` after this PR merges will
confirm.

Without step 1, the new `Release: Website` job will fail loudly (the
refresh script exits 1 with `missing WEBSITE_CLOUD_API_KEY`). Without
step 2, the new preflight will fail the production deploy with a clear
error annotation pointing at `apps/website/README.md`. Both failure
modes are intentional — they replace today's silent stale snapshot.

## Related (out of scope for this PR)

The other half of the original report — production 404s on
`/p/supported-models/*`, `/cloud/supported-nodes/*`,
`/demos/community-workflows` from PRs #11892 / #11903 / #11942 — is a
`comfy-router` allow-list gap (those paths exist in the Vercel build as
pre-rendered static HTML). That fix needs to land in
`Comfy-Org/comfy-router` and is being handled separately since glary
doesn't have access to that repo.

## Verification

- `pnpm --filter @comfyorg/website test:unit` — 75/75 pass (6 new in
`cloudNodes.build.test.ts`)
- `pnpm --filter @comfyorg/website typecheck` — 0 errors, 0 warnings (2
pre-existing hints unrelated to this PR)
- `pnpm format` + `pnpm exec eslint` on changed files — clean
- `js-yaml` validates `release-website.yaml`,
`cloud-nodes-pull/action.yaml`, `ci-vercel-website-preview.yaml`
- Oracle code review (round 1) raised 1 warning + 1 suggestion; both
addressed in commit 2.

**Manual verification not applicable**: the runtime changes are GitHub
Actions workflows and a Vercel-env-gated branch in a build-time module —
they cannot meaningfully run outside of GitHub Actions / Vercel, and the
strict-on-stale path is exhaustively covered by the 6 unit tests
(including the exact assertions a manual run would check: throws on
`VERCEL_ENV=production` + stale, passes on preview, reports
observability annotation before throwing). The end-to-end behavior will
be verified by the first `Release: Website` dispatch and the next
production deploy after the maintainer adds the secret.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12219-fix-website-refresh-cloud-nodes-snapshot-in-release-workflow-strict-production-build-35f6d73d3650816d8f32d403cb39d733)
by [Unito](https://www.unito.io)

---------

Co-authored-by: glary <bot@glary.dev>
2026-05-14 09:49:00 +00:00
Christian Byrne
01742672bb feat: extend version warning to all comfy_package_versions entries (#12167)
*PR Created by the Glary-Bot Agent*

---

## Summary

Extend the existing frontend version-mismatch warning UI to consume the
new `comfy_package_versions` array now exposed by ComfyUI's
`/system_stats` endpoint. The backend ships installed/required versions
for every `comfy*` package pinned in `requirements.txt` (frontend,
workflow-templates, embedded-docs, kitchen, aimdo, …); the frontend now
surfaces one toast per outdated package, reusing the existing N=1
frontend-version warning shape.

Backend PR: https://github.com/Comfy-Org/ComfyUI/pull/13875

## Changes

- `src/schemas/apiSchema.ts` — add `comfy_package_versions: Array<{name,
installed, required}>` to the `SystemStats` schema and export a
`ComfyPackageVersion` type. The field is `.optional()` so older backends
remain compatible.
- `src/platform/updates/common/versionCompatibilityStore.ts` — new
`outdatedComfyPackages` / `packageWarningMessages` computeds that mirror
the existing semver `gt` comparison (same `valid` guard). Skip
`comfyui-frontend-package` because the dedicated frontend warning above
already covers it (and uses the running bundle's version rather than the
installed pip version). Outdated packages are sorted by
`name`/`installed`/`required` before being folded into the dismissal
storage key so the key is stable across response orderings — a fresh
package bump re-shows the warning, but the same outdated set in a
different order does not.
- `src/platform/updates/common/useFrontendVersionMismatchWarning.ts` —
emit one toast per outdated package in addition to the existing frontend
toast, reusing the same i18n wrapper and the existing `hasShownWarning`
once-per-session guard.
- `src/locales/en/main.json` — new `g.comfyPackageOutdated` string.
- Unit tests — added coverage for outdated/skip/invalid-version paths,
dismissal-key inclusion, and stable ordering. Existing 21 store + 8
composable tests untouched and still passing.

CI suppression is unchanged: warnings still gate on
`versionCompatibilityStore.shouldShowWarning` (which respects the
`Comfy.VersionCompatibility.DisableWarnings` setting and the 7-day
dismissal cache), and unit tests continue to mock the store the same
way.

## Verification

- `pnpm vitest run` for the version-warning module: **31/31** passing.
- Targeted sweep across `src/platform/updates`,
`src/stores/systemStatsStore.test.ts`, `src/schemas`: **160/160**
passing.
- `pnpm typecheck`: clean.
- `pnpm lint`: 0 errors (3 pre-existing warnings in unrelated 3D test
files).
- `pnpm format`: applied, no incidental changes.
- **Manual Playwright run** against the real dev server with
`/api/system_stats` intercepted to return outdated package data —
produced exactly the expected toasts and correctly skipped
`comfyui-frontend-package` and the up-to-date `comfy-kitchen` entry.
Same run with all-up-to-date data produced zero toasts.

### Toasts produced (manual verification)

The fixture used: `required_frontend_version=99.99.99`, plus
`comfy_package_versions=[frontend-package (outdated, skipped),
workflow-templates 0.9.0→0.9.5 (outdated), embedded-docs 0.4.0→0.5.0
(outdated), comfy-kitchen 0.2.8→0.2.8 (up to date)]`.

## Screenshots

![Three version warning toasts displayed: outdated frontend (1.45.1 <
99.99.99), outdated workflow-templates (0.9.0 < 0.9.5), and outdated
embedded-docs (0.4.0 < 0.5.0). The skipped comfyui-frontend-package
entry and the up-to-date comfy-kitchen entry produced no extra
toasts.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/986a510ae8e587d3ced862efffa622d9d476c40b9ac1423b89f56e85c0ce51c1/pr-images/1778562152770-11307c64-2863-41af-a42d-1c42fb332ca6.png)

![Zoomed view of the three Alert toasts in the top-right toast
stack.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/986a510ae8e587d3ced862efffa622d9d476c40b9ac1423b89f56e85c0ce51c1/pr-images/1778562153211-df80e3b5-48d5-416e-a11f-3a6d25b6618c.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12167-feat-extend-version-warning-to-all-comfy_package_versions-entries-35e6d73d365081e7b993d0f06c9e5c98)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-14 08:24:32 +00:00
Christian Byrne
b7fe0365af fix: remove "400 Free Credits Monthly" line from cloud paywall modal (#12251)
*PR Created by the Glary-Bot Agent*

---

## Summary

The in-app cloud paywall modal shown on first install of ComfyUI Desktop
1.0 advertised "400 Free Credits Monthly" as a benefit, but the free
tier is currently disabled, making that copy misleading.

Per the thread direction, this skips the feature-flag plumbing (which
isn't available on Desktop anyway) and simply removes the line.

## Changes

- `CloudNotificationContent.vue`: change the feature loop from `n in 4`
to `n in [2, 3, 4]` so feature1 is skipped.
- `src/locales/*/main.json` (12 locales): remove the now-unused
`cloudNotification.feature1Title` key.
- `browser_tests/tests/dialog.spec.ts`: add a regression assertion that
"400" / "Free Credits" copy is no longer present in the modal.

## Test plan

- `pnpm typecheck` — passes
- `pnpm typecheck:browser` — passes
- `pnpm exec vitest run
src/platform/cloud/notification/components/DesktopCloudNotificationController.test.ts`
— 3/3 pass
- Pre-commit hooks (oxfmt, oxlint, eslint, stylelint, typecheck) — all
pass
- Manual: triggered `showCloudNotification()` against the dev server and
confirmed only 3 benefit rows render (screenshot attached); the "400
Free Credits Monthly" line is gone.

- Fixes DESK2-90

## Screenshots

![Cloud paywall modal after fix — shows 3 benefits without the '400 Free
Credits Monthly'
line](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/d96d51ee1f37a5138014eeff4c46a901e5178c013e0412f288e49c17bddb88e4/pr-images/1778728094191-423f01df-f004-4ae5-a939-7d28413f6fbd.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12251-fix-remove-400-Free-Credits-Monthly-line-from-cloud-paywall-modal-3606d73d365081eaa572e6cd995278d8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-13 22:39:32 -07:00
Dante
bdb92c845e fix: include share_id when importing published assets (FE-603) (#12055)
*PR Created by the Glary-Bot Agent*

https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1778150003248989
FE-603
---

## Summary

Send `share_id` alongside `published_asset_ids` from
`workflowShareService.importPublishedAssets`, and type / zod-validate
the body against `ImportPublishedAssetsRequest` +
`zImportPublishedAssetsRequest` from `@comfyorg/ingest-types`.

This is a **parameter-schema change**, not a live bug fix. The original
`BAD_REQUEST: share_id is required` failure is **no longer reproducible
in production** — the backend rolled back the required-field enforcement
in [BE-855](https://linear.app/comfyorg/issue/BE-855) (cloud
[#3587](https://github.com/Comfy-Org/cloud/pull/3587) /
[#3588](https://github.com/Comfy-Org/cloud/pull/3588)). Today the import
succeeds without `share_id`.

- Closes FE-603

## Why we still ship this

Frontend goes first so the backend can later re-tighten the contract
without breaking shared-workflow imports a second time. Agreed sequence
in
[Slack](https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1778535349236419)
— BE-6 was split into two sub-issues to make the chain explicit:

1. **[BE-898](https://linear.app/comfyorg/issue/BE-898)** (blocks
FE-603) — BE makes `share_id` **optional**, validating when present.
[cloud PR #3633](https://github.com/Comfy-Org/cloud/pull/3633), In
Review.
2. **FE-603** (this PR) — FE sends `share_id`.
3. **[BE-899](https://linear.app/comfyorg/issue/BE-899)** (blocked by
FE-603) — BE flips `share_id` back to **required** after FE-603 deploys.

Steps 1 and 2 ship in order: BE-898 → FE-603 → BE-899.

## Changes

- `workflowShareService.importPublishedAssets` now takes `shareId:
string` and types the request body with `ImportPublishedAssetsRequest`.
The body is parsed through `zImportPublishedAssetsRequest` before the
network call so future contract drift surfaces as a typecheck or zod
parse failure rather than a silent runtime 400.
- `useSharedWorkflowUrlLoader.ts` threads `payload.shareId` (already in
scope from `SharedWorkflowPayload`) into the import call.
- Unit tests assert `share_id` is sent and that an empty `share_id` is
rejected before fetch.

## Verification

- `pnpm typecheck` — clean
- `pnpm lint` — 0 errors / 0 warnings on changed files (3 pre-existing
warnings in unrelated files)
- `pnpm format` — applied
- `pnpm vitest run` on both affected files — 34/34 passing

### Live in-browser smoke test (Vite dev server)

Loaded the built module in the browser, intercepted `fetch`, and
exercised the new code path. The new `importPublishedAssets` produces
the correct wire format and zod rejects bad input before fetch:

```json
// Happy path — sent to /api/assets/import (POST)
{
  "published_asset_ids": ["pa-1", "pa-2", "pa-3"],
  "share_id": "share-abc"
}
```

```text
// Empty share_id — zod rejects, fetch is never called
[ { code: "too_small", minimum: 1, path: ["share_id"], message: "String must contain at least 1 character(s)" } ]
fetchCallsBetweenAttempts: 0
```

## Why typecheck didn't catch the original miss

`api.fetchApi(route: string, options?: RequestInit)` accepts the
standard DOM `RequestInit`, so `body` is just `BodyInit | null`. Once
the call site does `JSON.stringify({ ... })`, the inline object is
erased into a string and TS has no schema to enforce. There was no
`@ts-ignore` or `as any` — the generated types were simply never
imported. This PR plugs that one call site; the same pattern should be
applied wherever the frontend hits ingest endpoints (the broader hey-zod
migration gap mentioned in #bug-dump).

Reported in #bug-dump.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12055-fix-include-share_id-when-importing-published-assets-3596d73d365081c0a1c7e69102f5cfcc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-14 04:13:16 +00:00
Christian Byrne
d96be3d668 feat(#3410): add centralized assert() utility in src/base/ (#11824)
## Summary

Add a shared `assert(condition, message)` utility in `src/base/` that
centralizes DEV-throw / Desktop-Sentry / nightly-toast / `console.error`
policy for invariant reporting across the codebase.

## Changes

- **`src/base/assert.ts`**: New `assert()` utility with
`setAssertReporter()` registration pattern
  - `console.error` always fires on failure
  - Throws `Error` in DEV mode (surfaces bugs immediately)
  - Delegates to registered reporter otherwise (Sentry, toast, etc.)
- No imports from `platform/` — respects layer architecture (`base →
platform → workbench → renderer`)
- **`src/main.ts`**: Registers Sentry + nightly-toast reporter after
`Sentry.init()`
- **`src/scripts/changeTracker.ts`**: Migrates
`reportInactiveTrackerCall()` to use `assert()`, removing inline
`Sentry.captureMessage` + `console.warn` calls
- **`src/scripts/changeTracker.test.ts`**: Mocks `@/base/assert` to
prevent DEV-mode throws in existing no-op tests

## Testing

### Automated

- `src/base/assert.test.ts` — 6 tests covering: no-op on true,
console.error on false, DEV throw, non-DEV no-throw, reporter
invocation, reporter not called on true
- `src/scripts/changeTracker.test.ts` — 16 tests all pass (pre-existing)
- Coverage: 100% for assert.ts

### E2E Verification Steps

1. Run `pnpm test:unit` — all tests pass
2. Build the app and open browser devtools
3. In DEV mode: trigger a lifecycle violation (call an inactive tracker
method) — should see error thrown in console
4. In production build: same trigger — should see `console.error` only,
no throw

## Review Focus

- `setAssertReporter()` is called in `main.ts` once at startup —
appropriate for a singleton reporter. In tests that import `assert`, the
reporter is reset to a no-op in `afterEach`.
- Layer architecture respected: `base/assert.ts` has zero imports, upper
layers wire in side effects via `setAssertReporter()`.

Fixes #11373

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

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11824-feat-3410-add-centralized-assert-utility-in-src-base-3546d73d3650819d96afdf4018161c26)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-14 02:42:16 +00:00
Dante
1869416185 fix: surface error dialog when Open Workflow from Job Queue fails (FE-215) (#12071)
## Summary

`openJobWorkflow` (Job Queue → "Open Workflow in New Tab") had no error
handling around `workflowService.openWorkflow`. When workflow JSON
contained nodes that fail `LiteGraph.configure()` (e.g. rgthree
`DisplayAny`, stale `GetNode/SetNode aux_id`), the menu action appeared
to do nothing — either an early return after a generic "Load Workflow
Error" dialog (`app.ts:1340-1347`) or a context-less toast from the
surrounding `wrapWithErrorHandlingAsync`.

This PR catches the error inside `openJobWorkflow` and routes it to
`dialogService.showErrorDialog` with a Job-Queue-specific `reportType`
so users get a clear, actionable message tied to the action they
invoked.

- Fixes #8841
- Linear:
[FE-215](https://linear.app/comfyorg/issue/FE-215/open-workflow-from-frontend-not-working)

## Red-Green Verification

| Commit | CI Status | Run |
|--------|-----------|-----|
| `test: add failing test for openJobWorkflow swallowing load errors`
(a422b392d) | 🔴 failure |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25531063427 |
| `fix: surface error dialog when Open Workflow from Job Queue fails`
(86b2a3a9) | 🟢 success |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25531374096 |

## Test Plan

- [x] CI red on test-only commit
- [x] CI green on fix commit
- [ ] Manual: load a workflow containing rgthree `DisplayAny` from Job
Queue → "Open Workflow in New Tab" → confirm error dialog appears with
Job-Queue context instead of nothing
<img width="972" height="662" alt="Screenshot 2026-05-13 at 5 58 31 PM"
src="https://github.com/user-attachments/assets/bf1d8d96-85b7-47a8-89d8-b3bb69a526cb"
/>
2026-05-14 02:32:32 +00:00
jaeone94
a3106c4d53 fix: open node info panel from context menu (#12205)
## Summary

Replaces #12164.

Right-clicking a Vue node, using the selection toolbox More Options
menu, or clicking the selection toolbox Node Info button now opens the
right-side Info tab only when the new-menu UI makes that panel
available. Legacy-menu contexts hide the no-op action even when the
legacy node library design is selected; node-library help remains
isolated to the node library itself. The existing
`selection_toolbox_node_info_opened` telemetry fires only after the
toolbox button successfully opens node info. No new context-menu
telemetry event is added in this PR.

## Changes

- **What**: Share the node-info availability/action path across the
context menu and selection toolbox, keep legacy-menu state out of the
right-side panel public store API, tighten node-info settings tests, and
add unit plus E2E regression coverage for new-menu and legacy-menu
modes.
- **Dependencies**: None

## Review Focus

Confirm the node context menu, selection toolbox direct Info button, and
selection toolbox More Options entry all respect right-side panel
availability, including legacy menu + legacy node library mode, while
node-library help behavior remains isolated to the node library.

## Validation

- Self-review: checked production path, unit mocks, and Playwright
coverage; only gap found was weak E2E coverage for the toolbox direct
Info path, now strengthened.
- `pnpm test:unit -- src/composables/graph/useSelectionState.test.ts
src/components/graph/SelectionToolbox.test.ts
src/components/graph/selectionToolbox/InfoButton.test.ts`
- `pnpm test:browser:local -- --project=chromium
browser_tests/tests/selectionToolboxActions.spec.ts
browser_tests/tests/selectionToolboxSubmenus.spec.ts
browser_tests/tests/vueNodes/interactions/node/contextMenu.spec.ts
--grep "info button opens the right-side info tab|info button is
hidden|hides Node Info|should open node info"`
- `pnpm typecheck:browser`
- `pnpm exec oxlint --type-aware
browser_tests/tests/selectionToolboxActions.spec.ts`
- `pnpm exec eslint --cache --no-warn-ignored
browser_tests/tests/selectionToolboxActions.spec.ts`
- `pnpm exec oxfmt --check
browser_tests/tests/selectionToolboxActions.spec.ts`
- `git diff --check`
- Commit hooks: lint-staged + `pnpm typecheck` + `pnpm
typecheck:browser`
- Push hook: `knip --cache` (existing tag hint only)

## Screenshots (if applicable)
Before 


https://github.com/user-attachments/assets/4b1f6ddb-a01c-4958-81ab-36167f434e59


https://github.com/user-attachments/assets/83433f0d-24f1-46b7-a81d-f0f065812496

After 


https://github.com/user-attachments/assets/30bd61e5-f8d4-48b7-97e0-26c93e3cb362


https://github.com/user-attachments/assets/afce9f51-a43d-434f-a006-6b357a61ac8f

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-05-14 02:26:11 +00:00
Comfy Org PR Bot
b59b342a43 1.45.7 (#12238)
Patch version increment to 1.45.7

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12238-1-45-7-3606d73d36508138981ee641470344e4)
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-14 02:21:20 +00:00
jaeone94
95e616b894 fix: clear media upload errors via widget change (#12212)
## Summary

Clear missing media validation errors after paste/drop media uploads by
emitting the existing widget-change event path.

## Changes

- **What**: Emit `node.onWidgetChanged` after image/video upload
completion updates the file combo widget.
- **What**: Emit the same widget-change path after Load Audio upload
completion.
- **What**: Add unit coverage for upload completion emitting
`onWidgetChanged` and for missing media clearing through that existing
hook path.
- **What**: Add E2E coverage for Load Image drag/drop and paste clearing
validation rings, with red/green verified from a fresh `main` base.
- **Dependencies**: None.

## Review Focus

Please check that paste/drop upload paths now reuse the existing
widget-change error-clearing path instead of expanding `widget.callback`
patching.

Also check the Load Image E2E helper path for synthetic paste/drop
behavior.

Supersedes #12207.

Ref: FE-687

## Screenshots

Before 


https://github.com/user-attachments/assets/2cee52bc-b1c8-4dff-8a02-5b18a69ae639

After 


https://github.com/user-attachments/assets/e1ecd147-1d8a-470e-b77d-13345d473ef3



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12212-fix-clear-media-upload-errors-via-widget-change-35f6d73d365081bcb1a0dfc042d417eb)
by [Unito](https://www.unito.io)
2026-05-14 01:28:41 +00:00
Alexis Rolland
5738c7a539 Add SaveAudioAdvanced to whitelisted nodes (#12213)
## Summary

Add `SaveAudioAdvanced` to whitelisted nodes in order to display the
audio player. This PR goes with the core PR:
https://github.com/Comfy-Org/ComfyUI/pull/13871

## Changes

- **What**: Display audio player on new node `SaveAudioAdvanced`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12213-Add-SaveAudioAdvanced-to-whitelisted-nodes-35f6d73d3650815783fac52d3d37e1e1)
by [Unito](https://www.unito.io)
2026-05-14 00:10:06 +00:00
Christian Byrne
8abfa678a3 fix: harden e2e coverage workflow and fix GH Pages deploy (#11381)
## Problem

The GH Pages coverage deploy has been failing since #11291 merged —
every `CI: E2E Coverage` workflow run errors out and
https://comfy-org.github.io/ComfyUI_frontend/ returns 404.

Additionally, two correctness/security issues were identified in the
workflow (filed as #11374 and #11375).

## Changes

1. **`--ignore-errors source` on genhtml** — merged LCOV data includes
paths like `localhost-8188/assets/main-BRkC1B8m.js` from Playwright V8
coverage instrumented runtime bundles that don't exist as source files
in CI, causing genhtml to error out
2. **Pin checkout to `workflow_run.head_sha`** — in `workflow_run`
context, the default checkout ref points to the default branch, not the
commit that triggered the upstream run; genhtml could annotate against
wrong source files (#11375)
3. **Gate deploy on `event == 'push'`** — a fork branch named `main`
could satisfy the branch check and overwrite production coverage; adding
the event guard prevents this (#11375)
4. **Include workflow run link in placeholder HTML** — when no coverage
data is available, the placeholder page now links back to the workflow
run for debugging (#11374)

## Fixes

- Fixes the GH Pages 404 caused by #11291
- Fixes #11374
- Fixes #11375
2026-05-13 23:06:36 +00:00
Christian Byrne
b36b601a1c refactor(litegraph): prune dead surfaces (AUDIT-LG implementation, draft) (#12228)
Implements the AUDIT-LG verdicts (#12223, #12224, #12225) as a single
deletion PR off main.

> **DRAFT — sequencing.** Per the AUDIT-LG framing doc, deletions land
**after Phase B ECS migration** (Alex's #11939 + #11811). Open early to
capture the diff and let CI cascade-flag any unused-export fallout. Flip
to ready-for-review post-Alex-rebase.

## Status

| commit | scope | status |
|---|---|---|
| 1 | Delete 6 LGraph stepping hooks (`onAfterStep`, `onBeforeStep`,
`onPlayEvent`, `onStopEvent`, `onAfterExecute`, `onExecuteStep`) + their
dispatch sites in `start()`/`stop()`/`runStep()` |  landed (this
commit) |
| 2 | Delete the rest of the dead executor cluster
(`start()`/`stop()`/`runStep()`/`sendEventToAllNodes()` + state fields +
`STATUS_*` constants) | follow-up |
| 3 | Delete `LGraphNode` dead event hooks (~22 fields per #12224) |
follow-up |
| 4 | Delete trigger/action subsystem (~22 symbols, #12223) | follow-up
|
| 5 | `ON_EVENT` deprecation cycle, release N (#12225) | follow-up |

## Verification (commit 1)

```
$ pnpm lint && pnpm format:check && pnpm knip
✓ format: All matched files use the correct format
✓ lint: pre-existing icon-name warnings only
✓ knip: no new unused exports flagged
```

## Verdicts the deletion is grounded in

- AUDIT-LG.7 master verdict table (146 surfaces classified DELETE-NOW /
DEPRECATE / KEEP)
- AUDIT-LG.9 per-symbol attribution sweep (confirmed zero functional
callers for the trigger cluster + the dead hooks)

For each surface in this PR:

- **`internal_count` from rg over `src/`, `browser_tests/`, `packages/`
excluding `lib/litegraph/`:** 0
- **External use from touch-points DB:** 0 (per AUDIT-LG.3 + AUDIT-LG.9
per-symbol attribution)
- **Host methods (`start()`/`stop()`/`runStep()`):** `@deprecated 'Will
be removed in 0.9'` already

The dispatch sites (`this.onAfterStep?.()` etc.) are inside the
deprecated host methods — removing the dispatchers does not change
observable behaviour because no listener is attached to begin with.

## Why batched, why draft

Per AUDIT-LG framing, the deletion sequences behind Alex's PR #11939
(ECS world-combo). Opening as draft lets CI run early and flags any
unused-export cascades the audit script missed. Each follow-up commit
will be its own atomic deletion (one feature per commit) so any single
one can be reverted in isolation if needed.

cc @drjkl @christian-byrne

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12228-refactor-litegraph-prune-dead-surfaces-AUDIT-LG-implementation-draft-35f6d73d365081e0b72cd292228e2ca6)
by [Unito](https://www.unito.io)

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-13 23:03:32 +00:00
jaeone94
ad63f7cb9b test: cover missing media runtime sources (#12126)
## Summary

Adds browser coverage for the missing-media runtime paths introduced by
#12069 and #12111:

- OSS: annotated `[output]` media is resolved from job history.
- Cloud: compact `[output]` media is resolved from output assets.
- OSS and Cloud: dropped video uploads do not surface a missing-media
error while the upload is still in progress.

This PR is now rebased directly onto `main`; the parent fix PRs have
been squash-merged, so this branch only contains the E2E coverage
commit.

## Test Fixtures

- Adds workflow fixtures for OSS spaced output annotations and Cloud
compact output annotations.
- Adds a small plain MP4 fixture for video drag/drop upload coverage.

## Validation

- `pnpm exec oxfmt --check
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
browser_tests/assets/missing/missing_media_cloud_output_annotation.json
browser_tests/assets/missing/missing_media_output_annotations.json`
- `pnpm typecheck:browser`
- `pnpm exec oxlint
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
--type-aware`
- `git diff --check origin/main..HEAD`

Note: before the rebase, the Cloud project target for this spec passed
locally. Local OSS project execution against the currently running Cloud
dist did not reach the test body because `ComfyPage.waitForAppReady`
timed out in `beforeEach`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12126-test-cover-missing-media-runtime-sources-35d6d73d365081f0a981c02f33c0ff84)
by [Unito](https://www.unito.io)
2026-05-13 22:46:47 +00:00
Terry Jia
f7ef563b46 FE-657: prevent browser zoom on ctrl+wheel in mask editor (#12215)
## Summary

Wheel events on the mask editor pointer zone now call preventDefault,
matching the main canvas behavior so ctrl+wheel only zooms the mask
canvas instead of also triggering page zoom.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12215-FE-657-prevent-browser-zoom-on-ctrl-wheel-in-mask-editor-35f6d73d36508131a9b8dbf2f6640d72)
by [Unito](https://www.unito.io)
2026-05-13 18:24:22 -04:00
AustinMroz
9cc09cd46c Add additional subgraph test fixtures and tests (#11806)
- Adds functions to SubgraphHelper to perform widget promotion by
standard user means
  - Right Click -> Promote
  - Properties Panel
- Adds new slot fixture code that works with simple `locator.dragTo`
operations.
- Adds multiple subgraph tests with a focus on historically difficult
operations.
- Fixes a bug where the litegraph `node.selected` state would not be
unset when switching graphs. This made it so 'Selecting a node ->
leaving subgraph -> re-enter subgraph -> right click on node' would fail
to select the node because it is marked as already selected.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11806-Add-helper-functions-for-widget-promotion-3536d73d365081f58dd9cd730c1a91a9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-13 20:35:57 +00:00
pythongosssss
de1c1ee1f2 fix: add support for parsing python generated json with NaN/infinite (#12217)
## Summary

API and other legacy JSON generated by python `json.dumps` can contain
`NaN` and `Infinity` which cannot be parsed with JS `JSON.parse`. This
adds regex to replace these invalid tokens with `null`.

## Changes

- **What**: 
- add regex replace on bare NaN/infinity tokens after JSON.parse fails
- update call sites
- tests

## Review Focus
- The regex should only rewrite bare NaN/-Infinity/Infinity and not
touch string values or other invalid tokens.
- A small regex was chosen over JSON5 due to package size (30.3kB
Minified, 9kB Minified + Gzipped) or a manual parser due to the
unnecessarily complexity vs a single regex replace.
- The happy path is run first, the safe parse is only executed if that
failed, meaning no overhead the vast majority of the time and no
possiblity of corrupting valid workflows due to a bug in the fallback
parser
- Multiple call sites had to be updated due to pre-existing architecture
of the various parsers, an issue for unifying these is logged for future
cleanup
- New binary fixtures added for validating e2e import using real files

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12217-fix-add-support-for-parsing-python-generated-json-with-NaN-infinite-35f6d73d365081889fc7f4af823f29c1)
by [Unito](https://www.unito.io)
2026-05-13 20:33:19 +00:00
AustinMroz
86b1e1a965 Fix descriptions on core blueprints (#12220)
Core blueprints were storing the description under a different key than
expected, which resulted in them displaying a placeholder description.
When initializing the description for a subgraph, this alternative field
is also checked.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/ed51c4a8-00cf-4927-9cba-880532a9e926"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/f19bf80d-adcc-4e9b-a9ba-a5ac8e089e2d"
/>|

Resolves FE-681

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12220-Austin-blueprint-descriptions-35f6d73d3650812fa04df48c203bebd1)
by [Unito](https://www.unito.io)
2026-05-13 20:30:45 +00:00
pythongosssss
4321013798 fix: resolve widget input link position drift on reload (#12214)
## Summary
The position of a link relative to its slot was able to drift on load,
due to widgets inside a node being able to resize without triggering an
node-level resize event (min-height node with space at the bottom could
have widgets expand into free space, causing misalignment).

Recreation:
1. Add KSampler
2. Add Float
3. Connect Float to KSamper.denoise
4. Reload workflow (F5)
5. Observe misalignment

## Changes

- **What**: 
- track widget grid element as signal only that triggers resync
- node bound calculations skipped for widget signals
- prevent setDirty on non-graph nodes (e.g. LGraphNodePreview)
- tests

## Review Focus
This is a small focused approach to fix the reported issue - it does not
address the underlying issue of the layout not being a SSOT. This fix is
a small bandaid and investigation into resolving the layout SOT issue is
not impacted by this.

## Screenshots (if applicable)

Before:
<img width="673" height="374" alt="image"
src="https://github.com/user-attachments/assets/2d34b8e3-0731-4fd2-8553-4dd429010ced"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12214-fix-resolve-widget-input-link-position-drift-on-reload-35f6d73d3650814eb31bebb3042ff58b)
by [Unito](https://www.unito.io)
2026-05-13 20:00:50 +00:00
pythongosssss
7ce0973386 fix: prevent first user template popup when following shared link (#12024)
## Summary

When a user who has not used the app before first loads up, they are
presented with the template selection dialog. This conflicts when the
first-time user visits the app via a share link - both the share &
template dialog are triggered.

## Changes

- **What**: 
- Skip the templates browser when share param is in URL
- Tests
- Add `url` to `setup`/`goto` to allow specifying the `share` parameter

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12024-fix-prevent-first-user-template-popup-when-following-shared-link-3586d73d365081cbbcecdba45a1ad1ea)
by [Unito](https://www.unito.io)
2026-05-13 19:19:54 +00:00
Yourz
6e9be7b164 feat: add Anthropic partner icon (#12216)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds the Anthropic logo to the partner-node icon set so nodes whose
category ends in `Anthropic` (e.g. the Claude node added in
Comfy-Org/ComfyUI#13867 with `category="api node/text/Anthropic"`)
render the correct provider badge in the node library.

## Changes

- `packages/design-system/src/icons/anthropic.svg` — new auto-discovered
partner icon (Anthropic A glyph, sourced from
[lobehub/lobe-icons](https://github.com/lobehub/lobe-icons), uses
`fill="currentColor"` for theme adaptation)
- `src/utils/categoryUtil.ts` — register Anthropic's brand coral
`#D97757` as the badge border color
- `packages/design-system/src/css/style.css` — add `anthropic` to the
dynamic comfy-icon safelist so Tailwind/Iconify emits CSS for
`icon-[comfy--anthropic]` in production builds
- `src/utils/categoryUtil.test.ts` — regression tests for
`getProviderIcon('Anthropic')` and `getProviderBorderStyle('Anthropic')`

## Verification

- `pnpm typecheck` ✓
- `pnpm lint` ✓ (0 errors; 3 pre-existing warnings in unrelated files)
- `pnpm format:check` ✓
- `pnpm test:unit -- src/utils/categoryUtil.test.ts` ✓ (13/13)
- `pnpm build` ✓ — confirmed `comfy--anthropic` class is emitted into
`dist/assets/index-*.css`
- Manual visual check via Playwright against `pnpm dev`: injected `<i
class="icon-[comfy--anthropic]">` elements at badge size (10px) and 48px
alongside the existing OpenAI and BFL icons and confirmed the Anthropic
"A" glyph renders correctly in coral. See screenshot.

End-to-end visual verification of the live badge in the node library
requires Comfy-Org/ComfyUI#13867 to land first (the Claude node is what
produces the `Anthropic` category that triggers the icon lookup).

Related: Comfy-Org/ComfyUI#13867

## Screenshots

![Anthropic icon rendered in coral alongside OpenAI and BFL partner
icons at badge and large
sizes](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/c0076decedd8863eec6253b44e583da6b3eaacc20081d126aaf5267c72c8cc84/pr-images/1778683078329-49e37a7b-86ed-4ef2-988f-5702433f8412.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12216-feat-add-Anthropic-partner-icon-35f6d73d36508133a134fcafaf72f4f8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-13 17:01:49 +00:00
Terry Jia
4b5b184cad FE-566: fix Painter mask submission edge cases on cloud (#12196)
## Summary
Rework the painter always hands the backend a valid asset reference:
- Drop the `hasStrokes` flag and the `isCanvasEmpty` check.
- `serializeValue` falls back to the existing `modelValue` when the
canvas element is transiently unmounted, reuses the cached upload when
not dirty and a value is present, and otherwise uploads the current
canvas (a fully transparent PNG is a valid no-op mask, Painter's Python
`execute()` treats painter_alpha=0 the same as "no mask painted").
- `handleClear` now also clears `modelValue` so a user-initiated clear
doesn't resurrect a stale upload on the next serialize.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12196-FE-566-fix-Painter-mask-submission-edge-cases-on-cloud-35e6d73d365081dd8856ddb785952526)
by [Unito](https://www.unito.io)
2026-05-13 10:19:39 -04:00
pythongosssss
129bfd9f1b fix: fix drop location and zindex of dragged in images (#12194)
## Summary

Images dragged into the canvas were placed at the last graph mouse
position, which is not updated during the drag event - meaning nodes
were created in "random" locations. Additionally, the z-index was not
set so newly created nodes can appear under other nodes.

## Changes

- **What**: 
- ensure added nodes are at top level
- update graph mouse pos with position from drop event
- tests

## Screenshots (if applicable)


Before / After

https://github.com/user-attachments/assets/34b4652e-a834-4c22-b191-2875a2404ac5

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12194-fix-fix-drop-location-and-zindex-of-dragged-in-images-35e6d73d3650814781edc9f4b4b5b223)
by [Unito](https://www.unito.io)
2026-05-13 10:35:16 +00:00
Christian Byrne
e60ae14bc0 feat(website): hide Free tier behind SHOW_FREE_TIER flag (#12165)
*PR Created by the Glary-Bot Agent*

---

## Summary

Disables the Free tier on the public marketing website
(`comfy.org/cloud/pricing` and `comfy.org/cloud`) behind a single
boolean flag so re-enabling is a one-line change.

The Free tier was already removed from the Comfy Cloud sign-up flow;
this PR removes the matching promotional surfaces on the marketing site
so users hit the paywall directly.

## Changes

- **New** `apps/website/src/config/features.ts` — exports
`SHOW_FREE_TIER` (currently `false`). Flip to `true` to restore the
previous UX.
- **`apps/website/src/components/pricing/PriceSection.vue`** — when
`SHOW_FREE_TIER` is `false`:
  - drops the Free plan card from the pricing array
  - desktop grid collapses from `lg:grid-cols-4` to `lg:grid-cols-3`
- Standard plan's "Everything in Free, plus:" intro is replaced with an
aria-hidden spacer so the three remaining cards stay vertically aligned
- **`apps/website/src/components/product/cloud/PricingSection.vue`** —
hides the "Start free. Upgrade when you're ready." tagline on the
`/cloud` pricing teaser.
- **New** `apps/website/e2e/pricing.spec.ts` — three @smoke tests
asserting the paid tiers + Enterprise are visible and that all Free-tier
surfaces are absent.

All translation strings (`pricing.plan.free.*`, `cloud.pricing.tagline`)
are retained so re-enabling requires no copy work.

## Verification

- `pnpm typecheck` — clean (0 errors, 0 warnings; pre-existing hint
unrelated)
- `pnpm lint` / `oxfmt` — clean
- `pnpm test:unit` — 30/30 passing
- Playwright e2e (desktop project) — `pricing.spec.ts` 3/3 passing,
`cloud.spec.ts` 13/13 still passing
- Visual: desktop and mobile pricing pages render with balanced 3-column
layout; `/cloud` teaser card still proportional.

Screenshot below.

## Screenshots

![/cloud/pricing desktop with Free tier hidden: balanced 3-column layout
(Standard, Creator, Pro) and Enterprise section
below](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/732492b43e4794dae051b2b86643e7a5d3daebce028765f27470561681d4f1db/pr-images/1778561413584-9c1a82d1-781f-49d4-9a26-cc7523c903c4.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12165-feat-website-hide-Free-tier-behind-SHOW_FREE_TIER-flag-35e6d73d36508164b4dfcfe9fee6b5e7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-13 05:40:09 +00:00
Comfy Org PR Bot
b172534f55 [chore] Update Ingest API types from cloud@9619326 (#12202)
## Automated Ingest API Type Update

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

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

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

---------

Co-authored-by: MillerMedia <7741082+MillerMedia@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-13 03:47:03 +00:00
Comfy Org PR Bot
a0150ffe17 1.45.6 (#12204)
Patch version increment to 1.45.6

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12204-1-45-6-35f6d73d365081fc8539ca25a55aac74)
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-13 02:01:42 +00:00
Christian Byrne
c92030b158 refactor: deduplicate Civitai hostname logic in getSourceName (#11822)
## Summary

- Extract `isCivitaiHost` private helper from `isCivitaiModelUrl` in
`formatUtil.ts` for DRY hostname checking
- Add `isCivitaiUrl` exported function for hostname-only Civitai URL
detection (distinct from `isCivitaiModelUrl` which also validates the
path format)
- Refactor `getSourceName` in `assetMetadataUtils.ts` to use the shared
`isCivitaiUrl` instead of inline duplicate hostname checks
- Add tests for `isCivitaiUrl` covering `.com`, `.red`, subdomain, and
invalid URL cases

## Changes

- `packages/shared-frontend-utils/src/formatUtil.ts` — add
`isCivitaiHost` private helper + export `isCivitaiUrl`; refactor
`isCivitaiModelUrl` to use helper
- `packages/shared-frontend-utils/src/formatUtil.test.ts` — add
`isCivitaiUrl` test suite
- `src/platform/assets/utils/assetMetadataUtils.ts` — import
`isCivitaiUrl` from `@/utils/formatUtil`; remove inline hostname logic
from `getSourceName`

## Testing

### Automated

- Added `isCivitaiUrl` test suite (6 cases: `.com`, `.red`, subdomains,
non-Civitai, invalid URL)
- All 71 existing `formatUtil` tests pass
- All 53 existing `assetMetadataUtils` tests pass (behavior preserved)
- TypeScript typecheck passes

### E2E Verification Steps

1. Run unit tests: `npx vitest run
packages/shared-frontend-utils/src/formatUtil.test.ts
src/platform/assets/utils/assetMetadataUtils.test.ts`
2. Expected: all tests pass
3. Verify `getSourceName('https://civitai.red/models/123')` returns
`'Civitai'`
4. Verify `isCivitaiUrl('https://civitai.com/models/any-path')` returns
`true`
5. Verify `isCivitaiModelUrl` still rejects non-API paths while
`isCivitaiUrl` accepts them

## Review Focus

`isCivitaiUrl` (new, hostname-only) vs `isCivitaiModelUrl` (existing,
hostname+path format): `getSourceName` needs to recognize ANY Civitai
URL as a source, so using `isCivitaiModelUrl` directly would incorrectly
reject valid browse URLs like `civitai.com/models/123`.

Closes #11357

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11822-refactor-deduplicate-Civitai-hostname-logic-in-getSourceName-3546d73d36508110974ccc3b7384d82b)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:33:33 -07:00
Dante
988d532467 fix(queue): contain JobDetailsPopover error message overflow (#12173)
## Summary

Cap the Job Details "Error message" block at `max-h-96` (24rem / 384px)
with an internal scroll, wrap long unbreakable tokens (filenames, JSON),
and preserve newlines so the failed-job popover no longer grows
unbounded.

## Changes

- **What**: Added `max-h-96 overflow-y-auto whitespace-pre-wrap
wrap-break-word` to the error message container in
`JobDetailsPopover.vue`, plus a `FailedWithLongError` Storybook story
covering the overflow case.

## Review Focus

- 24rem cap was set per Alex's spec in the [Slack
thread](https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1778506109115989).
- `wrap-break-word` (Tailwind 4 canonical of `break-words`) is needed
because long underscore-joined filenames don't break naturally;
`whitespace-pre-wrap` preserves any newlines in the raw error.
- Not in scope: the popover z-index clipping issue Alex flagged later in
the same thread — that's a separate follow-up.

Fixes FE-660

## Screenshots

**Before** — error block grows unbounded with the panel:


![before](https://github.com/Comfy-Org/ComfyUI_frontend/raw/jaewon/fe-660-contain-textarea-overflow-and-cap-its-max-height/.github/pr-images/fe-660-before.png)

**After** — error block capped at 384px and internally scrollable:


![after](https://github.com/Comfy-Org/ComfyUI_frontend/raw/jaewon/fe-660-contain-textarea-overflow-and-cap-its-max-height/.github/pr-images/fe-660-after.png)

Reproduce locally via Storybook: `pnpm storybook` → Queue →
JobDetailsPopover → **FailedWithLongError**.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12173-fix-queue-contain-JobDetailsPopover-error-message-overflow-35e6d73d3650812d9873e5d163cad0c6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-05-12 17:51:52 -07:00
AustinMroz
fe08ad2fcd Fix pre-commit linter skipping type checks (#12203)
Adds the `--type-aware` so that the typechecks performed by precommit
hooks have parity with the results output by a full `pnpm lint`

Most notably, unawaited promises would not be caught by the precommit
hooks prior to this PR.
```
    × typescript-eslint(no-floating-promises): Promises must be awaited, add void operator to ignore.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       
      ╭─[browser_tests/fixtures/utils/vueNodeFixtures.ts:45:5]                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              
   44 │   async select() {                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  
   45 │     this.header.click()                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             
      ·     ───────────────────                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             
   46 │   }                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 
      ╰──── 
```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12203-Fix-pre-commit-linter-skipping-type-checks-35e6d73d365081a4adade833294df7ed)
by [Unito](https://www.unito.io)
2026-05-12 23:56:12 +00:00
Alexander Brown
93edf166d0 fix(website): link careers page to Ashby job description, not application form (#12200)
*PR Created by the Glary-Bot Agent*

---

## Summary

The careers page at comfy.org/careers was linking every role to its
Ashby application form (`.../{id}/application`) instead of the job
description page (`.../{id}/`). Users expect to first read the role
description, not land on the submit-resume page.

Ashby's job board API returns both `jobUrl` (description) and `applyUrl`
(application form). `toDomainRole` was preferring `applyUrl`; this PR
switches to `jobUrl` and renames the `Role` field accordingly so the
field name matches its meaning.

## Changes

- `apps/website/src/utils/ashby.ts`: use `job.jobUrl` directly instead
of `job.applyUrl ?? job.jobUrl`.
- `apps/website/src/data/roles.ts`: rename `Role.applyUrl` →
`Role.jobUrl`.
- `apps/website/src/components/careers/RolesSection.vue`: update the `<a
:href>` binding.
- `apps/website/src/data/ashby-roles.snapshot.json`: regenerated
fallback snapshot — URLs stripped of `/application`, `id`s recomputed
from the new URLs.
- Unit + E2E tests updated; new E2E assertion that links do not end in
`/application` prevents regressions.

The Ashby schema (`ashby.schema.ts`) still accepts `applyUrl` since the
API returns it — we just no longer consume it.

## Verification

- `pnpm test:unit` — 70/70 pass
- `pnpm typecheck` — 0 errors
- `pnpm build` — succeeds; inspected `dist/careers/index.html`, all 19
Ashby links now point to description URLs and zero contain
`/application`
- Oracle code review — 0 issues

Fixes user report in #hiring-ideas (Slack).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12200-fix-website-link-careers-page-to-Ashby-job-description-not-application-form-35e6d73d3650815cbedadf974f7d3364)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-12 23:32:15 +00:00
Dante
9fe19a2afb fix(settings): unify settings item heights and use 14px label text (#12180)
## Summary

Body text in the settings dialog was still rendering at the inherited
16px (browser default) instead of the 14px design spec, and rows with
different control types (toggle, slider, dropdown, radio) collapsed to
different heights — making the list look uneven and cramped.

## Changes

- **What**: `FormItem` label now uses `text-sm` (14px) and the row
enforces `min-h-8` (32px) so toggle/slider/dropdown/radio rows align.
`SettingGroup` bumps inter-item margin from `mb-2` to `mb-3` for
breathing room between settings.

## Review Focus

`FormItem` is also used by `ServerConfigPanel`, so the 14px/32px row
also applies there — consistent with the same settings-dialog visual
language, but worth a glance.

Fixes #FE-525

## Screenshots

Lite Graph panel (1280×900 viewport) showing
toggle/slider/dropdown/radio rows side-by-side:

| Before (`origin/main`) | After (this PR) |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/pr-12180-screenshots/before-litegraph.png"
width="480"> | <img
src="https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/pr-12180-screenshots/after-litegraph.png"
width="480"> |

Before: label text inherits 16px from `<body>`; toggle-only rows (e.g.
"Always snap to grid", "Live selection") shrink to ~24px while
dropdown/slider rows stay ~32px, so the list looks uneven and cramped.
After: labels are 14px; every row is at least 32px tall so
toggles/sliders/dropdowns/radios line up; `mb-3` adds 4px of breathing
room between rows.

## References

- Linear:
https://linear.app/comfyorg/issue/FE-525/verify-settings-text-size-and-item-heights
- Figma:
https://www.figma.com/design/vALUV83vIdBzEsTJAhQgXq/Comfy-Design-System?node-id=6290-75412
- Origin thread:
https://comfy-organization.slack.com/archives/C075ANWQ8KS/p1777657610484679?thread_ts=1776808927.654249

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-05-12 20:33:05 +00:00
Comfy Org PR Bot
6845d57a80 chore(website): refresh Ashby roles snapshot (#12191)
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
from the Ashby job board API.

**Flow:**
1. `Release: Website` workflow ran (manual trigger).
2. This PR opens with the regenerated snapshot.
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` remains
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
committed snapshot.

Triggered by workflow run `25746888214`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12191-chore-website-refresh-Ashby-roles-snapshot-35e6d73d365081f4b2e1d802dd412a72)
by [Unito](https://www.unito.io)

Co-authored-by: Yourz <8287689+Yourz@users.noreply.github.com>
2026-05-12 16:18:14 +00:00
Christian Byrne
469a5edf99 feat: cloud-nodes catalog at /cloud/supported-nodes (#11903)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds a comfy.org page that lists every custom-node pack supported on
Comfy Cloud, with per-pack detail subpages. Data is fetched at build
time from `cloud.comfy.org/api/object_info` (gated by
`WEBSITE_CLOUD_API_KEY`), sanitized of user content, joined with public
registry metadata from `api.comfy.org/nodes`, and falls back to a
committed snapshot — mirroring the existing Ashby careers integration
pattern.

- Index: `/cloud/supported-nodes` (en) and
`/zh-CN/cloud/supported-nodes` (zh-CN)
- Detail: `/cloud/supported-nodes/[pack]` and
`/zh-CN/cloud/supported-nodes/[pack]`, generated via `getStaticPaths()`
from the same fetcher as the index so the two routes can never diverge.

## What's new

**Shared package (extracted)**
- `@comfyorg/object-info-parser` — Zod schemas (`zComfyNodeDef`,
`validateComfyNodeDef`), node-source classifier (`getNodeSource`,
`isCustomNode`, `CORE_NODE_MODULES`), and helpers (`groupNodesByPack`,
`sanitizeUserContent`). `src/schemas/nodeDefSchema.ts` and
`src/types/nodeSource.ts` become 1-line re-export shims; existing
imports keep compiling.

**Build-time pipeline**
- `apps/website/src/utils/cloudNodes.ts` — Ashby-style fetcher:
retry/backoff `[1s, 2s, 4s]`, 10 s timeout via AbortController, Zod
envelope + per-node validation, snapshot fallback, memoized via
module-level `inflight` promise.
- `apps/website/src/utils/cloudNodes.registry.ts` — Public registry
enrichment (no auth, batches of 50, single retry, soft-fail).
- `apps/website/src/utils/cloudNodes.ci.ts` — GitHub Actions annotations
+ step summary mirroring the Ashby reporter.
- `apps/website/src/utils/cloudNodes.build.ts` — Single
`loadPacksForBuild()` consumed by both index and detail pages so they
share one source of truth.
- `apps/website/scripts/refresh-cloud-nodes-snapshot.ts` — atomic-rename
refresh CLI that walks pack/node string fields with a user-content
extension regex *before* renaming the snapshot into place.
- Mandatory user-content sanitization strips uploaded filenames from
combo lists (`LoadImage`, `LoadImageMask`, `LoadImageOutput`,
`LoadVideo`, `LoadAudio` zeroed; any combo value matching
`/\.(png|jpe?g|webp|gif|mp4|mov|webm|wav|mp3|flac|ogg|safetensors|ckpt|pt)$/i`
filtered).

**Page + components**
- `apps/website/src/pages/cloud/supported-nodes.astro` (en) + zh-CN
twin.
- `apps/website/src/pages/cloud/supported-nodes/[pack].astro` detail
(en) + zh-CN twin, async `getStaticPaths` driven by
`loadPacksForBuild()`.
-
`apps/website/src/components/cloud-nodes/{HeroSection,PackGridSection,PackCard,PackBanner,NodeList,PackDetail}.vue`
— Vue 3.5 destructured props, `cn()` from `@comfyorg/tailwind-utils`,
design-system tokens only, no PrimeVue.
- Pack card name links to its detail page; banner uses the shared
`fallback-gradient-avatar.svg` asset (copied into
`apps/website/public/assets/images/`) when `banner_url` and `icon` are
missing.
- 25 new `cloudNodes.*` i18n keys in `en` + `zh-CN`.

**Tests**
- 33 unit tests in `@comfyorg/object-info-parser` (schemas, classifier,
sanitizer, grouping).
- 19 new website unit tests covering fetcher (10), CI reporter (6),
registry enrichment (3) — Ashby patterns mirrored.
- E2E: index smoke + search + banner + detail click-through + direct
visit + zh-CN parity.

## Required maintainer follow-up

GitHub Apps cannot push `.github/workflows/*` changes (push was rejected
with `refusing to allow a GitHub App to create or update workflow …
without workflows permission`), so the workflow edits prepared in this
branch were reverted in commit `9be2abce8`. The intended diffs are
documented as copy-paste-ready snippets in `apps/website/README.md`
under the new "Cloud nodes integration → CI wiring" section.

A maintainer must:

1. Provision `WEBSITE_CLOUD_API_KEY` in the repo secrets and the Vercel
project env.
2. Apply the `ci-website-build.yaml` and
`ci-vercel-website-preview.yaml` diffs documented in the README directly
to `main` (or as a follow-up commit on this branch with a maintainer
account).

The committed snapshot lets builds succeed without the secret while the
maintainer step is pending — pages render from
`apps/website/src/data/cloud-nodes.snapshot.json`.

## Self-review (Oracle)

Two warnings caught and fixed in commits `deba5ab02` and `99dfc3381`:
- Index/detail pages now share a single source of truth
(`loadPacksForBuild`), so a fresh fetch can't expose packs whose detail
routes weren't generated.
- Refresh script validates parsed snapshot fields *before* the atomic
rename, instead of regex-scanning the serialized JSON after the file is
already in place.

## Quality gates (local)

```
pnpm --filter @comfyorg/object-info-parser test  →  33 passed
pnpm --filter @comfyorg/website test:unit         →  42 passed
pnpm --filter @comfyorg/website typecheck         →  0 errors
pnpm --filter @comfyorg/website build             →  47 pages built (incl. 6 cloud-nodes routes)
pnpm lint                                         →  0 errors (1 pre-existing warning in unrelated test file)
pnpm knip                                         →  0 errors (1 pre-existing tag hint in unrelated file)
```

E2E (`pnpm --filter @comfyorg/website test:e2e`) is intended to be run
by the Vercel/CI pipelines.

## Manual verification

Built `dist/`, served locally on port 4321, drove with Playwright:
- `/cloud/supported-nodes` renders both pack cards, search input, sort
dropdown
- `/cloud/supported-nodes/comfyui-impact-pack` renders the metadata grid
(publisher, downloads, stars, version, license, last updated) and 3
categorized node sections with 5 nodes total
- `/zh-CN/cloud/supported-nodes` localizes hero (`Comfy Cloud 上的自定义节点`),
label (`云端节点目录`), search placeholder (`搜索节点包或节点名称`), sort
- `/zh-CN/cloud/supported-nodes/comfyui-controlnet-aux` localizes every
metadata label (`查看仓库`, `发布者`, `下载量`, `GitHub 星标`, `最新版本`, `许可证`,
`最后更新`) and renders dates with `Intl.DateTimeFormat('zh-CN')`
(`2026年4月27日`)
- Search input narrows pack count from 2 to 1 when typing `impact`
(verified via DOM count)

Banners render the shared `fallback-gradient-avatar.svg` when the
snapshot's image URL doesn't resolve — expected in the local sandbox.

## Preview URL (after CI completes)

`https://comfy-website-preview-pr-{N}.vercel.app/cloud/supported-nodes`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11903-feat-cloud-nodes-catalog-at-cloud-supported-nodes-3566d73d36508194afdec5f389897585)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-12 15:51:24 +00:00
Christian Byrne
35443e94f5 feat(website): SEO model pages — 207 models, FAQ JSON-LD, partner node support (#11892)
## Summary

- Adds programmatic SEO model pages at `/p/supported-models/[slug]` for
**207 models** auto-generated from `workflow_templates` (180 local + 27
partner nodes)
- 3-file architecture: `generated-models.json` (auto-generated, checked
in) + `model-metadata.ts` (editorial overrides) + `models.ts` (65-line
merger)
- Full JSON-LD per page: `SoftwareApplication` + `BreadcrumbList` +
`FAQPage` (targeting AI Overviews / People Also Ask)
- Partner node support: `directory: 'partner_nodes'` hides Download
button, shows VIEW TUTORIAL
- `generate-models.ts`: walks `workflow_templates` for local models +
`API_PROVIDER_MAP` for 30+ partner integrations (Kling, Meshy, Luma,
Runway, Stability AI, ByteDance, Google, etc.)
- Weekly GH Actions workflow opens issue when new models appear in
`workflow_templates` but not in `generated-models.json`
- `add-model-page` Claude skill for Slack-driven model page PRs

## Files changed

| File | Purpose |
|------|---------|
| `apps/website/src/config/generated-models.json` | Auto-generated, 207
models (27 partner + 180 local) |
| `apps/website/src/config/model-metadata.ts` | Editorial overrides:
docsUrl, blogUrl, featured (9 entries) |
| `apps/website/src/config/models.ts` | 65-line merger — imports JSON +
overrides, exports `models` + `getModelBySlug` |
| `apps/website/scripts/generate-models.ts` | Build-time parser; run
with `pnpm generate:models` |
| `apps/website/src/i18n/translations.ts` | ~30 UI keys added (no
per-model keys — displayName is plain string) |
| `apps/website/src/pages/p/supported-models/[slug].astro` | Dynamic
route with 3x JSON-LD schemas |
| `apps/website/src/pages/p/supported-models/index.astro` | Model grid
index page |
| `apps/website/src/components/models/ModelHeroSection.vue` | Hero
component |
| `.github/workflows/model-page-discovery.yaml` | Weekly auto-discovery
workflow |
| `.claude/skills/add-model-page/SKILL.md` | Claude skill for
adding/updating model pages |

## Test plan

- [ ] `pnpm build` passes in `apps/website`
- [ ] `/p/supported-models` index renders 207 model cards
- [ ] `/p/supported-models/kling-ai` shows Partner Node eyebrow, no
Download button, VIEW TUTORIAL CTA
- [ ] `/p/supported-models/flux-1-dev` shows Diffusion Model eyebrow,
Download + Tutorial buttons
- [ ] `/p/supported-models/umt5-xxl-fp8-e4m3fn-scaled` redirects 301 to
`umt5-xxl-fp16` (canonicalSlug)
- [ ] Structured data validator shows FAQPage + SoftwareApplication +
BreadcrumbList valid

Fixes FE-421

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:50:23 +00:00
Dante
e05e6cd2fb test: FE-230 e2e regression for asset-delete clearing Load Image preview (#12131)
## Summary

Follow-up to #11493 — adds a `@cloud` Playwright spec that drives the
asset-deletion flow end-to-end and asserts the four FE-230 outcomes
wired up in `useMediaAssetActions.deleteAssets`.

- Path: `browser_tests/tests/assetDeleteClearsLoadImage.spec.ts`
- Flow: load `widgets/load_image_widget`, seed `widget.value` +
`node.imgs`, reset changeTracker, open assets sidebar → Imported tab →
right-click card → Delete → confirm dialog.
- Asserts (auto-retrying): DELETE `/assets/:id` request issued,
`widget.value === ''`, `node.imgs.length === 0`, `workflow.isModified
=== true`.
- Tagged `@cloud` because input-asset deletion is gated on `isCloud` in
`deleteAssetApi`.

Addresses [this review
thread](https://github.com/Comfy-Org/ComfyUI_frontend/pull/11493#pullrequestreview-4262129237)
from #11493.

## Test plan

- [ ] `pnpm build:cloud && pnpm exec playwright test --project=cloud
browser_tests/tests/assetDeleteClearsLoadImage.spec.ts --reporter=list`
passes locally with a running ComfyUI backend
- [ ] CI `cloud` matrix passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12131-test-FE-230-e2e-regression-for-asset-delete-clearing-Load-Image-preview-35d6d73d36508192b4a2df7860f48c44)
by [Unito](https://www.unito.io)
2026-05-12 14:42:31 +00:00
pythongosssss
681915275e fix: remove failed to export toast when cancelling export workflow (#12134)
## Summary

An incorrect error toast currently shows when cancelling the workflow
export from an asset

## Changes

- **What**: 
- skip toast on cancel
- add e2e & unit tests
- refactor asset tab open helper to wait by default & cleanup usage

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12134-fix-remove-failed-to-export-toast-when-cancelling-export-workflow-35d6d73d3650815b839ff26edd70a472)
by [Unito](https://www.unito.io)
2026-05-12 09:00:46 +00:00
pythongosssss
3d9c9ce327 fix: update color widget colors (#12133)
## Summary

The widget was using a different fg/background compared to all other
widgets, this updates to use the standard classes.

## Changes

- **What**: 
- update styles

## Screenshots (if applicable)

Before
<img width="570" height="597" alt="Screen Shot 2026-05-11 at 07 19 12"
src="https://github.com/user-attachments/assets/18a5330f-5e9a-4d16-b3f0-0acfab5d6f99"
/>

After
<img width="570" height="597" alt="Screen Shot 2026-05-11 at 07 15 46"
src="https://github.com/user-attachments/assets/81c9da58-fdda-4539-ae1e-98727f12b9ac"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12133-fix-update-color-widget-colors-35d6d73d365081da8515cd48a8e8ecc2)
by [Unito](https://www.unito.io)

No e2e tests for this as they would be simple screenshot asserts on the
color of nodes, which is not worthwhile.
2026-05-12 09:00:22 +00:00
jaeone94
e765eb1bb2 fix: suppress missing media scan during uploads (#12111)
## Summary
- Prevent missing media detection from scanning media loader nodes while
a drag/drop, paste, or file-select upload is still in progress.
- Align LoadAudio with the existing media upload lifecycle by setting
`node.isUploading`, blocking concurrent uploads, and clearing the flag
after upload completion.
- Keep added-node model and missing-node scans on the original
one-microtask path, while deferring added-node media scanning by one
extra microtask so upload handlers can mark transient upload state
before the scan reads widget values.

## Why
Drag/drop and paste can create media loader nodes before the backing
upload has settled. During that short window, the widget may contain a
local filename that is not yet backend-resolvable, so missing media
detection can surface a false missing asset. Refreshing works because
the upload has completed by then.

## Follow-up
- E2E coverage for this upload race will be handled in a follow-up PR
together with E2E coverage for the annotated output-media path changes
from #12069.

## Validation
- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm vitest run src/composables/graph/useErrorClearingHooks.test.ts
src/platform/missingMedia/missingMediaScan.test.ts
src/extensions/core/uploadAudio.test.ts
src/composables/node/useNodeImageUpload.test.ts`
- Re-ran `pnpm typecheck` after rebasing onto latest `main`
- Pre-push `knip` hook passed

Fixes FE-620

## Screenshots 

Before


https://github.com/user-attachments/assets/db7891de-a4b5-4cde-aa76-6340e6cdf7b2

After


https://github.com/user-attachments/assets/9b99bb13-0d5b-4ff7-8f52-66eea6e417ec



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12111-fix-suppress-missing-media-scan-during-uploads-35b6d73d365081f3b54eed02874ccaa4)
by [Unito](https://www.unito.io)
2026-05-12 08:54:11 +00:00
Rizumu Ayaka
56434ae9ac perf: debounce template search input to keep typing responsive (#12183)
## Summary

Route the templates dialog search through `FormSearchInput`'s debounced
searcher so per-keystroke work no longer trips the heavy filter/render
path.

## Changes

- **What**: `WorkflowTemplateSelectorDialog` now uses `FormSearchInput`
instead of `SearchInput`. The raw input is bound to a local ref; the
actual `searchQuery` consumed by `useTemplateFiltering` is only written
after the input debounce settles, so dependent computeds (notably
`shouldUsePagination`, which used to flip on every keystroke and force a
full grid rebuild) stay stable while typing.
- **What**: `FormSearchInput` gains optional `debounceMs` (default
`250`) and `debounceMaxWaitMs` (default `1000`) props. Existing callers
are unchanged; the templates dialog passes `400` / `4000` to match the
feel tuned in this PR.

## Review Focus

- Reset path: `searchQuery` is still owned by `useTemplateFiltering` and
cleared by `resetFilters`; a watch syncs the visible input back to empty
when that happens.
- `FormSearchInput` is currently under `src/renderer/...` but already
imported by workbench-level components (rightSidePanel tabs). This PR
follows that existing precedent rather than relocating the component.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12183-perf-debounce-template-search-input-to-keep-typing-responsive-35e6d73d365081b7a11ec4a84323095f)
by [Unito](https://www.unito.io)
2026-05-12 08:15:31 +00:00
Alexander Brown
25c2d828c0 test: enable vitest/consistent-each-for and migrate .each → .for (#12161)
*PR Created by the Glary-Bot Agent*

---

Enables the oxlint rule `vitest/consistent-each-for` (configured to
prefer `.for` for `test`, `it`, `describe`, and `suite`) and migrates
every `.each` parameterized test in the repo to `.for`. Using `.for`
avoids accidentally splatting tuple elements into separate callback
arguments and exposes `TestContext` as the second callback argument.

The first commit covers the 38 lint-detected files (88 callsites):
renames `.each` → `.for` and updates callback signatures to destructure
when the data is an array of tuples (objects/primitives already work
unchanged with `.for`).

The follow-up commit addresses code review feedback: oxlint's rule does
not recognize `test.each` on extended test bases
(`baseTest.extend(...)`) and skips files in `ignorePatterns`
(`src/extensions/core/*`). These were converted manually so the policy
is uniform across the codebase.

## Verification

- `node_modules/.bin/oxlint src` — 0 errors, 0 `consistent-each-for`
violations
- `pnpm typecheck` — passes
- `pnpm test:unit` — all modified test files pass; pre-existing
environmental flakes (`GraphView.test.ts`, `ColorWidget.test.ts`, etc.,
unchanged here and flaky on `main` in this sandbox) are unrelated
- `pnpm lint` / `pnpm knip` — clean
- Manual verification: 362 tests across 6 representative converted
suites re-run in an interactive shell — all passing

Manual UI verification (Playwright/screenshots) is not applicable:
changes are test-file-only refactors with no production runtime or UI
behavior change.

## Notes on `.for` semantics

- Array-of-tuples (`[[a, b], ...]`) passes the tuple as a single arg, so
callbacks were changed from `(a, b) => …` to `([a, b]) => …`.
- Array-of-objects (`[{a}, …]`) already used destructuring — unchanged.
- Array-of-primitives (`['a', …]`) — callback signature unchanged.
- A handful of complex cases use a small `type Case = [...]` alias plus
`it.for<Case>([...])` to preserve tuple inference where TS narrowed
unions otherwise broke parameter types.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12161-test-enable-vitest-consistent-each-for-and-migrate-each-for-35e6d73d3650810c9417e07bdd9f27a2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-11 23:11:51 -07:00
Dante
ceb9936058 fix(i18n): clamp unsupported browser locales to a shipped tag (#11712)
## Summary

Sidebar buttons rendered literal i18n keys (e.g.
`sideToolbar.labels.assets`) on a fresh install when the user's
`navigator.language` base tag wasn't one of the 12 shipped locales —
German/Italian/Polish/Dutch/Brazilian-Portuguese users among others.

## Changes

- **What**: Add `resolveSupportedLocale()` that tries the full BCP-47
tag first (preserves `zh-TW`, `pt-BR`), then the base tag, then `'en'`.
Wire through both entry points (`createI18n`'s initial locale,
`Comfy.Locale`'s `defaultValue`) and clamp inside `loadLocale`,
propagating the resolved tag to `GraphView` so a stale stored
`Comfy.Locale='de'` from older builds also recovers.
- **Side benefit**: Brazilian Portuguese users were previously falling
through `pt-BR` → `pt` (unshipped) → broken. The full-tag-first lookup
now correctly lands them on the `pt-BR` bundle.
- **Breaking**: None.
- **Dependencies**: None.

## Root Cause

Three-link chain:

1. `Comfy.Locale`'s default was `() => navigator.language.split('-')[0]
|| 'en'`. German → `'de'` (unshipped).
2. `loadLocale('de')` silently `console.warn`'d and returned without
throwing.
3. `GraphView` then ran `i18n.global.locale.value = 'de'` anyway.
4. `st(key, fallback) = te(key) ? t(key) : fallback`. vue-i18n's `te()`
checks **only** the current locale and ignores `fallbackLocale` — every
key missed → `st()` returned the literal key string.

Two pathways reached the broken state (defaultValue path, and
unset-setting path through `createI18n`'s own `navigator.language`
snapshot); the new helper closes both.

## Review Focus

- `loadLocale` now returns `SupportedLocale` (was `void`). Old `void`
callers continue to compile; the only change is `GraphView` consuming
the return value.
- Unit-tested in `src/i18n.test.ts` (added `resolveSupportedLocale`
block + updated the `loadLocale` unsupported-locale case from "warn" to
"clamp to en").
- Self-reproduced via Playwright with `navigator.language='de-DE'` +
fresh-install state on both `main` (shows the bug) and this branch
(shows the fix). Spec saved at
`temp/scripts/issue-10563-locale-bug.spec.ts`.

Fixes #10563
FE-480 — https://linear.app/comfyorg/issue/FE-480

## Screenshots

**Before** (from #10563, on `main`):

<img width="258" height="399" alt="Sidebar with literal i18n keys"
src="https://github.com/user-attachments/assets/098d1d76-8e89-4237-813f-5f030b34e51e"
/>

**After** (this branch, same `navigator.language='de-DE'`):

<img width="367" height="793" alt="Screenshot 2026-04-28 at 2 07 38 PM"
src="https://github.com/user-attachments/assets/9d279de3-50a8-4774-999f-ab4c3018a9ef"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11712-fix-i18n-clamp-unsupported-browser-locales-to-a-shipped-tag-3506d73d3650812f89d2f0fe3199de3a)
by [Unito](https://www.unito.io)
2026-05-12 15:06:13 +09:00
Deep Mehta
bb420fe2c7 feat: add model-to-node mappings for new model directories (#12151)
## Summary

Add entries to `MODEL_NODE_MAPPINGS` so the model browser's "Use" button
correctly creates a loader node for five model directories that
currently have no mapping.

## Changes

- **What**: 5 new entries in
`src/platform/assets/mappings/modelNodeMappings.ts`:
- `background_removal` → `LoadBackgroundRemovalModel` /
`bg_removal_name` (ComfyUI v0.21+ core)
- `frame_interpolation` → `FrameInterpolationModelLoader` / `model_name`
(ComfyUI v0.21+ core)
  - `film` → `FILM VFI` / `ckpt_name` (ComfyUI-Frame-Interpolation)
- `ultralytics/bbox` → `UltralyticsDetectorProvider` / `model_name`
(ComfyUI-Impact-Pack)
- `ultralytics/segm` → `UltralyticsDetectorProvider` / `model_name`
(ComfyUI-Impact-Pack)
- **Breaking**: none

## Review Focus

- Node class names and input keys were cross-checked against the ComfyUI
v0.21.0 source and the published Impact-Pack / Frame-Interpolation node
definitions
- Both `ultralytics/bbox` and `ultralytics/segm` map to the same node
(`UltralyticsDetectorProvider`); its `model_name` combo accepts values
from both subdirectories (`bbox/...` and `segm/...`)
- `film` and `frame_interpolation` are separate directories serving
different node packs — keeping them as distinct entries rather than
collapsing under a parent

## Test plan

- [ ] In the model browser, clicking "Use" on `birefnet.safetensors`
creates a `LoadBackgroundRemovalModel` node with the model preselected
- [ ] Same for one model in each of: `frame_interpolation`, `film`,
`ultralytics/bbox`, `ultralytics/segm`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12151-feat-add-model-to-node-mappings-for-new-model-directories-35d6d73d365081ff834bf6eb610da160)
by [Unito](https://www.unito.io)
2026-05-12 02:28:51 +00:00
pythongosssss
4504256f11 test: add test for custom node i18n (#12132)
## Summary

Adds e2e test for custom node i18n

## Changes

- **What**: 
- add e2e regression for previous fix with no e2e

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12132-test-add-test-for-custom-node-i18n-35d6d73d365081f7bed2db39af38f855)
by [Unito](https://www.unito.io)
2026-05-12 01:04:38 +00:00
Comfy Org PR Bot
1290bbd359 1.45.5 (#12152)
Patch version increment to 1.45.5

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12152-1-45-5-35e6d73d365081c39aecddf42d8f7a2a)
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-12 00:44:22 +00:00
Yourz
7ddf71d91b fix(website): center GitHubStarBadge text in Safari (#12138)
*PR Created by the Glary-Bot Agent*

---

## Summary

The 10px star count text inside the desktop nav GitHub star badge
rendered vertically off-center in Safari/WebKit. The text was visibly
shifted upward inside the yellow badge body, while Chromium rendered it
centered correctly.

## Root cause

`NodeBadge.vue` centers its inner text span by setting `flex
items-center justify-center` on the segment, then nudging the text with
`translate-y-1` (or `translate-y-0` for the small variant). The text
span itself is an `inline-block` with no explicit `line-height`, so it
inherits the default `line-height: 1.5` (15px for a 10px font).

Safari and Chromium distribute that extra leading differently for the
`PP Formula Narrow` custom font: Safari pushes the glyph higher inside
its 15px line box, while Chromium positions it near the middle. With
only a 5px gap above/below the glyph to play with, that browser-specific
divergence is enough to make the badge look misaligned in Safari.

## Fix

Add `leading-none` to the star count text class so the inline-block's
line box equals the font size (10px) and the parent flex container's
`items-center` produces deterministic vertical centering across
browsers.

Verified at lg breakpoint (1440×900) in both WebKit and Chromium via
Playwright; the badge now renders identically and is properly centered.

## Verification

- `pnpm typecheck` (website) — clean
- `pnpm build` (website) — 51 pages built successfully
- Pre-commit hooks (stylelint, oxfmt, oxlint, eslint, typecheck,
typecheck:website) — all passed
- Visual inspection in WebKit and Chromium via Playwright

Fixes FE-648

## Screenshots

![BEFORE (Safari/WebKit): star count text displaced upward in yellow
badge](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/364b285ccc004b7d3778b6129dab4410e0bffe9db5ef124e37443414f9790d70/pr-images/1778513564903-c047c9ac-4719-479e-8442-f15ad389be02.png)

![AFTER (Safari/WebKit): star count text properly centered in yellow
badge](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/364b285ccc004b7d3778b6129dab4410e0bffe9db5ef124e37443414f9790d70/pr-images/1778513565238-a4f00af8-54d7-4131-a5fd-849105e427d6.png)

![Zoomed Safari/WebKit view showing the fixed badge with centered 85K
text](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/364b285ccc004b7d3778b6129dab4410e0bffe9db5ef124e37443414f9790d70/pr-images/1778513565587-f6aca193-5ced-45aa-b692-3340629f64be.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12138-fix-website-center-GitHubStarBadge-text-in-Safari-35d6d73d3650818aa0e8e0f341b60378)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-12 00:38:49 +00:00
Christian Byrne
74caeb0b0b fix: detect V1/V2 draft storage keys in new-user check (#11728)
## Problem

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

## Solution

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

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

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

## Tests

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

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

---------

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

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

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-11 19:05:48 +00:00
302 changed files with 22117 additions and 1553 deletions

View File

@@ -0,0 +1,173 @@
---
name: add-model-page
description: 'add, update, or remove a model page entry on the comfy org website. creates a PR to Comfy-Org/ComfyUI_frontend apps/website folder with the change and posts a Vercel preview link back to Slack.'
---
# add-model-page
add, update, or remove model pages in the ComfyUI website.
## Trigger phrases
- `Add a model page for <model-name>`
- `Update the model page for <model-name>`
- `Remove <model-name> from model pages`
## Phase 1 — Parse the request
Extract:
- **action**: `add` | `update` | `remove`
- **model-name**: raw string (e.g. `flux1-schnell`, `flux1_dev.safetensors`)
Normalize to a slug: lowercase, replace `_` and `.` with `-`, strip file extensions.
Example: `flux1_dev.safetensors``flux1-dev`
## Architecture overview
Models come from two sources merged at build time:
| File | Purpose |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `apps/website/src/config/generated-models.json` | Auto-generated from workflow_templates (slug, name, directory, huggingFaceUrl, workflowCount, displayName, thumbnailUrl, docsUrl) |
| `apps/website/src/config/model-metadata.ts` | Hand-curated overrides (docsUrl, blogUrl, featured) — only add entries that need overrides |
| `apps/website/src/config/models.ts` | Merges the two above; exports typed `Model[]` |
To regenerate the JSON from workflow_templates:
```bash
pnpm tsx apps/website/scripts/generate-models.ts
```
This writes `apps/website/src/config/generated-models.json` directly.
Thumbnails are populated from local `.webp` files in `workflow_templates/templates/` — no network access needed.
---
## Phase 2 — Gather model data (ADD / UPDATE)
Run the generator to get fresh data, then find the model:
```bash
pnpm tsx apps/website/scripts/generate-models.ts
jq '.[] | select(.slug | contains("MODEL_SLUG"))' \
apps/website/src/config/generated-models.json
```
The JSON fields are:
- `slug` — URL slug
- `name` — exact filename or display name for partner nodes
- `huggingFaceUrl` — download URL (empty for partner nodes)
- `directory``diffusion_models` | `loras` | … | `partner_nodes`
- `workflowCount` — integer
- `displayName` — human-readable name
If no match and it is a known API/partner model, add it to `API_PROVIDER_MAP` in
`generate-models.ts` and re-run. Otherwise tell the user.
---
## Phase 3 — Check for existing entry
```bash
jq --arg slug "${SLUG}" '.[] | select(.slug == $slug)' \
apps/website/src/config/generated-models.json
```
- Match found + action is `add` → switch to UPDATE flow automatically
- No match + action is `update` → stop and tell the user
---
## Phase 4A — ADD: new partner/API model not in workflow_templates
For partner nodes (no local file), add an entry to `API_PROVIDER_MAP` in
`apps/website/scripts/generate-models.ts`:
```typescript
mymodel: { name: 'My Model', slug: 'my-model' },
```
Then re-run `pnpm tsx apps/website/scripts/generate-models.ts` — it will appear
in `generated-models.json` automatically.
If you also want a `docsUrl`, `blogUrl`, or a link to the hub model page, add an entry to `model-metadata.ts`:
```typescript
'my-model': {
docsUrl: 'https://docs.comfy.org/tutorials/...',
blogUrl: 'https://blog.comfy.org/...',
hubSlug: 'my-model', // slug at comfy.org/workflows/model/{hubSlug} — only set if the page exists (returns 200)
featured: true
}
```
No changes to `models.ts` or `translations.ts` are needed.
---
## Phase 4B — UPDATE: edit existing entry
Only `model-metadata.ts` needs editing for most updates (docsUrl, blogUrl,
featured). For `displayName` or `directory` changes, edit the entry directly in
`generated-models.json` (until the next generator run would overwrite it — then
fix the source in `generate-models.ts`).
---
## Phase 4C — REMOVE: delete entry
Remove the entry from `generated-models.json` (or mark it with `canonicalSlug`
pointing to the replacement). No translation file changes needed.
---
## Phase 5 — Verify TypeScript
```bash
pnpm typecheck 2>&1 | grep -E "error|warning" | head -20
```
Fix any type errors before proceeding. Common issues:
- `ModelDirectory` type not matching a new `directory` value — add it to the union
- JSON import shape mismatch — `generated-models.json` must match `OutputModel`
---
## Phase 6 — Create PR
```bash
BRANCH="add-model-page-MODEL-SLUG" # or update- / remove-
git checkout -b $BRANCH
git add apps/website/src/config/generated-models.json \
apps/website/scripts/generate-models.ts \
apps/website/src/config/model-metadata.ts
git commit -m "feat(models): add model page for MODEL-SLUG"
git push -u origin $BRANCH
gh pr create \
--title "Add model page: MODEL-SLUG" \
--body "$(cat <<'EOF'
Adds a new model page entry for MODEL-SLUG.
## Changes
- `generated-models.json`: regenerated with new entry (workflowCount N, directory DIRECTORY)
- `model-metadata.ts`: editorial overrides (docsUrl, featured) if needed
EOF
)"
```
For UPDATE use branch `update-model-page-MODEL-SLUG`.
For REMOVE use `remove-model-page-MODEL-SLUG`.
---
## Error states
| Situation | Response |
| ------------------------------- | ---------------------------------------------------------------- |
| Model not in workflow templates | Ask user to verify spelling or add it manually as a partner node |
| Slug already exists (add) | Switch to update flow automatically |
| Slug not found (update/remove) | Stop and ask user to confirm |
| Typecheck fails | Fix the error before pushing |

View File

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

View File

@@ -0,0 +1,19 @@
name: Cloud Nodes Pull
description: 'Refresh the apps/website cloud nodes snapshot from the Comfy Cloud /api/object_info endpoint'
inputs:
api_key:
description: 'Comfy Cloud API key (WEBSITE_CLOUD_API_KEY).'
required: true
runs:
using: 'composite'
steps:
# Note: this action assumes the frontend repo is checked out at the workspace root.
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Refresh cloud nodes snapshot
shell: bash
env:
WEBSITE_CLOUD_API_KEY: ${{ inputs.api_key }}
run: pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot

View File

@@ -109,14 +109,16 @@ jobs:
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
mkdir -p coverage/html
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}"
echo "<html><body><h1>No E2E coverage data available for this run.</h1><p><a href=\"${WORKFLOW_URL}\">View workflow run</a></p></body></html>" > coverage/html/index.html
exit 0
fi
genhtml coverage/playwright/coverage.lcov \
-o coverage/html \
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1
--precision 1 \
--ignore-errors source
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'
@@ -130,7 +132,8 @@ jobs:
needs: merge
if: >
github.event.workflow_run.head_branch == 'main' &&
needs.merge.outputs.has-coverage == 'true'
needs.merge.outputs.has-coverage == 'true' &&
github.event.workflow_run.event == 'push'
runs-on: ubuntu-latest
permissions:
pages: write

View File

@@ -58,6 +58,7 @@ jobs:
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: vercel build
- name: Fetch head commit metadata
@@ -151,10 +152,20 @@ jobs:
- name: Pull Vercel environment information
run: vercel pull --yes --environment=production
- name: Verify WEBSITE_CLOUD_API_KEY is present for production build
env:
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: |
if [ -z "${WEBSITE_CLOUD_API_KEY:-}" ]; then
echo "::error title=Missing WEBSITE_CLOUD_API_KEY::Production builds require WEBSITE_CLOUD_API_KEY so /cloud/supported-nodes is generated from fresh Cloud API data. Add it as a GitHub Actions repo secret and to the Vercel project environment. See apps/website/README.md."
exit 1
fi
- name: Build project artifacts
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: vercel build --prod
- name: Deploy project artifacts to Vercel

View File

@@ -0,0 +1,123 @@
name: Model Page Discovery
on:
schedule:
- cron: '0 9 * * 1'
workflow_dispatch:
jobs:
discover:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Fetch model labels from hub API
id: hub
shell: bash
run: |
set -euo pipefail
curl -fsSL 'https://comfy.org/api/hub/labels?type=model' -o hub-labels.json
echo "Fetched $(jq '.labels | length' hub-labels.json) model labels from hub"
- name: Checkout ComfyUI_frontend
uses: actions/checkout@v6
with:
sparse-checkout: apps/website/src/config/generated-models.json
- name: Compare against existing models
id: compare
shell: bash
run: |
set -euo pipefail
HUB_SLUGS=$(jq -r '[.labels[].name]' hub-labels.json)
EXISTING_SLUGS=$(node -e "
const fs = require('fs');
const models = JSON.parse(
fs.readFileSync(
'apps/website/src/config/generated-models.json',
'utf8'
)
);
console.log(JSON.stringify(models.map(m => m.slug)));
" 2>/dev/null || echo '[]')
ADDED_SLUGS=$(node -e "
const hub = $HUB_SLUGS;
const existing = new Set($EXISTING_SLUGS);
console.log(JSON.stringify(hub.filter(s => !existing.has(s))));
")
COUNT=$(node -e "console.log($ADDED_SLUGS.length)")
echo "new_count=$COUNT" >> \$GITHUB_OUTPUT
echo "new_slugs=$ADDED_SLUGS" >> \$GITHUB_OUTPUT
if [ "\$COUNT" -eq 0 ]; then
echo "No new models found."
else
echo "Found \$COUNT new model(s)"
fi
- name: Check for existing open discovery issue
id: existing_issue
if: steps.compare.outputs.new_count != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
COUNT=$(gh issue list \
--repo "$GITHUB_REPOSITORY" \
--state open \
--search 'in:title "New models detected"' \
--json number \
--jq 'length')
echo "open_count=$COUNT" >> $GITHUB_OUTPUT
- name: Open GitHub issue for new models
if: |
steps.compare.outputs.new_count != '0' &&
steps.existing_issue.outputs.open_count == '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NEW_SLUGS: ${{ steps.compare.outputs.new_slugs }}
NEW_COUNT: ${{ steps.compare.outputs.new_count }}
shell: bash
run: |
SLUG_LIST=$(node -e "
const slugs = $NEW_SLUGS;
console.log(slugs.map(s => '- \`' + s + '\`').join('\n'));
")
gh issue create \
--repo "$GITHUB_REPOSITORY" \
--title "New models detected — add to model pages" \
--body "## $NEW_COUNT new model(s) found in hub
The weekly model discovery scan found model labels on the hub not yet in
\`apps/website/src/config/generated-models.json\`.
### New slugs ($NEW_COUNT)
$SLUG_LIST
### Next steps
1. Review which of these warrant an SEO model page
2. For local models: run \`SKIP_THUMBNAILS=1 pnpm generate:models\` and commit the result
3. For partner/API models: add to \`API_PROVIDER_MAP\` in \`generate-models.ts\`, regenerate, commit
---
*Generated by the [model-page-discovery workflow](https://github.com/$GITHUB_REPOSITORY/actions/workflows/model-page-discovery.yaml)*"
- name: Skip — open issue already exists
if: |
steps.compare.outputs.new_count != '0' &&
steps.existing_issue.outputs.open_count != '0'
run: echo "An open discovery issue already exists — skipping creation."
- name: No new models found
if: steps.compare.outputs.new_count == '0'
run: echo "No new models found — nothing to do."

View File

@@ -1,6 +1,6 @@
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
# and open a PR. Merging the PR triggers the existing Vercel website production
# deploy via ci-vercel-website-preview.yaml.
# Description: Manual workflow to refresh the apps/website Ashby roles and
# cloud nodes snapshots and open a PR. Merging the PR triggers the existing
# Vercel website production deploy via ci-vercel-website-preview.yaml.
name: 'Release: Website'
on:
@@ -11,7 +11,7 @@ concurrency:
cancel-in-progress: true
jobs:
refresh-snapshot:
refresh-snapshots:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
@@ -31,28 +31,39 @@ jobs:
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
- name: Refresh cloud nodes snapshot
uses: ./.github/actions/cloud-nodes-pull
with:
api_key: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
- name: Create Pull Request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: 'chore(website): refresh Ashby roles snapshot'
title: 'chore(website): refresh Ashby roles snapshot'
commit-message: 'chore(website): refresh Ashby and cloud nodes snapshots'
title: 'chore(website): refresh Ashby and cloud nodes snapshots'
body: |
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
from the Ashby job board API.
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 snapshot.
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` remains
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
committed snapshot.
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 `${{ github.run_id }}`.
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
branch: chore/refresh-website-snapshots-${{ github.run_id }}
base: main
labels: |
Release:Website

View File

@@ -85,6 +85,15 @@
"typescript/no-unused-vars": "off",
"unicorn/no-empty-file": "off",
"vitest/require-mock-type-parameters": "off",
"vitest/consistent-each-for": [
"error",
{
"test": "for",
"it": "for",
"describe": "for",
"suite": "for"
}
],
"unicorn/no-new-array": "off",
"unicorn/no-single-promise-in-promise-methods": "off",
"unicorn/no-useless-fallback-in-spread": "off",

View File

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

View File

@@ -113,6 +113,50 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
The script exits non-zero on any non-fresh outcome so stale/empty
snapshots can't be accidentally committed.
## Cloud nodes integration
`/cloud/supported-nodes` (and `/zh-CN/`) lists custom-node packs preinstalled on Comfy Cloud, joined with public metadata from the [ComfyUI Custom Node Registry](https://registry.comfy.org) ([`api.comfy.org`](https://api.comfy.org)). See [`src/pages/cloud/supported-nodes/AGENTS.md`](src/pages/cloud/supported-nodes/AGENTS.md) for the build pipeline, source-file map, and key invariants.
Build-time env var: `WEBSITE_CLOUD_API_KEY` (Cloud `/api/object_info` auth; the build falls back to the committed snapshot when unset). Must also be set in the Vercel project environment.
### Production strictness
`src/utils/cloudNodes.build.ts` throws when `fetchCloudNodesForBuild()` returns
`{ status: 'stale' }` **and** `process.env.VERCEL_ENV === 'production'`. This
prevents the production deploy from silently shipping an out-of-date snapshot
when the Cloud API is unreachable or `WEBSITE_CLOUD_API_KEY` is missing. Preview
and local builds continue to use the committed snapshot with a warning
annotation.
### Required GitHub Actions / Vercel secrets
| Name | Where | Purpose |
| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- |
| `WEBSITE_CLOUD_API_KEY` | GitHub Actions repo secret + Vercel project env | Auth for Cloud `/api/object_info`. Required for fresh production data. |
The `Release: Website` workflow uses the GitHub Actions secret to regenerate
`apps/website/src/data/cloud-nodes.snapshot.json` via
`.github/actions/cloud-nodes-pull/action.yaml`. The Vercel environment value is
read at build time by `vercel build` in `ci-vercel-website-preview.yaml`; the
`deploy-production` job hard-fails before `vercel build --prod` if the secret
is missing.
### Refreshing the snapshot
To update the committed snapshot manually (e.g. after onboarding new packs
to Comfy Cloud):
```bash
WEBSITE_CLOUD_API_KEY=\
pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot
git commit apps/website/src/data/cloud-nodes.snapshot.json
```
The script exits non-zero on any non-fresh outcome so stale/empty snapshots
can't be accidentally committed. Otherwise the `Release: Website` GitHub
Actions workflow runs the same step on every manual dispatch and opens a PR
with the refreshed snapshot.
## HubSpot contact form
The contact page uses HubSpot's hosted form embed for the interest form:
@@ -146,3 +190,4 @@ renders the documented embed container.
- `pnpm test:unit` — Vitest unit tests
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot
- `pnpm cloud-nodes:refresh-snapshot` — refresh the committed cloud nodes snapshot

View File

@@ -23,15 +23,6 @@ test.describe('Careers page @smoke', () => {
expect(await roles.count()).toBeGreaterThan(0)
})
test('each role links to jobs.ashbyhq.com', async ({ page }) => {
const roles = page.getByTestId('careers-role-link')
const count = await roles.count()
for (let i = 0; i < count; i++) {
const href = await roles.nth(i).getAttribute('href')
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
}
})
test('clicking a department button scrolls to and activates that section', async ({
page
}) => {
@@ -63,6 +54,21 @@ test.describe('Careers page @smoke', () => {
})
})
test.describe('Careers page role links', () => {
test('each role links to the Ashby job description page, not the application form', async ({
page
}) => {
await page.goto('/careers')
const roles = page.getByTestId('careers-role-link')
const count = await roles.count()
for (let i = 0; i < count; i++) {
const href = await roles.nth(i).getAttribute('href')
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
expect(href).not.toMatch(/\/application\/?$/)
}
})
})
test.describe('Careers page (zh-CN) @smoke', () => {
test('renders localized heading and roles', async ({ page }) => {
await page.goto('/zh-CN/careers')

View File

@@ -0,0 +1,169 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Cloud nodes page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/cloud/supported-nodes')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Custom-node packs on Comfy Cloud — supported by default'
)
})
test('renders at least one pack card', async ({ page }) => {
const cards = page.getByTestId('cloud-node-pack-card')
await expect(cards.first()).toBeVisible()
expect(await cards.count()).toBeGreaterThan(0)
})
test('search input filters cards down', async ({ page }) => {
const cards = page.getByTestId('cloud-node-pack-card')
const initialCount = await cards.count()
await page.getByTestId('cloud-nodes-search').fill('impact')
const filteredCards = page.getByTestId('cloud-node-pack-card')
await expect(filteredCards.first()).toBeVisible()
const filteredCount = await filteredCards.count()
expect(filteredCount).toBeGreaterThan(0)
expect(filteredCount).toBeLessThanOrEqual(initialCount)
})
test('renders banner image or fallback element', async ({ page }) => {
const banners = page.getByTestId('cloud-node-pack-banner')
await expect(banners.first()).toBeVisible()
})
test('opens pack detail page from first card', async ({ page }) => {
const firstCard = page.getByTestId('cloud-node-pack-card').first()
await expect(firstCard).toBeVisible()
await firstCard.locator('a').first().click()
await expect(page).toHaveURL(/\/cloud\/supported-nodes\/[a-z0-9-]+$/)
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
})
test('direct pack detail route renders node entries', async ({ page }) => {
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
await expect(
page.getByTestId('cloud-node-pack-detail-node').first()
).toBeVisible()
})
test('search with no matches shows empty state', async ({ page }) => {
await page
.getByTestId('cloud-nodes-search')
.fill('zzzz-this-pack-does-not-exist')
await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(0)
await expect(page.getByText('No matching packs')).toBeVisible()
})
test('clearing search restores the full list', async ({ page }) => {
const cards = page.getByTestId('cloud-node-pack-card')
const initialCount = await cards.count()
await page.getByTestId('cloud-nodes-search').fill('impact')
await expect(cards.first()).toBeVisible()
await page.getByTestId('cloud-nodes-search').fill('')
await expect(cards).toHaveCount(initialCount)
})
test('search matches against node display names, not just pack names', async ({
page
}) => {
await page.getByTestId('cloud-nodes-search').fill('FaceDetailer')
await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(1)
await expect(
page.getByTestId('cloud-node-pack-card-link').first()
).toContainText('Impact Pack')
})
test('switching sort to A → Z reorders cards alphabetically', async ({
page
}) => {
await page.locator('#cloud-nodes-sort').selectOption('az')
const firstName = await page
.getByTestId('cloud-node-pack-card-link')
.first()
.textContent()
expect(firstName?.trim().toLowerCase().charAt(0)).toMatch(/^[a-c]/)
})
test('list grid carries a localized aria-label', async ({ page }) => {
await expect(
page.getByRole('list', {
name: 'Custom-node packs supported on Comfy Cloud'
})
).toBeVisible()
})
test('clicking the back link returns to the index from a detail page', async ({
page
}) => {
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
await page.getByRole('link', { name: 'Back to all packs' }).click()
await expect(page).toHaveURL(/\/cloud\/supported-nodes\/?$/)
await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible()
})
test('detail page renders publisher and external repo link', async ({
page
}) => {
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
const repoLink = page.getByRole('link', {
name: /github\.com\/ltdrdata\/ComfyUI-Impact-Pack/
})
await expect(repoLink).toBeVisible()
await expect(repoLink).toHaveAttribute('rel', /noopener/)
})
test('unknown pack slug 404s', async ({ page }) => {
const response = await page.goto(
'/cloud/supported-nodes/this-pack-does-not-exist'
)
expect(response?.status()).toBe(404)
})
test('JSON-LD ItemList is emitted on the index page', async ({ page }) => {
const jsonLd = page.locator('script[type="application/ld+json"]')
const ldBlocks = await jsonLd.allTextContents()
expect(ldBlocks.some((b) => b.includes('"@type":"ItemList"'))).toBeTruthy()
})
test('JSON-LD payload escapes <-sequences', async ({ page }) => {
const ldBlocks = await page
.locator('script[type="application/ld+json"]')
.allTextContents()
for (const block of ldBlocks) {
expect(block).not.toContain('</script')
}
})
})
test.describe('Cloud nodes page (zh-CN) @smoke', () => {
test('renders localized title and packs', async ({ page }) => {
await page.goto('/zh-CN/cloud/supported-nodes')
await expect(page).toHaveTitle('Comfy Cloud 自定义节点包合集——开箱即用')
await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible()
await expect(
page.getByTestId('cloud-node-pack-banner').first()
).toBeVisible()
})
test('opens pack detail page from first card', async ({ page }) => {
await page.goto('/zh-CN/cloud/supported-nodes')
const firstCard = page.getByTestId('cloud-node-pack-card').first()
await expect(firstCard).toBeVisible()
await firstCard.locator('a').first().click()
await expect(page).toHaveURL(/\/zh-CN\/cloud\/supported-nodes\/[a-z0-9-]+$/)
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
})
})

View File

@@ -0,0 +1,56 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Pricing page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/cloud/pricing')
})
test('shows the three paid tiers and Enterprise', async ({ page }) => {
const pricingGrid = page
.locator('section', {
has: page.getByRole('heading', { name: /Pricing/i })
})
.locator('.lg\\:grid')
for (const label of ['STANDARD', 'CREATOR', 'PRO']) {
await expect(
pricingGrid.locator('span', { hasText: new RegExp(`^${label}$`) })
).toBeVisible()
}
await expect(
page.getByRole('heading', { name: /Looking for Enterprise Solutions/i })
).toBeVisible()
})
test('does not show the Free tier when SHOW_FREE_TIER is disabled', async ({
page
}) => {
const pricingGrid = page
.locator('section', {
has: page.getByRole('heading', { name: /Pricing/i })
})
.locator('.lg\\:grid')
await expect(
pricingGrid.locator('span', { hasText: /^FREE$/ })
).toHaveCount(0)
await expect(page.getByRole('link', { name: /^START FREE$/ })).toHaveCount(
0
)
await expect(page.getByText(/Everything in Free, plus:/i)).toHaveCount(0)
})
})
test.describe('Cloud pricing teaser @smoke', () => {
test('does not show the "Start free" tagline when SHOW_FREE_TIER is disabled', async ({
page
}) => {
await page.goto('/cloud')
await expect(
page.getByText(/Start free\.\s*Upgrade when you're ready\./i)
).toHaveCount(0)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -15,11 +15,15 @@
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
"test:visual": "playwright test --project visual",
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
"generate:models": "tsx ./scripts/generate-models.ts"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/object-info-parser": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@vercel/analytics": "catalog:",
"@vueuse/core": "catalog:",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -0,0 +1,396 @@
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const WORKFLOW_TEMPLATES_BASE =
'https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates'
const TEMPLATES_DIR = fileURLToPath(
new URL('../../../../workflow_templates/templates', import.meta.url)
)
const QUANT_SUFFIXES = [
'_fp8_e4m3fn_scaled',
'_fp8_e4m3fn',
'_fp8_scaled',
'_fp4_mixed',
'_fp8mixed',
'_fp8',
'_fp16',
'_fp4',
'_bf16',
'_int8'
]
interface RawModel {
name: string
url: string
directory: string
}
interface ModelData {
url: string
directory: string
templates: Set<string>
firstTemplate?: string
}
interface OutputModel {
slug: string
name: string
huggingFaceUrl: string
directory: string
workflowCount: number
displayName: string
docsUrl?: string
thumbnailUrl?: string
canonicalSlug?: string
}
// Maps api_*.json filename prefix to a canonical display name and slug.
// Add entries here as new partner integrations land in workflow_templates.
const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
nano: { name: 'Nano Banana', slug: 'nano-banana' },
kling: { name: 'Kling AI', slug: 'kling-ai' },
kling2: { name: 'Kling AI', slug: 'kling-ai' },
meshy: { name: 'Meshy AI', slug: 'meshy-ai' },
luma: { name: 'Luma Dream Machine', slug: 'luma-dream-machine' },
runway: { name: 'Runway', slug: 'runway' },
vidu: { name: 'Vidu', slug: 'vidu' },
bfl: { name: 'Flux (API)', slug: 'flux-api' },
grok: { name: 'Grok Image', slug: 'grok-image' },
stability: { name: 'Stability AI', slug: 'stability-ai' },
bytedance: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
bytedace: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
google: { name: 'Gemini Image', slug: 'gemini-image' },
hailuo: { name: 'Hailuo MiniMax', slug: 'hailuo-minimax' },
ideogram: { name: 'Ideogram', slug: 'ideogram' },
pixverse: { name: 'Pixverse', slug: 'pixverse' },
rodin: { name: 'Rodin 3D', slug: 'rodin-3d' },
magnific: { name: 'Magnific AI', slug: 'magnific-ai' },
bria: { name: 'Bria AI', slug: 'bria-ai' },
tripo: { name: 'Tripo 3D', slug: 'tripo-3d' },
tripo3: { name: 'Tripo 3D', slug: 'tripo-3d' },
hunyuan3d: { name: 'Hunyuan 3D', slug: 'hunyuan-3d' },
recraft: { name: 'Recraft', slug: 'recraft' },
topaz: { name: 'Topaz Labs', slug: 'topaz-labs' },
moonvalley: { name: 'Moonvalley', slug: 'moonvalley' },
ltxv: { name: 'LTX Video (API)', slug: 'ltxv-api' },
openai: { name: 'OpenAI DALL-E', slug: 'openai-dall-e' },
wan: { name: 'Wan (API)', slug: 'wan-api' },
wan2: { name: 'Wan (API)', slug: 'wan-api' },
veo2: { name: 'Veo 2', slug: 'veo-2' },
veo3: { name: 'Veo 3', slug: 'veo-3' },
flux2: { name: 'Flux 2 (API)', slug: 'flux-2-api' },
wavespeed: { name: 'Wavespeed', slug: 'wavespeed' },
wavespped: { name: 'Wavespeed', slug: 'wavespeed' }
}
function stripExt(name: string): string {
return name.replace(/\.(safetensors|ckpt|pt|bin)$/, '')
}
function stripQuant(base: string): string {
for (const suffix of QUANT_SUFFIXES) {
if (base.endsWith(suffix)) return base.slice(0, -suffix.length)
}
return base
}
function makeSlug(name: string): string {
const base = stripExt(name)
return base
.toLowerCase()
.replace(/[_.]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
function makeDisplayName(name: string): string {
const base = stripExt(name)
return base
.split(/[_-]/)
.map((part) => {
if (/^(fp\d+|bf\d+|int\d+)$/i.test(part)) return part.toUpperCase()
if (/^(e4m3fn|scaled|mixed|fp8mixed)$/i.test(part)) return part
if (/^\d+(\.\d+)?[bBkKmM]?$/.test(part)) return part
return part.charAt(0).toUpperCase() + part.slice(1)
})
.join(' ')
}
function extractModels(
obj: unknown,
templateName: string,
models: Map<string, ModelData>
): void {
if (obj === null || typeof obj !== 'object') return
if (Array.isArray(obj)) {
for (const item of obj) extractModels(item, templateName, models)
return
}
const record = obj as Record<string, unknown>
if (Array.isArray(record['models'])) {
for (const m of record['models'] as unknown[]) {
if (m === null || typeof m !== 'object' || Array.isArray(m)) continue
const model = m as Record<string, unknown>
if (typeof model['name'] !== 'string') continue
const name = model['name']
const url = typeof model['url'] === 'string' ? model['url'] : ''
const directory =
typeof model['directory'] === 'string' ? model['directory'] : ''
if (!models.has(name)) {
models.set(name, {
url,
directory,
templates: new Set(),
firstTemplate: templateName
})
}
models.get(name)!.templates.add(templateName)
}
}
for (const value of Object.values(record)) {
extractModels(value, templateName, models)
}
}
interface ApiModelData {
slug: string
name: string
directory: 'partner_nodes'
templateCount: number
}
function extractApiModels(files: string[]): ApiModelData[] {
const counts = new Map<string, number>()
for (const file of files) {
if (!file.startsWith('api_')) continue
const prefix = file.slice(4).split('_')[0]
const entry = API_PROVIDER_MAP[prefix]
if (!entry) continue
counts.set(entry.slug, (counts.get(entry.slug) ?? 0) + 1)
}
return [...counts.entries()].map(([slug, count]) => {
const found = Object.values(API_PROVIDER_MAP).find((e) => e.slug === slug)!
return {
slug,
name: found.name,
directory: 'partner_nodes' as const,
templateCount: count
}
})
}
// Reads all locale index.json files to build a map of
// raw model filename → tutorialUrl. Index entries name the template file;
// that file's embedded model objects give the actual filenames.
function buildTutorialUrlMap(templatesDir: string): Map<string, string> {
const map = new Map<string, string>()
const indexFiles = readdirSync(templatesDir).filter(
(f) =>
f.startsWith('index') &&
f.endsWith('.json') &&
!f.includes('schema') &&
!f.includes('logo')
)
// Collect template-name → tutorialUrl from all locale indexes (first wins)
const templateTutorialMap = new Map<string, string>()
const sorted = ['index.json', ...indexFiles.filter((f) => f !== 'index.json')]
for (const file of sorted) {
let data: unknown
try {
data = JSON.parse(readFileSync(join(templatesDir, file), 'utf8'))
} catch {
continue
}
if (!Array.isArray(data)) continue
for (const cat of data as unknown[]) {
if (typeof cat !== 'object' || cat === null) continue
const templates = (cat as Record<string, unknown>)['templates']
if (!Array.isArray(templates)) continue
for (const t of templates) {
if (typeof t !== 'object' || t === null) continue
const entry = t as Record<string, unknown>
const tutorialUrl =
typeof entry['tutorialUrl'] === 'string'
? entry['tutorialUrl']
: undefined
const templateName =
typeof entry['name'] === 'string' ? entry['name'] : undefined
if (
tutorialUrl &&
templateName &&
!templateTutorialMap.has(templateName)
) {
templateTutorialMap.set(templateName, tutorialUrl)
}
}
}
}
// For each template with a tutorialUrl, open the template file and map
// every embedded model filename to that tutorialUrl
for (const [templateName, tutorialUrl] of templateTutorialMap) {
const filePath = join(templatesDir, `${templateName}.json`)
let data: unknown
try {
data = JSON.parse(readFileSync(filePath, 'utf8'))
} catch {
continue
}
function extractModelNames(obj: unknown): void {
if (obj === null || typeof obj !== 'object') return
if (Array.isArray(obj)) {
for (const item of obj) extractModelNames(item)
return
}
const record = obj as Record<string, unknown>
if (Array.isArray(record['models'])) {
for (const m of record['models'] as unknown[]) {
if (m === null || typeof m !== 'object' || Array.isArray(m)) continue
const model = m as Record<string, unknown>
if (typeof model['name'] === 'string' && !map.has(model['name'])) {
map.set(model['name'], tutorialUrl)
}
}
}
for (const value of Object.values(record)) {
extractModelNames(value)
}
}
extractModelNames(data)
}
return map
}
function templateThumbnailUrl(
firstTemplate: string | undefined,
templatesDir: string
): string | undefined {
if (!firstTemplate) return undefined
const base = firstTemplate.replace(/\.json$/, '')
const localPath = join(templatesDir, `${base}-1.webp`)
if (!existsSync(localPath)) return undefined
return `${WORKFLOW_TEMPLATES_BASE}/${encodeURIComponent(base)}-1.webp`
}
function run(): void {
const models = new Map<string, ModelData>()
const files = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith('.json'))
for (const file of files) {
const filePath = join(TEMPLATES_DIR, file)
try {
const raw = readFileSync(filePath, 'utf8')
const data: unknown = JSON.parse(raw)
extractModels(data, file, models)
} catch (error) {
throw new Error(
`Failed to parse ${file}: ${
error instanceof Error ? error.message : String(error)
}`
)
}
}
const apiModels = extractApiModels(files)
const tutorialUrlMap = buildTutorialUrlMap(TEMPLATES_DIR)
const sorted = [...models.entries()].sort(
([, a], [, b]) => b.templates.size - a.templates.size
)
// Build quant convergence map
const groups = new Map<string, Array<[string, ModelData]>>()
for (const [name, data] of sorted) {
const base = stripExt(name)
const canonicalBase = stripQuant(base)
if (!groups.has(canonicalBase)) groups.set(canonicalBase, [])
groups.get(canonicalBase)!.push([name, data])
}
const canonicalMap = new Map<string, string | null>()
for (const members of groups.values()) {
if (members.length > 1) {
const membersSorted = [...members].sort(
([, a], [, b]) => b.templates.size - a.templates.size
)
const canonicalName = membersSorted[0][0]
canonicalMap.set(canonicalName, null)
for (const [name] of membersSorted.slice(1)) {
canonicalMap.set(name, canonicalName)
}
} else {
canonicalMap.set(members[0][0], null)
}
}
const output: OutputModel[] = sorted.map(([name, data]) => {
const canonicalRaw = canonicalMap.get(name) ?? null
const result: OutputModel = {
slug: makeSlug(name),
name,
huggingFaceUrl: data.url,
directory: data.directory,
workflowCount: data.templates.size,
displayName: makeDisplayName(name)
}
const docsUrl = tutorialUrlMap.get(name)
if (docsUrl) result.docsUrl = docsUrl
const thumb = templateThumbnailUrl(data.firstTemplate, TEMPLATES_DIR)
if (thumb) result.thumbnailUrl = thumb
if (canonicalRaw !== null) {
result.canonicalSlug = makeSlug(canonicalRaw)
}
return result
})
const apiOutput: OutputModel[] = apiModels
.sort((a, b) => b.templateCount - a.templateCount)
.map((m) => ({
slug: m.slug,
name: m.name,
huggingFaceUrl: '',
directory: m.directory,
workflowCount: m.templateCount,
displayName: m.name
}))
const combined = [...apiOutput, ...output]
const withThumbs = combined.filter((m) => m.thumbnailUrl).length
process.stdout.write(
` ${withThumbs}/${combined.length} models have thumbnails\n`
)
const defaultOut = join(
fileURLToPath(new URL('.', import.meta.url)),
'../src/config/generated-models.json'
)
const outputArg = process.argv[2] ?? defaultOut
const json = JSON.stringify(combined, null, 2) + '\n'
writeFileSync(outputArg, json, 'utf8')
process.stdout.write(
`Written ${combined.length} models ` +
`(${apiOutput.length} partner, ${output.length} local) to ${outputArg}\n`
)
}
try {
run()
} catch (err) {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
process.exit(1)
}

View File

@@ -0,0 +1,32 @@
import { renameSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { fetchCloudNodesForBuild } from '../src/utils/cloudNodes'
const snapshotPath = fileURLToPath(
new URL('../src/data/cloud-nodes.snapshot.json', import.meta.url)
)
const tempPath = `${snapshotPath}.tmp`
const outcome = await fetchCloudNodesForBuild()
if (outcome.status !== 'fresh') {
const reason = 'reason' in outcome ? outcome.reason : '(none)'
console.error(
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
)
process.exit(1)
}
const serialized = JSON.stringify(outcome.snapshot, null, 2) + '\n'
writeFileSync(tempPath, serialized, 'utf8')
renameSync(tempPath, snapshotPath)
const totalNodes = outcome.snapshot.packs.reduce(
(n, pack) => n + pack.nodes.length,
0
)
process.stdout.write(
`Wrote snapshot with ${outcome.snapshot.packs.length} pack(s) and ${totalNodes} node(s) to ${snapshotPath}\n`
)

View File

@@ -130,7 +130,7 @@ function scrollToDepartment(deptKey: string) {
<a
v-for="role in dept.roles"
:key="role.id"
:href="role.applyUrl"
:href="role.jobUrl"
target="_blank"
rel="noopener noreferrer"
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import SectionLabel from '../common/SectionLabel.vue'
const { locale = 'en' } = defineProps<{
locale?: Locale
}>()
</script>
<template>
<section class="px-6 pt-24 pb-14 md:px-20 md:pt-32 md:pb-20">
<div class="mx-auto flex w-full max-w-6xl flex-col gap-7">
<SectionLabel>
{{ t('cloudNodes.hero.label', locale) }}
</SectionLabel>
<h1
class="text-primary-comfy-canvas max-w-4xl text-4xl/tight md:text-6xl"
>
{{ t('cloudNodes.hero.heading', locale) }}
</h1>
<p class="text-primary-warm-gray max-w-3xl text-base/relaxed md:text-lg">
{{ t('cloudNodes.hero.body', locale) }}
</p>
</div>
</section>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { PackNode } from '../../data/cloudNodes'
import type { Locale } from '../../i18n/translations'
import { useNodesByCategory } from '../../composables/useNodesByCategory'
import { t } from '../../i18n/translations'
const { locale = 'en', nodes } = defineProps<{
locale?: Locale
nodes: readonly PackNode[]
}>()
const { groupedNodes } = useNodesByCategory(() => nodes)
</script>
<template>
<details
class="group border-primary-warm-gray/20 rounded-2xl border px-4 py-3"
>
<summary
class="text-primary-comfy-canvas cursor-pointer list-none text-sm font-semibold"
>
{{ t('cloudNodes.card.nodesHeading', locale) }}
</summary>
<div class="mt-4 flex flex-col gap-5">
<div
v-for="group in groupedNodes"
:key="group.category"
class="flex flex-col gap-2"
>
<h4
class="text-primary-warm-gray text-xs font-semibold tracking-widest uppercase"
>
{{ group.category }}
</h4>
<ul class="flex flex-col gap-1">
<li
v-for="node in group.nodes"
:key="node.name"
class="text-primary-comfy-canvas text-sm/relaxed"
>
{{ node.displayName }}
</li>
</ul>
</div>
</div>
</details>
</template>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import { useBannerImage } from '../../composables/useBannerImage'
const { bannerUrl, iconUrl, name } = defineProps<{
bannerUrl?: string
iconUrl?: string
name: string
}>()
const {
DEFAULT_BANNER,
isImageError,
showDefaultBanner,
imgSrc,
onImageError
} = useBannerImage({
bannerUrl: () => bannerUrl,
iconUrl: () => iconUrl
})
</script>
<template>
<div
class="z-0 aspect-7/3 w-full overflow-hidden"
data-testid="cloud-node-pack-banner"
>
<div v-if="showDefaultBanner" class="size-full">
<img
:src="DEFAULT_BANNER"
:alt="`${name} banner`"
class="size-full object-cover"
/>
</div>
<div v-else class="relative size-full">
<div
v-if="imgSrc && !isImageError"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
:style="{ backgroundImage: `url(${imgSrc})`, filter: 'blur(10px)' }"
/>
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="`${name} banner`"
:class="
isImageError
? 'relative z-10 size-full object-cover'
: 'relative z-10 size-full object-contain'
"
@error="onImageError"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import type { Pack } from '../../data/cloudNodes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import NodeList from './NodeList.vue'
import PackBanner from './PackBanner.vue'
const { locale = 'en', pack } = defineProps<{
locale?: Locale
pack: Pack
}>()
const detailHref =
locale === 'zh-CN'
? `/zh-CN/cloud/supported-nodes/${pack.id}`
: `/cloud/supported-nodes/${pack.id}`
function nodeCountLabel(nodeCount: number): string {
const key =
new Intl.PluralRules(locale).select(nodeCount) === 'one'
? 'cloudNodes.card.nodeCountOne'
: 'cloudNodes.card.nodeCountOther'
return t(key, locale).replace('{count}', String(nodeCount))
}
</script>
<template>
<article
class="bg-transparency-white-t5 border-primary-warm-gray/20 flex h-full flex-col overflow-hidden rounded-3xl border"
data-testid="cloud-node-pack-card"
>
<PackBanner
:banner-url="pack.bannerUrl"
:icon-url="pack.iconUrl"
:name="pack.displayName"
/>
<div class="flex flex-1 flex-col gap-5 p-5 md:p-6">
<div class="flex flex-col gap-2">
<h3 class="text-primary-comfy-canvas text-2xl/tight font-semibold">
<a
:href="detailHref"
class="hover:text-primary-comfy-yellow"
data-testid="cloud-node-pack-card-link"
>
{{ pack.displayName }}
</a>
</h3>
<p class="text-primary-warm-gray text-sm/relaxed">
{{
pack.description ||
t('cloudNodes.card.unavailableDescription', locale)
}}
</p>
</div>
<div class="flex flex-wrap items-center gap-3 text-sm">
<a
v-if="pack.repoUrl"
:href="pack.repoUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 font-semibold underline"
>
{{ t('cloudNodes.card.viewRepo', locale) }}
</a>
<span v-else class="text-primary-warm-gray">
{{ t('cloudNodes.card.viewRepo', locale) }}
</span>
<span class="text-primary-warm-gray"></span>
<span class="text-primary-comfy-canvas">{{
nodeCountLabel(pack.nodes.length)
}}</span>
</div>
<NodeList :locale="locale" :nodes="pack.nodes" class="mt-auto" />
</div>
</article>
</template>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Pack } from '../../data/cloudNodes'
import type { Locale } from '../../i18n/translations'
import {
formatLocalizedMediumDate,
formatLocalizedNumber
} from '@comfyorg/shared-frontend-utils/formatUtil'
import { useNodesByCategory } from '../../composables/useNodesByCategory'
import { t } from '../../i18n/translations'
import PackBanner from './PackBanner.vue'
const { pack, locale = 'en' } = defineProps<{
pack: Pack
locale?: Locale
}>()
const backHref =
locale === 'zh-CN' ? '/zh-CN/cloud/supported-nodes' : '/cloud/supported-nodes'
const { groupedNodes } = useNodesByCategory(() => pack.nodes)
</script>
<template>
<article
class="px-6 pb-20 md:px-20 md:pb-28"
data-testid="cloud-node-pack-detail"
>
<div class="mx-auto flex w-full max-w-6xl flex-col gap-8">
<a
:href="backHref"
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 w-fit text-sm font-semibold underline"
>
{{ t('cloudNodes.detail.back', locale) }}
</a>
<div
class="bg-transparency-white-t5 border-primary-warm-gray/20 overflow-hidden rounded-3xl border"
>
<PackBanner
:banner-url="pack.bannerUrl"
:icon-url="pack.iconUrl"
:name="pack.displayName"
/>
<div class="flex flex-col gap-7 p-5 md:p-6">
<header class="flex flex-col gap-2">
<h1
class="text-primary-comfy-canvas text-3xl/tight font-semibold md:text-4xl"
>
{{ pack.displayName }}
</h1>
<p
class="text-primary-warm-gray text-sm/relaxed md:text-base/relaxed"
>
{{
pack.description ||
t('cloudNodes.card.unavailableDescription', locale)
}}
</p>
</header>
<dl class="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.card.viewRepo', locale) }}
</dt>
<dd>
<a
v-if="pack.repoUrl"
:href="pack.repoUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 font-semibold underline"
>
{{ pack.repoUrl }}
</a>
<span v-else class="text-primary-comfy-canvas"></span>
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.detail.publisher', locale) }}
</dt>
<dd class="text-primary-comfy-canvas">
{{ pack.publisher?.name || pack.publisher?.id || '—' }}
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.detail.downloads', locale) }}
</dt>
<dd class="text-primary-comfy-canvas">
{{ formatLocalizedNumber(pack.downloads, locale) }}
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.detail.stars', locale) }}
</dt>
<dd class="text-primary-comfy-canvas">
{{ formatLocalizedNumber(pack.githubStars, locale) }}
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.detail.latestVersion', locale) }}
</dt>
<dd class="text-primary-comfy-canvas">
{{ pack.latestVersion || '—' }}
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.detail.license', locale) }}
</dt>
<dd class="text-primary-comfy-canvas">
{{ pack.license || '—' }}
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.detail.lastUpdated', locale) }}
</dt>
<dd class="text-primary-comfy-canvas">
{{ formatLocalizedMediumDate(pack.lastUpdated, locale) }}
</dd>
</div>
</dl>
<section class="flex flex-col gap-4">
<h2
class="text-primary-comfy-canvas text-xl font-semibold md:text-2xl"
>
{{ t('cloudNodes.detail.nodesHeading', locale) }}
</h2>
<section
v-for="group in groupedNodes"
:key="group.category"
class="border-primary-warm-gray/20 rounded-2xl border p-4"
>
<h3 class="text-primary-comfy-canvas text-base font-semibold">
{{ group.category }}
</h3>
<ul class="mt-3 flex flex-col gap-3">
<li
v-for="node in group.nodes"
:key="node.name"
class="border-primary-warm-gray/20 rounded-xl border p-3"
data-testid="cloud-node-pack-detail-node"
>
<div class="flex flex-wrap items-center gap-2">
<span
class="text-primary-comfy-canvas text-sm font-semibold"
>
{{ node.displayName }}
</span>
<span
v-if="node.experimental"
:class="
cn(
'bg-primary-comfy-yellow/20 text-primary-comfy-canvas rounded-full px-2 py-0.5 text-xs font-semibold'
)
"
>
{{ t('cloudNodes.detail.experimental', locale) }}
</span>
<span
v-if="node.deprecated"
:class="
cn(
'text-primary-comfy-canvas rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-semibold'
)
"
>
{{ t('cloudNodes.detail.deprecated', locale) }}
</span>
</div>
<p
v-if="node.description"
class="text-primary-warm-gray mt-2 text-sm/relaxed"
>
{{ node.description }}
</p>
</li>
</ul>
</section>
</section>
</div>
</div>
</div>
</article>
</template>

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import { ref } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import type { Pack } from '../../data/cloudNodes'
import type { Locale } from '../../i18n/translations'
import { useFilteredPacks } from '../../composables/useFilteredPacks'
import type { PackSortMode } from '../../composables/useFilteredPacks'
import { t } from '../../i18n/translations'
import SectionLabel from '../common/SectionLabel.vue'
import PackCard from './PackCard.vue'
const { locale = 'en', packs } = defineProps<{
locale?: Locale
packs: readonly Pack[]
}>()
const query = defineModel<string>('query', { default: '' })
const sortMode = ref<PackSortMode>('downloads')
const { filteredPacks } = useFilteredPacks({
packs: () => packs,
query,
sortMode
})
</script>
<template>
<section class="px-6 pb-20 md:px-20 md:pb-32">
<div class="mx-auto flex w-full max-w-6xl flex-col gap-8">
<div class="flex flex-col gap-3">
<SectionLabel>
{{ t('cloudNodes.hero.label', locale) }}
</SectionLabel>
<h2
class="text-primary-comfy-canvas text-3xl/tight font-medium md:text-4xl"
>
{{ t('cloudNodes.section.heading', locale) }}
</h2>
</div>
<div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
>
<label for="cloud-nodes-search" class="sr-only">
{{ t('cloudNodes.search.label', locale) }}
</label>
<input
id="cloud-nodes-search"
v-model="query"
:placeholder="t('cloudNodes.search.placeholder', locale)"
:class="
cn(
'bg-transparency-white-t5 border-primary-warm-gray/30 text-primary-comfy-canvas placeholder:text-primary-warm-gray/80 w-full rounded-2xl border px-4 py-3 text-sm md:max-w-md'
)
"
data-testid="cloud-nodes-search"
/>
<label for="cloud-nodes-sort" class="sr-only">
{{ t('cloudNodes.sort.label', locale) }}
</label>
<select
id="cloud-nodes-sort"
v-model="sortMode"
class="bg-transparency-white-t5 border-primary-warm-gray/30 text-primary-comfy-canvas w-full appearance-none rounded-2xl border bg-size-[0.65rem_0.65rem] bg-position-[right_1rem_center] bg-no-repeat py-3 pr-12 pl-4 text-sm md:w-64"
:style="{
backgroundImage:
'url(\'data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 12 12%22 fill=%22%23a39b8d%22><path d=%22M6 9.2L1.4 4.6 2.8 3.2 6 6.4l3.2-3.2 1.4 1.4z%22/></svg>\')'
}"
>
<option value="downloads">
{{ t('cloudNodes.sort.downloads', locale) }}
</option>
<option value="mostNodes">
{{ t('cloudNodes.sort.mostNodes', locale) }}
</option>
<option value="az">{{ t('cloudNodes.sort.az', locale) }}</option>
<option value="recentlyUpdated">
{{ t('cloudNodes.sort.recentlyUpdated', locale) }}
</option>
</select>
</div>
<p
v-if="filteredPacks.length === 0"
class="text-primary-warm-gray rounded-2xl border border-dashed border-current/30 px-5 py-6 text-sm"
>
<span class="text-primary-comfy-canvas block text-base font-semibold">
{{ t('cloudNodes.empty.heading', locale) }}
</span>
<span class="mt-2 block">{{ t('cloudNodes.empty.body', locale) }}</span>
</p>
<div
v-else
class="grid grid-cols-1 gap-5 md:grid-cols-2"
role="list"
:aria-label="t('cloudNodes.list.ariaLabel', locale)"
>
<PackCard
v-for="pack in filteredPacks"
:key="pack.id"
:pack="pack"
:locale="locale"
role="listitem"
/>
</div>
</div>
</section>
</template>

View File

@@ -26,7 +26,7 @@ const {
<img
src="/icons/node-left.svg"
alt=""
class="-mx-px self-stretch"
class="-mx-px h-full w-auto self-stretch"
aria-hidden="true"
/>
@@ -38,7 +38,7 @@ const {
v-if="i > 0"
src="/icons/node-union.svg"
alt=""
class="-mx-px self-stretch"
class="-mx-px h-full w-auto self-stretch"
aria-hidden="true"
/>
<span
@@ -72,7 +72,7 @@ const {
<img
src="/icons/node-right.svg"
alt=""
class="-mx-px self-stretch"
class="-mx-px h-full w-auto self-stretch"
aria-hidden="true"
/>
</div>

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import BrandButton from '../common/BrandButton.vue'
import { t } from '../../i18n/translations'
const {
displayName,
huggingFaceUrl,
docsUrl,
blogUrl,
hubSlug,
workflowCount,
directory
} = defineProps<{
displayName: string
huggingFaceUrl: string
docsUrl?: string
blogUrl?: string
hubSlug?: string
workflowCount: number
directory: string
}>()
const workflowsUrl = hubSlug
? `https://www.comfy.org/workflows/model/${hubSlug}`
: null
const dirDisplayMap: Record<string, string> = {
diffusion_models: 'Diffusion Model',
checkpoints: 'Checkpoint',
loras: 'LoRA',
controlnet: 'ControlNet',
clip_vision: 'CLIP Vision',
model_patches: 'Model Patch',
vae: 'VAE',
text_encoders: 'Text Encoder',
audio_encoders: 'Audio Encoder',
latent_upscale_models: 'Latent Upscale Model',
upscale_models: 'Upscale Model',
style_models: 'Style Model',
partner_nodes: 'Partner Node'
}
const eyebrow = dirDisplayMap[directory] ?? directory
const isPartnerNode = directory === 'partner_nodes'
</script>
<template>
<section
:class="
cn(
'mx-auto flex max-w-7xl flex-col gap-8 px-6 py-16',
'lg:flex-row lg:items-center lg:gap-16 lg:px-8 lg:py-24'
)
"
>
<div class="flex max-w-2xl flex-1 flex-col gap-6">
<p
class="text-sm font-medium uppercase tracking-widest text-primary-comfy-yellow"
>
{{ eyebrow }}
</p>
<h1 class="text-4xl font-bold text-primary-comfy-canvas lg:text-6xl">
{{ displayName }} in ComfyUI
</h1>
<p class="text-sm text-primary-comfy-canvas/60">
{{
t('models.hero.workflowCount').replace(
'{count}',
String(workflowCount)
)
}}
</p>
<div class="flex flex-col gap-3 sm:flex-row">
<BrandButton
v-if="workflowsUrl"
:href="workflowsUrl"
variant="solid"
size="lg"
class="w-full uppercase sm:w-auto sm:min-w-48"
>
{{ t('models.hero.primaryCta') }}
</BrandButton>
<BrandButton
v-if="!isPartnerNode && huggingFaceUrl"
:href="huggingFaceUrl"
target="_blank"
rel="noopener noreferrer"
:variant="workflowsUrl ? 'outline' : 'solid'"
size="lg"
class="w-full uppercase sm:w-auto sm:min-w-48"
>
{{ t('models.hero.secondaryCta') }}
</BrandButton>
<BrandButton
v-if="!workflowsUrl"
href="https://www.comfy.org/cloud"
target="_blank"
rel="noopener noreferrer"
:variant="huggingFaceUrl && !isPartnerNode ? 'outline' : 'solid'"
size="lg"
class="w-full uppercase sm:w-auto sm:min-w-48"
>
{{ t('models.hero.cloudCta') }}
</BrandButton>
<BrandButton
v-if="docsUrl"
:href="docsUrl"
target="_blank"
rel="noopener noreferrer"
variant="outline"
size="lg"
class="w-full uppercase sm:w-auto sm:min-w-48"
>
{{ t('models.hero.tutorialCta') }}
</BrandButton>
</div>
<div v-if="blogUrl" class="text-sm text-primary-comfy-canvas/60">
<a
:href="blogUrl"
target="_blank"
rel="noopener noreferrer"
class="hover:text-primary-comfy-canvas underline"
>
{{ t('models.hero.blogLink') }}
</a>
</div>
</div>
</section>
</template>

View File

@@ -7,6 +7,7 @@ import { ref } from 'vue'
import BrandButton from '../common/BrandButton.vue'
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
import PricingTierCard from './PricingTierCard.vue'
import { SHOW_FREE_TIER } from '../../config/features'
import { externalLinks, getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
@@ -37,21 +38,23 @@ interface PricingPlan {
isEnterprise?: boolean
}
const freePlan: PricingPlan = {
id: 'free',
labelKey: 'pricing.plan.free.label',
summaryKey: 'pricing.plan.free.summary',
priceKey: 'pricing.plan.free.price',
creditsKey: 'pricing.plan.free.credits',
estimateKey: 'pricing.plan.free.estimate',
ctaKey: 'pricing.plan.free.cta',
ctaHref: externalLinks.cloud,
features: [
{ text: 'pricing.plan.free.feature1' },
{ text: 'pricing.plan.free.feature2' }
]
}
const plans: PricingPlan[] = [
{
id: 'free',
labelKey: 'pricing.plan.free.label',
summaryKey: 'pricing.plan.free.summary',
priceKey: 'pricing.plan.free.price',
creditsKey: 'pricing.plan.free.credits',
estimateKey: 'pricing.plan.free.estimate',
ctaKey: 'pricing.plan.free.cta',
ctaHref: externalLinks.cloud,
features: [
{ text: 'pricing.plan.free.feature1' },
{ text: 'pricing.plan.free.feature2' }
]
},
...(SHOW_FREE_TIER ? [freePlan] : []),
{
id: 'standard',
labelKey: 'pricing.plan.standard.label',
@@ -61,7 +64,9 @@ const plans: PricingPlan[] = [
estimateKey: 'pricing.plan.standard.estimate',
ctaKey: 'pricing.plan.standard.cta',
ctaHref: subscribeUrl('standard'),
featureIntroKey: 'pricing.plan.standard.featureIntro',
featureIntroKey: SHOW_FREE_TIER
? 'pricing.plan.standard.featureIntro'
: undefined,
features: [
{ text: 'pricing.plan.standard.feature1' },
{ text: 'pricing.plan.standard.feature2' }
@@ -150,9 +155,14 @@ const activePlanIndex = ref(0)
</button>
</div>
<!-- Desktop: 4-column grid / Mobile: single card -->
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: single card -->
<div
class="rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:grid-cols-4 lg:gap-2"
:class="
cn(
'rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:gap-2',
standardPlans.length === 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3'
)
"
>
<PricingTierCard v-for="plan in standardPlans" :key="plan.id">
<!-- Label + badge -->
@@ -223,10 +233,18 @@ const activePlanIndex = ref(0)
<!-- Features -->
<div v-if="plan.features.length" class="px-6 py-3">
<p class="text-primary-comfy-canvas mb-2 text-sm font-semibold">
{{
plan.featureIntroKey ? t(plan.featureIntroKey, locale) : '&nbsp;'
}}
<p
v-if="plan.featureIntroKey"
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
>
{{ t(plan.featureIntroKey, locale) }}
</p>
<p
v-else
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
aria-hidden="true"
>
&nbsp;
</p>
<ul class="space-y-2">
<li

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import { SHOW_FREE_TIER } from '../../../config/features'
import { getRoutes } from '../../../config/routes'
import { t } from '../../../i18n/translations'
@@ -25,7 +26,10 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
{{ t('cloud.pricing.description', locale) }}
</p>
<p class="text-primary-comfy-ink mt-4 text-base font-bold">
<p
v-if="SHOW_FREE_TIER"
class="text-primary-comfy-ink mt-4 text-base font-bold"
>
{{ t('cloud.pricing.tagline', locale) }}
</p>
</div>

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import { useBannerImage } from './useBannerImage'
describe('useBannerImage', () => {
it('shows the default banner when neither url is provided', () => {
const { showDefaultBanner, imgSrc } = useBannerImage({})
expect(showDefaultBanner.value).toBe(true)
expect(imgSrc.value).toBeUndefined()
})
it('prefers bannerUrl over iconUrl when both are provided', () => {
const { showDefaultBanner, imgSrc } = useBannerImage({
bannerUrl: 'https://example.com/banner.png',
iconUrl: 'https://example.com/icon.png'
})
expect(showDefaultBanner.value).toBe(false)
expect(imgSrc.value).toBe('https://example.com/banner.png')
})
it('falls back to iconUrl when bannerUrl is missing', () => {
const { imgSrc } = useBannerImage({
iconUrl: 'https://example.com/icon.png'
})
expect(imgSrc.value).toBe('https://example.com/icon.png')
})
it('reactively updates when sources change', () => {
const banner = ref<string | undefined>(undefined)
const { showDefaultBanner, imgSrc } = useBannerImage({ bannerUrl: banner })
expect(showDefaultBanner.value).toBe(true)
banner.value = 'https://example.com/new.png'
expect(showDefaultBanner.value).toBe(false)
expect(imgSrc.value).toBe('https://example.com/new.png')
})
it('flips isImageError when onImageError is called', () => {
const { isImageError, onImageError } = useBannerImage({
bannerUrl: 'x'
})
expect(isImageError.value).toBe(false)
onImageError()
expect(isImageError.value).toBe(true)
})
it('exposes the default banner constant for consumers', () => {
const { DEFAULT_BANNER } = useBannerImage({})
expect(DEFAULT_BANNER).toBe('/assets/images/fallback-gradient-avatar.svg')
})
})

View File

@@ -0,0 +1,30 @@
import { computed, ref, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
interface UseBannerImageInput {
bannerUrl?: MaybeRefOrGetter<string | undefined>
iconUrl?: MaybeRefOrGetter<string | undefined>
}
export function useBannerImage({ bannerUrl, iconUrl }: UseBannerImageInput) {
const isImageError = ref(false)
const showDefaultBanner = computed(
() => !toValue(bannerUrl) && !toValue(iconUrl)
)
const imgSrc = computed(() => toValue(bannerUrl) || toValue(iconUrl))
function onImageError() {
isImageError.value = true
}
return {
DEFAULT_BANNER,
isImageError,
showDefaultBanner,
imgSrc,
onImageError
}
}

View File

@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import type { Pack, PackNode } from '../data/cloudNodes'
import { useFilteredPacks } from './useFilteredPacks'
import type { PackSortMode } from './useFilteredPacks'
function pack(overrides: Partial<Pack> = {}): Pack {
return {
id: overrides.id ?? 'pack',
displayName: overrides.displayName ?? 'Pack',
nodes: overrides.nodes ?? [],
downloads: overrides.downloads,
lastUpdated: overrides.lastUpdated,
...overrides
}
}
function node(name: string, displayName: string): PackNode {
return { name, displayName, category: 'x' }
}
describe('useFilteredPacks', () => {
const packs: readonly Pack[] = [
pack({
id: 'a',
displayName: 'Alpha',
downloads: 100,
lastUpdated: '2025-01-01T00:00:00Z',
nodes: [node('aa', 'Aardvark')]
}),
pack({
id: 'b',
displayName: 'Beta',
downloads: 300,
lastUpdated: '2025-06-01T00:00:00Z',
nodes: [node('bb', 'Beaver'), node('bb2', 'Bumblebee')]
}),
pack({
id: 'c',
displayName: 'Gamma',
downloads: 200,
lastUpdated: '2025-03-01T00:00:00Z',
nodes: [
node('cc', 'Cat'),
node('cc2', 'Crocodile'),
node('cc3', 'Capybara')
]
})
]
it('sorts by downloads desc by default', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: '',
sortMode: 'downloads' as PackSortMode
})
expect(filteredPacks.value.map((p) => p.id)).toEqual(['b', 'c', 'a'])
})
it('sorts most-nodes places highest count first', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: '',
sortMode: 'mostNodes' as PackSortMode
})
expect(filteredPacks.value.map((p) => p.id)).toEqual(['c', 'b', 'a'])
})
it('sorts A → Z by display name', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: '',
sortMode: 'az' as PackSortMode
})
expect(filteredPacks.value.map((p) => p.displayName)).toEqual([
'Alpha',
'Beta',
'Gamma'
])
})
it('sorts recently updated newest first', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: '',
sortMode: 'recentlyUpdated' as PackSortMode
})
expect(filteredPacks.value.map((p) => p.id)).toEqual(['b', 'c', 'a'])
})
it('treats invalid lastUpdated as 0', () => {
const broken = [
pack({ id: 'x', lastUpdated: 'nonsense' }),
pack({ id: 'y', lastUpdated: '2025-01-01T00:00:00Z' })
]
const { filteredPacks } = useFilteredPacks({
packs: broken,
query: '',
sortMode: 'recentlyUpdated' as PackSortMode
})
expect(filteredPacks.value[0].id).toBe('y')
})
it('matches the search query against pack display names', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: 'beta',
sortMode: 'az' as PackSortMode
})
expect(filteredPacks.value.map((p) => p.id)).toEqual(['b'])
})
it('matches the search query against node display names', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: 'CAPYBARA',
sortMode: 'az' as PackSortMode
})
expect(filteredPacks.value.map((p) => p.id)).toEqual(['c'])
})
it('returns empty when nothing matches', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: 'zzz-no-such-thing',
sortMode: 'az' as PackSortMode
})
expect(filteredPacks.value).toHaveLength(0)
})
it('reacts when the query ref changes', () => {
const query = ref('beta')
const { filteredPacks } = useFilteredPacks({
packs,
query,
sortMode: 'az' as PackSortMode
})
expect(filteredPacks.value).toHaveLength(1)
query.value = ''
expect(filteredPacks.value).toHaveLength(3)
})
})

View File

@@ -0,0 +1,53 @@
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import type { Pack } from '../data/cloudNodes'
export type PackSortMode = 'downloads' | 'mostNodes' | 'az' | 'recentlyUpdated'
interface UseFilteredPacksInput {
packs: MaybeRefOrGetter<readonly Pack[]>
query: MaybeRefOrGetter<string>
sortMode: MaybeRefOrGetter<PackSortMode>
}
function matchesQuery(pack: Pack, normalizedQuery: string): boolean {
if (pack.displayName.toLowerCase().includes(normalizedQuery)) return true
return pack.nodes.some((node) =>
node.displayName.toLowerCase().includes(normalizedQuery)
)
}
function safeTimestamp(value: string | undefined): number {
if (!value) return 0
const ts = Date.parse(value)
return Number.isNaN(ts) ? 0 : ts
}
export function useFilteredPacks(input: UseFilteredPacksInput) {
const filteredPacks = computed<Pack[]>(() => {
const allPacks = toValue(input.packs)
const normalizedQuery = toValue(input.query).trim().toLowerCase()
const matching =
normalizedQuery.length === 0
? [...allPacks]
: allPacks.filter((pack) => matchesQuery(pack, normalizedQuery))
const mode = toValue(input.sortMode)
if (mode === 'az') {
return matching.sort((a, b) => a.displayName.localeCompare(b.displayName))
}
if (mode === 'recentlyUpdated') {
return matching.sort(
(a, b) => safeTimestamp(b.lastUpdated) - safeTimestamp(a.lastUpdated)
)
}
if (mode === 'mostNodes') {
return matching.sort((a, b) => b.nodes.length - a.nodes.length)
}
return matching.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0))
})
return { filteredPacks }
}

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import type { PackNode } from '../data/cloudNodes'
import { useNodesByCategory } from './useNodesByCategory'
function node(name: string, displayName: string, category: string): PackNode {
return { name, displayName, category }
}
describe('useNodesByCategory', () => {
it('groups nodes by category', () => {
const { groupedNodes } = useNodesByCategory(() => [
node('A', 'A', 'cat-1'),
node('B', 'B', 'cat-2'),
node('C', 'C', 'cat-1')
])
expect(groupedNodes.value).toHaveLength(2)
expect(groupedNodes.value[0]).toMatchObject({
category: 'cat-1',
nodes: [
expect.objectContaining({ name: 'A' }),
expect.objectContaining({ name: 'C' })
]
})
})
it('sorts nodes alphabetically by display name within a category', () => {
const { groupedNodes } = useNodesByCategory(() => [
node('z', 'Zulu', 'x'),
node('a', 'Alpha', 'x'),
node('m', 'Mike', 'x')
])
expect(groupedNodes.value[0].nodes.map((n) => n.displayName)).toEqual([
'Alpha',
'Mike',
'Zulu'
])
})
it('sorts categories alphabetically', () => {
const { groupedNodes } = useNodesByCategory(() => [
node('a', 'A', 'beta'),
node('b', 'B', 'alpha'),
node('c', 'C', 'gamma')
])
expect(groupedNodes.value.map((g) => g.category)).toEqual([
'alpha',
'beta',
'gamma'
])
})
it('falls back to a placeholder for missing categories', () => {
const { groupedNodes } = useNodesByCategory(() => [node('a', 'A', '')])
expect(groupedNodes.value[0].category).toBe('—')
})
it('reacts to ref changes', () => {
const nodes = ref<PackNode[]>([node('a', 'A', 'x')])
const { groupedNodes } = useNodesByCategory(nodes)
expect(groupedNodes.value).toHaveLength(1)
nodes.value = [node('a', 'A', 'x'), node('b', 'B', 'y')]
expect(groupedNodes.value).toHaveLength(2)
})
})

View File

@@ -0,0 +1,40 @@
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import type { PackNode } from '../data/cloudNodes'
const UNCATEGORIZED = '—'
interface NodeCategoryGroup {
category: string
nodes: PackNode[]
}
export function useNodesByCategory(
nodes: MaybeRefOrGetter<readonly PackNode[]>
) {
const groupedNodes = computed<NodeCategoryGroup[]>(() => {
const byCategory = new Map<string, PackNode[]>()
for (const node of toValue(nodes)) {
const category = node.category || UNCATEGORIZED
const existing = byCategory.get(category)
if (existing) {
existing.push(node)
continue
}
byCategory.set(category, [node])
}
return [...byCategory.entries()]
.map(([category, items]) => ({
category,
nodes: [...items].sort((a, b) =>
a.displayName.localeCompare(b.displayName)
)
}))
.sort((a, b) => a.category.localeCompare(b.category))
})
return { groupedNodes }
}

View File

@@ -0,0 +1 @@
export const SHOW_FREE_TIER = false

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,208 @@
interface ModelOverride {
docsUrl?: string
blogUrl?: string
featured?: boolean
// Slug used on comfy.org/workflows/model/{hubSlug}. Only set when the page exists.
hubSlug?: string
}
export const modelMetadata: Record<string, ModelOverride> = {
'nano-banana': {
docsUrl:
'https://docs.comfy.org/tutorials/partner-nodes/google/nano-banana-pro',
hubSlug: 'nano-banana',
featured: true
},
'kling-ai': {
docsUrl:
'https://docs.comfy.org/tutorials/partner-nodes/kling/kling-motion-control',
hubSlug: 'kling',
featured: true
},
'meshy-ai': {
docsUrl: 'https://docs.comfy.org/tutorials/partner-nodes/meshy/meshy-6',
hubSlug: 'meshy',
featured: true
},
'openai-dall-e': {
docsUrl: 'https://docs.comfy.org/tutorials/partner-nodes/openai/dall-e-3',
hubSlug: 'openai',
featured: true
},
'ltxv-api': {
docsUrl: 'https://docs.comfy.org/tutorials/video/ltxv',
hubSlug: 'ltx-2-3',
featured: true
},
'wan-api': {
docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan2_2',
hubSlug: 'wan',
featured: true
},
'wan-2-2': {
docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan2_2',
hubSlug: 'wan',
featured: true
},
'wan-2-1': {
docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan-video',
hubSlug: 'wan',
featured: true
},
'flux-1-kontext-dev': {
docsUrl:
'https://docs.comfy.org/tutorials/partner-nodes/black-forest-labs/flux-1-kontext',
hubSlug: 'flux-1-kontext',
featured: true
},
'flux1-dev': {
docsUrl: 'https://docs.comfy.org/tutorials/flux/flux-1-text-to-image',
hubSlug: 'flux-1',
featured: true
},
'flux1-schnell': {
hubSlug: 'flux-1',
featured: true
},
'hunyuan-video': {
docsUrl: 'https://docs.comfy.org/tutorials/video/hunyuan/hunyuan-video',
hubSlug: 'hunyuan-video',
featured: true
},
'hunyuan-3d': {
docsUrl: 'https://docs.comfy.org/tutorials/3d/hunyuan3D-2',
hubSlug: 'hunyuan-3d',
featured: true
},
vidu: {
hubSlug: 'vidu',
featured: true
},
runway: {
hubSlug: 'runway',
featured: true
},
'stability-ai': {
hubSlug: 'stability',
featured: true
},
'seedance-bytedance': {
hubSlug: 'seedance',
featured: true
},
'grok-image': {
hubSlug: 'grok',
featured: false
},
'luma-dream-machine': {
hubSlug: 'luma',
featured: false
},
moonvalley: {
hubSlug: 'moonvalley',
featured: false
},
'magnific-ai': {
hubSlug: 'magnific',
featured: false
},
pixverse: {
hubSlug: 'pixverse',
featured: false
},
'rodin-3d': {
hubSlug: 'rodin',
featured: false
},
recraft: {
hubSlug: 'recraft',
featured: false
},
'bria-ai': {
hubSlug: 'bria',
featured: false
},
'topaz-labs': {
hubSlug: 'topaz',
featured: false
},
wavespeed: {
hubSlug: 'wavespeed',
featured: false
},
ideogram: {
hubSlug: 'ideogram',
featured: false
},
'veo-2': {
hubSlug: 'veo',
featured: false
},
'veo-3': {
hubSlug: 'veo',
featured: false
},
'flux-2-api': {
hubSlug: 'flux-2',
featured: false
},
'ace-step-v1-3-5b': {
docsUrl: 'https://docs.comfy.org/tutorials/audio/ace-step/ace-step-v1',
hubSlug: 'ace-step',
featured: false
},
'hidream-i1-dev-fp8': {
docsUrl: 'https://docs.comfy.org/tutorials/image/hidream/hidream-i1',
hubSlug: 'hidream',
featured: false
},
'omnigen2-fp16': {
hubSlug: 'omnigen',
featured: false
},
'sd-xl-base-1-0': {
hubSlug: 'sdxl',
featured: false
},
'z-image-bf16': {
hubSlug: 'z-image',
featured: false
},
'z-image-turbo-bf16': {
hubSlug: 'z-image',
featured: false
},
'svd-xt': {
hubSlug: 'svd',
featured: false
},
'flux1-dev-kontext-fp8-scaled': {
docsUrl: 'https://docs.comfy.org/tutorials/flux/flux-1-kontext-dev',
hubSlug: 'flux-1-kontext',
featured: false
},
'ltx-2-19b-dev-fp8': {
hubSlug: 'ltx-2',
featured: false
},
'ltx-2-19b-distilled': {
hubSlug: 'ltx-2',
featured: false
},
'flux1-fill-dev': {
hubSlug: 'flux-1',
featured: false
},
'flux-2-klein-base-9b-fp8': {
hubSlug: 'flux-2',
featured: false
},
'qwen-image-fp8-e4m3fn': {
hubSlug: 'qwen',
featured: false
},
'qwen-image-edit-2509-fp8-e4m3fn': {
hubSlug: 'qwen',
featured: false
}
}

View File

@@ -0,0 +1,81 @@
import generatedModels from './generated-models.json'
import { modelMetadata } from './model-metadata'
type ModelDirectory =
| 'diffusion_models'
| 'checkpoints'
| 'loras'
| 'controlnet'
| 'clip_vision'
| 'model_patches'
| 'vae'
| 'text_encoders'
| 'audio_encoders'
| 'latent_upscale_models'
| 'upscale_models'
| 'style_models'
| 'partner_nodes'
interface Model {
readonly slug: string
readonly canonicalSlug?: string
readonly name: string
readonly displayName: string
readonly directory: ModelDirectory
readonly huggingFaceUrl: string
readonly thumbnailUrl?: string
readonly docsUrl?: string
readonly blogUrl?: string
readonly hubSlug?: string
readonly featured: boolean
readonly workflowCount: number
}
export const models: readonly Model[] = (
generatedModels as Array<{
slug: string
canonicalSlug?: string
name: string
displayName: string
directory: string
huggingFaceUrl: string
docsUrl?: string
thumbnailUrl?: string
workflowCount: number
}>
).map((m) => ({
slug: m.slug,
...(m.canonicalSlug ? { canonicalSlug: m.canonicalSlug } : {}),
name: m.name,
displayName: m.displayName,
directory: m.directory as ModelDirectory,
huggingFaceUrl: m.huggingFaceUrl,
...(m.docsUrl ? { docsUrl: m.docsUrl } : {}),
...(m.thumbnailUrl ? { thumbnailUrl: m.thumbnailUrl } : {}),
featured: false,
workflowCount: m.workflowCount,
...modelMetadata[m.slug]
}))
const slugSet = new Set(models.map((m) => m.slug))
if (slugSet.size !== models.length) {
for (const model of models) {
if (models.filter((m) => m.slug === model.slug).length > 1) {
throw new Error(`Duplicate model slug: ${model.slug}`)
}
}
}
for (const model of models) {
if (
model.canonicalSlug !== undefined &&
(!slugSet.has(model.canonicalSlug) || model.canonicalSlug === model.slug)
) {
throw new Error(
`Invalid canonicalSlug "${model.canonicalSlug}" on "${model.slug}"`
)
}
}
export function getModelBySlug(slug: string): Model | undefined {
return models.find((m) => m.slug === slug)
}

View File

@@ -14,7 +14,8 @@ const baseRoutes = {
demos: '/demos',
termsOfService: '/terms-of-service',
privacyPolicy: '/privacy-policy',
contact: '/contact'
contact: '/contact',
models: '/p/supported-models'
} as const
type Routes = typeof baseRoutes

View File

@@ -1,37 +1,30 @@
{
"fetchedAt": "2026-05-02T20:15:18.321Z",
"fetchedAt": "2026-05-14T14:47:42.884Z",
"departments": [
{
"name": "DESIGN",
"key": "design",
"roles": [
{
"id": "e915f2c78b17f93b",
"id": "18743530eb448c99",
"title": "Senior Product Designer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3"
},
{
"id": "b9f9a23219be7cd4",
"title": "Design Engineer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application"
},
{
"id": "547b6ba622c800a5",
"id": "8718d17012f26fa2",
"title": "Senior Product Designer - Craft",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f"
},
{
"id": "7bb02634a24763bc",
"id": "1e181b9ed8fb2e86",
"title": "Staff Product Designer - Systems",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34"
}
]
},
@@ -40,67 +33,67 @@
"key": "engineering",
"roles": [
{
"id": "102d58e35a8a9817",
"id": "6a6d865eeb3c10a8",
"title": "Senior Software Engineer, Frontend",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2"
},
{
"id": "d01d69fba7743905",
"id": "1b4f7f1da9616e14",
"title": "Senior Software Engineer, Backend Generalist",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e"
},
{
"id": "f36f60cfd5bb5910",
"id": "a6d8269c66e37c5c",
"title": "Senior/Staff Applied Machine Learning Engineer",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0"
},
{
"id": "9d8ec4c65e20b19e",
"id": "841da783e6e41928",
"title": "Software Engineer, Frontend",
"department": "Engineering",
"location": "Remote",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40"
},
{
"id": "be94b193d1f4d482",
"id": "5d01d58b03870d7a",
"title": "Tech Lead Manager, Frontend",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261"
},
{
"id": "ab48f5db6bd1783c",
"id": "91604c4182a1bc3c",
"title": "Software Engineer, Core ComfyUI Contributor",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f"
},
{
"id": "c5dff4ee628bdcd1",
"id": "a1dbc0576ab14034",
"title": "Software Engineer, ComfyUI Desktop",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0"
},
{
"id": "4302a7aaa87e16e3",
"id": "0b8f4fecd89c3b11",
"title": "Product Manager, ComfyUI",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e"
},
{
"id": "2eb53e8943cc9396",
"id": "2f6bac39d723dfef",
"title": "Growth Engineer",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5"
}
]
},
@@ -109,39 +102,46 @@
"key": "marketing",
"roles": [
{
"id": "4c5d6afb78652df7",
"id": "23dd98cab77ff459",
"title": "Freelance Motion Designer",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b"
},
{
"id": "0f5256cf302e552b",
"id": "a998b9fc973ff3c0",
"title": "Creative Artist",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d"
},
{
"id": "5746486d87874937",
"id": "3e730938026d6e70",
"title": "Graphic Designer",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f"
},
{
"id": "b5803a0d4785d406",
"id": "6f771af6858283aa",
"title": "Lifecycle Growth Marketer",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64"
},
{
"id": "130d7218d7895bdb",
"id": "527a47e82970afc1",
"title": "Partnership & Events Marketing Manager",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c"
},
{
"id": "e11f8b9e58dbea81",
"title": "Creative Producer",
"department": "Marketing",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7be2d690-7a2b-4ebf-b1c4-6907b273d3d9"
}
]
},
@@ -150,25 +150,18 @@
"key": "operations",
"roles": [
{
"id": "ec68ae44dd5943c9",
"title": "Talent Lead",
"id": "0c6cc3685194ab7a",
"title": "Head of Talent",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362"
},
{
"id": "16f556001ce1cef4",
"title": "BizOps Strategist",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/145b8558-0ab4-43e8-8fac-b59059cf2537/application"
},
{
"id": "8e773a72c1b8e099",
"id": "82bd6ed26adab1c3",
"title": "Founding Customer Success Manager",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
export interface PackNode {
name: string
displayName: string
category: string
description?: string
deprecated?: boolean
experimental?: boolean
}
export interface Pack {
id: string
registryId?: string
displayName: string
description?: string
bannerUrl?: string
iconUrl?: string
repoUrl?: string
publisher?: {
id: string
name?: string
}
downloads?: number
githubStars?: number
latestVersion?: string
license?: string
lastUpdated?: string
supportedOs?: string[]
supportedAccelerators?: string[]
nodes: PackNode[]
}
export interface NodesSnapshot {
fetchedAt: string
packs: Pack[]
}
export function isNodesSnapshot(value: unknown): value is NodesSnapshot {
if (value === null || typeof value !== 'object') return false
const candidate = value as { fetchedAt?: unknown; packs?: unknown }
if (typeof candidate.fetchedAt !== 'string') return false
if (!Array.isArray(candidate.packs)) return false
return candidate.packs.every((pack) => {
if (pack === null || typeof pack !== 'object') return false
const p = pack as { id?: unknown; displayName?: unknown; nodes?: unknown }
return (
typeof p.id === 'string' &&
typeof p.displayName === 'string' &&
Array.isArray(p.nodes)
)
})
}

View File

@@ -3,7 +3,7 @@ export interface Role {
title: string
department: string
location: string
applyUrl: string
jobUrl: string
}
export interface Department {

View File

@@ -735,6 +735,142 @@ const translations = {
'zh-CN': '免费试用 COMFY CLOUD'
},
'cloudNodes.hero.label': {
en: 'CLOUD NODES',
'zh-CN': '云端节点目录'
},
'cloudNodes.hero.heading': {
en: 'Run your favorite ComfyUI custom nodes on the cloud',
'zh-CN': '在云端运行你喜爱的 ComfyUI 自定义节点'
},
'cloudNodes.hero.body': {
en: 'Spin up workflows with hundreds of community-built nodes — detailers, ControlNet preprocessors, animation tools, and quality-of-life utilities — preinstalled on Comfy Cloud and ready to run on managed GPUs.',
'zh-CN':
'在 Comfy Cloud 托管 GPU 上即开即用预装数百个社区节点——细节修复、ControlNet 预处理、动画工具与日常便利组件,应有尽有。'
},
'cloudNodes.section.heading': {
en: 'Find a custom-node pack',
'zh-CN': '查找自定义节点包'
},
'cloudNodes.search.placeholder': {
en: 'Search packs or nodes',
'zh-CN': '搜索节点包或节点名称'
},
'cloudNodes.sort.downloads': {
en: 'Most installed',
'zh-CN': '按安装量'
},
'cloudNodes.sort.mostNodes': {
en: 'Most nodes',
'zh-CN': '按节点数量'
},
'cloudNodes.sort.az': {
en: 'A → Z',
'zh-CN': '按名称 A → Z'
},
'cloudNodes.sort.recentlyUpdated': {
en: 'Recently updated',
'zh-CN': '最近更新'
},
'cloudNodes.search.label': {
en: 'Search custom-node packs',
'zh-CN': '搜索自定义节点包'
},
'cloudNodes.sort.label': {
en: 'Sort packs',
'zh-CN': '排序节点包'
},
'cloudNodes.list.ariaLabel': {
en: 'Custom-node packs supported on Comfy Cloud',
'zh-CN': 'Comfy Cloud 支持的自定义节点包'
},
'cloudNodes.meta.title': {
en: 'Custom-node packs on Comfy Cloud — supported by default',
'zh-CN': 'Comfy Cloud 自定义节点包合集——开箱即用'
},
'cloudNodes.meta.description': {
en: 'Browse hundreds of ComfyUI custom-node packs preinstalled on Comfy Cloud. Detailers, ControlNet preprocessors, animation tools, samplers, and more — search by pack or by node name.',
'zh-CN':
'浏览 Comfy Cloud 预装的数百个 ComfyUI 自定义节点包细节修复、ControlNet 预处理、动画工具、采样器等——按节点包或节点名搜索。'
},
'cloudNodes.detail.metaTitle': {
en: '{pack} on Comfy Cloud',
'zh-CN': '{pack}Comfy Cloud'
},
'cloudNodes.detail.metaDescription': {
en: '{pack} is preinstalled on Comfy Cloud — {nodeCount} nodes ready to run on managed GPUs. {description}',
'zh-CN':
'{pack} 已预装于 Comfy Cloud——{nodeCount} 个节点可在托管 GPU 上即时运行。{description}'
},
'cloudNodes.empty.heading': {
en: 'No matching packs',
'zh-CN': '未找到匹配的节点包'
},
'cloudNodes.empty.body': {
en: 'Try a different search term or clear your filters.',
'zh-CN': '试试其他关键词,或清空筛选条件。'
},
'cloudNodes.card.nodeCountOne': {
en: '{count} node',
'zh-CN': '{count} 个节点'
},
'cloudNodes.card.nodeCountOther': {
en: '{count} nodes',
'zh-CN': '{count} 个节点'
},
'cloudNodes.card.viewRepo': {
en: 'View repository',
'zh-CN': '查看仓库'
},
'cloudNodes.card.unavailableDescription': {
en: 'Description unavailable.',
'zh-CN': '暂无描述信息。'
},
'cloudNodes.card.nodesHeading': {
en: 'Included nodes',
'zh-CN': '包含节点'
},
'cloudNodes.detail.back': {
en: 'Back to all packs',
'zh-CN': '返回所有节点包'
},
'cloudNodes.detail.publisher': {
en: 'Publisher',
'zh-CN': '发布者'
},
'cloudNodes.detail.downloads': {
en: 'Downloads',
'zh-CN': '下载量'
},
'cloudNodes.detail.stars': {
en: 'GitHub stars',
'zh-CN': 'GitHub 星标'
},
'cloudNodes.detail.latestVersion': {
en: 'Latest version',
'zh-CN': '最新版本'
},
'cloudNodes.detail.license': {
en: 'License',
'zh-CN': '许可证'
},
'cloudNodes.detail.lastUpdated': {
en: 'Last updated',
'zh-CN': '最后更新'
},
'cloudNodes.detail.deprecated': {
en: 'Deprecated',
'zh-CN': '已弃用'
},
'cloudNodes.detail.experimental': {
en: 'Experimental',
'zh-CN': '实验性'
},
'cloudNodes.detail.nodesHeading': {
en: 'Nodes in this pack',
'zh-CN': '此节点包中的节点'
},
// Cloud ReasonSection
'cloud.reason.heading': {
en: 'Why\nprofessionals\nchoose ',
@@ -3678,6 +3814,60 @@ const translations = {
'zh-CN': 'Creative Studios AI 负责人'
},
// Models UI keys
'models.hero.eyebrow': {
en: 'AI Model',
'zh-CN': 'AI 模型'
},
'models.hero.primaryCta': {
en: 'TRY IN COMFY',
'zh-CN': '在 Comfy 中试用'
},
'models.hero.secondaryCta': {
en: 'DOWNLOAD MODEL',
'zh-CN': '下载模型'
},
'models.hero.cloudCta': {
en: 'RUN ON CLOUD',
'zh-CN': '云端运行'
},
'models.hero.tutorialCta': {
en: 'VIEW TUTORIAL',
'zh-CN': '查看教程'
},
'models.hero.blogLink': {
en: 'Read blog post',
'zh-CN': '阅读博客文章'
},
'models.hero.workflowCount': {
en: '{count} workflows use this model',
'zh-CN': '{count} 个工作流使用此模型'
},
'models.whatIs.heading': {
en: 'What is {name}?',
'zh-CN': '什么是 {name}'
},
'models.whatIs.tutorialLink': {
en: 'Read the full tutorial →',
'zh-CN': '阅读完整教程 →'
},
'models.index.title': {
en: 'Supported Models',
'zh-CN': '支持的模型'
},
'models.index.subtitle': {
en: "Run the world's leading AI models in ComfyUI",
'zh-CN': '在 ComfyUI 中运行世界领先的 AI 模型'
},
'models.breadcrumb.home': {
en: 'Home',
'zh-CN': '首页'
},
'models.breadcrumb.models': {
en: 'Supported Models',
'zh-CN': '支持的模型'
},
// Payment status pages
'payment.success.label': {
en: 'PAYMENT',

View File

@@ -5,6 +5,7 @@ import '../styles/global.css'
import type { Locale } from '../i18n/translations'
import SiteFooter from '../components/common/SiteFooter.vue'
import SiteNav from '../components/common/SiteNav.vue'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
interface Props {
@@ -94,8 +95,11 @@ const websiteJsonLd = {
<meta name="twitter:image" content={ogImageURL.href} />
<!-- Structured Data -->
<script is:inline type="application/ld+json" set:html={JSON.stringify(organizationJsonLd)} />
<script is:inline type="application/ld+json" set:html={JSON.stringify(websiteJsonLd)} />
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
<slot name="head" />
<slot name="head" />
<!-- Google Tag Manager -->
{gtmEnabled && (

View File

@@ -0,0 +1,42 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/cloud-nodes/HeroSection.vue'
import PackGridSection from '../../components/cloud-nodes/PackGridSection.vue'
import { t } from '../../i18n/translations'
import { loadPacksForBuild } from '../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../utils/escapeJsonLd'
const packs = await loadPacksForBuild()
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL('/cloud/supported-nodes', siteBase).href
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Custom-node packs supported on Comfy Cloud',
url: pageUrl,
numberOfItems: packs.length,
itemListElement: packs.map((pack, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href,
name: pack.displayName,
image: pack.bannerUrl || pack.iconUrl
}))
}
---
<BaseLayout
title={t('cloudNodes.meta.title', 'en')}
description={t('cloudNodes.meta.description', 'en')}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(itemListJsonLd)}
/>
<HeroSection client:visible />
<PackGridSection packs={packs} client:visible />
</BaseLayout>

View File

@@ -0,0 +1,48 @@
# Cloud Nodes Pages
Build-time catalog of custom-node packs preinstalled on Comfy Cloud. Index at `/cloud/supported-nodes`, per-pack details at `/cloud/supported-nodes/[pack]`, both also under `/zh-CN/`.
## Sources
- **Cloud `/api/object_info`** — authoritative list of nodes available on Comfy Cloud (auth: `WEBSITE_CLOUD_API_KEY`)
- **ComfyUI Custom Node Registry** ([dashboard](https://registry.comfy.org), API at `https://api.comfy.org/nodes`) — public pack metadata (banner, icon, description, downloads, stars, license, version, repo, publisher)
The registry is the same one the in-app Manager dialog reads from. For reference and additional reading, see the existing client wrappers in `src/`:
- [`src/services/comfyRegistryService.ts`](../../../../../../src/services/comfyRegistryService.ts) — typed wrappers around `/nodes`, `/nodes/search`, `/nodes/{id}`, `/nodes/{id}/versions/{version}/comfy-nodes`, etc.
- [`src/stores/comfyRegistryStore.ts`](../../../../../../src/stores/comfyRegistryStore.ts) — cached store + `getPacksByIds` batch helper
- [`packages/registry-types/src/comfyRegistryTypes.ts`](../../../../../../packages/registry-types/src/comfyRegistryTypes.ts) — generated OpenAPI types
- Public docs: <https://docs.comfy.org/registry>
## Build pipeline
| File | Role |
| ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| [`pages/cloud/supported-nodes.astro`](./supported-nodes.astro) and [`[pack].astro`](./supported-nodes/%5Bpack%5D.astro) | Page shells (and `zh-CN` twins) |
| [`utils/cloudNodes.build.ts`](../../../utils/cloudNodes.build.ts) | `loadPacksForBuild()` shared by index + detail routes |
| [`utils/cloudNodes.ts`](../../../utils/cloudNodes.ts) | Cloud `object_info` fetcher with retry, sanitization, snapshot fallback |
| [`utils/cloudNodes.registry.ts`](../../../utils/cloudNodes.registry.ts) | Registry enrichment (batches of 50, soft-fail) |
| [`utils/cloudNodes.ci.ts`](../../../utils/cloudNodes.ci.ts) | GitHub Actions annotations + step summary |
| [`utils/escapeJsonLd.ts`](../../../utils/escapeJsonLd.ts) | XSS-safe `<script type="application/ld+json">` serializer |
| [`composables/useFilteredPacks.ts`](../../../composables/useFilteredPacks.ts) | Search + sort logic for the index page |
| [`composables/useNodesByCategory.ts`](../../../composables/useNodesByCategory.ts) | Group nodes by category, alphabetized |
| [`scripts/refresh-cloud-nodes-snapshot.ts`](../../../../scripts/refresh-cloud-nodes-snapshot.ts) | Manual `pnpm cloud-nodes:refresh-snapshot` |
| [`packages/object-info-parser`](../../../../../../packages/object-info-parser) | Shared Zod schemas, classifier, `sanitizeUserContent` |
## Key invariants
- **Sanitization is mandatory.** The cloud `/api/object_info` endpoint mixes the calling user's uploaded files into combo input lists; `sanitizeUserContent()` strips them before the snapshot is written. Tests in [`packages/object-info-parser/src/__tests__/sanitizeUserContent.test.ts`](../../../../../../packages/object-info-parser/src/__tests__/sanitizeUserContent.test.ts).
- **All remote data is Zod-validated.** `cloudNodes.schema.ts` validates the cloud envelope; `@comfyorg/object-info-parser` validates each node def; `cloudNodes.registry.ts` validates the registry response shape with a passthrough Zod schema (defense-in-depth on top of the generated OpenAPI types). The fetcher never trusts a network response.
- **JSON-LD must be escaped.** Pack metadata is registry-controlled; route every `<script type="application/ld+json">` payload through `escapeJsonLd()`.
- **Index and detail share one fetch.** Both call `loadPacksForBuild()` so the static routes can never diverge from the rendered list.
- **No `PUBLIC_` prefix on env vars.** Astro inlines `PUBLIC_*` into the client bundle; build-time secrets never start with that prefix.
## Why `Pack` is a domain projection, not the raw registry `Node`
`apps/website/src/data/cloudNodes.ts` defines its own `Pack` and `PackNode` types instead of re-exporting `components['schemas']['Node']` from `@comfyorg/registry-types`. That is intentional:
- A `Pack` joins **two upstream sources** — the cloud `object_info` shape (which has `python_module`, `category`, `display_name`, etc.) and the registry `Node` shape (which has `banner_url`, `downloads`, `github_stars`, etc.). Neither shape on its own captures both.
- A `Pack` is **post-sanitization**. Combo input lists, user-uploaded filenames, and any other build-time-stripped data are guaranteed gone by the time the type appears. The raw `Node` carries fields we never expose.
- A `Pack` adds **safe-URL invariants** (banner / icon / repo all flow through `safeExternalUrl()` before becoming part of the type), which the registry's optional `string` fields don't encode.
The `Pack`/`PackNode` types live next to the snapshot they describe; the validated build-time projections feed Astro's static routes directly. The OpenAPI-generated `Node` type is still consumed inside `cloudNodes.registry.ts` as `RegistryPack` for the upstream fetch.

View File

@@ -0,0 +1,63 @@
---
import type { GetStaticPaths } from 'astro'
import type { Pack } from '../../../data/cloudNodes'
import PackDetail from '../../../components/cloud-nodes/PackDetail.vue'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { t } from '../../../i18n/translations'
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
return packs.map((pack) => ({
params: { pack: pack.id },
props: { pack }
}))
}
const { pack } = Astro.props as { pack: Pack }
const fallbackDescription = t('cloudNodes.card.unavailableDescription', 'en')
const description = pack.description || fallbackDescription
const title = t('cloudNodes.detail.metaTitle', 'en').replace(
'{pack}',
pack.displayName
)
const metaDescription = t('cloudNodes.detail.metaDescription', 'en')
.replace('{pack}', pack.displayName)
.replace('{nodeCount}', String(pack.nodes.length))
.replace('{description}', description)
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href
const softwareJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: pack.displayName,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
url: pageUrl,
description,
image: pack.bannerUrl || pack.iconUrl,
softwareVersion: pack.latestVersion,
license: pack.license,
codeRepository: pack.repoUrl,
author: pack.publisher?.name
? { '@type': 'Person', name: pack.publisher.name }
: undefined,
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
}
---
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<PackDetail pack={pack} />
</BaseLayout>

View File

@@ -0,0 +1,185 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
import { models, getModelBySlug } from '../../../config/models'
import { t } from '../../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
return models.map((model) => ({
params: { slug: model.slug }
}))
}
const { slug } = Astro.params
const model = getModelBySlug(slug as string)!
if (model.canonicalSlug) {
return Astro.redirect(`/p/supported-models/${model.canonicalSlug}`, 301)
}
const { displayName } = model
const canonicalURL = new URL(`/p/supported-models/${model.slug}`, Astro.site)
const dirDescriptions: Record<string, string> = {
diffusion_models: 'a diffusion model that generates images or video from text and image prompts',
checkpoints: 'an all-in-one checkpoint model that bundles a diffusion model, text encoder, and VAE',
loras: 'a LoRA (Low-Rank Adaptation) that fine-tunes an existing model for a specific style or subject',
controlnet: 'a ControlNet that steers image generation using structural guides like depth maps, edges, or poses',
clip_vision: 'a CLIP Vision encoder that converts images into embeddings for conditioning or style transfer',
vae: 'a VAE (Variational Autoencoder) that encodes and decodes latent representations',
text_encoders: 'a text encoder that converts prompts into embeddings used to guide generation',
audio_encoders: 'an audio encoder that converts audio into embeddings for audio-conditioned generation',
upscale_models: 'an upscale model that increases image resolution while preserving or enhancing detail',
latent_upscale_models: 'a latent upscale model that refines latents at higher resolution before decoding',
style_models: 'a style model that transfers artistic style onto generated images',
model_patches: 'a model patch that modifies or extends the behavior of an existing base model',
partner_nodes: 'a cloud API model accessible through ComfyUI partner nodes without local hardware requirements'
}
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
const softwareAppJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: displayName,
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Any',
url: canonicalURL.href,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('models.breadcrumb.home'),
item: 'https://comfy.org'
},
{
'@type': 'ListItem',
position: 2,
name: t('models.breadcrumb.models'),
item: 'https://comfy.org/p/supported-models'
},
{
'@type': 'ListItem',
position: 3,
name: displayName
}
]
}
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: [
{
'@type': 'Question',
name: `What is ${displayName}?`,
acceptedAnswer: {
'@type': 'Answer',
text: whatIsDescription
}
},
{
'@type': 'Question',
name: `How do I use ${displayName} in ComfyUI?`,
acceptedAnswer: {
'@type': 'Answer',
text: model.docsUrl
? `Follow the step-by-step tutorial at ${model.docsUrl}. You can also load any of the ${model.workflowCount} community workflow templates that use ${displayName} directly in ComfyUI.`
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`
}
},
{
'@type': 'Question',
name: `How many ComfyUI workflows use ${displayName}?`,
acceptedAnswer: {
'@type': 'Answer',
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`
}
},
{
'@type': 'Question',
name: `Is ${displayName} free to use in ComfyUI?`,
acceptedAnswer: {
'@type': 'Answer',
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`
}
}
]
}
const pageTitle = `${displayName} in ComfyUI`
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
---
<BaseLayout
title={`${pageTitle} — Comfy`}
description={pageDescription}
ogImage={model.thumbnailUrl}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(softwareAppJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(faqJsonLd)}
/>
</Fragment>
<ModelHeroSection
displayName={displayName}
huggingFaceUrl={model.huggingFaceUrl}
docsUrl={model.docsUrl}
blogUrl={model.blogUrl}
hubSlug={model.hubSlug}
workflowCount={model.workflowCount}
directory={model.directory}
/>
<section class="mx-auto max-w-7xl px-6 py-16 lg:px-8">
<h2 class="text-2xl font-bold text-primary-comfy-canvas lg:text-3xl">
{t('models.whatIs.heading').replace('{name}', displayName)}
</h2>
<p class="mt-4 max-w-3xl text-base/relaxed text-primary-comfy-canvas/70 lg:text-lg/relaxed">
{whatIsDescription}
</p>
{model.docsUrl && (
<a
href={model.docsUrl}
target="_blank"
rel="noopener noreferrer"
class="mt-6 inline-block text-primary-comfy-yellow hover:underline"
>
{t('models.whatIs.tutorialLink')}
</a>
)}
</section>
<section>
{/* TODO: workflow gallery — pulls from hub API */}
</section>
<section>
{/* TODO: shared HowComfyWorks component */}
</section>
</BaseLayout>

View File

@@ -0,0 +1,68 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { models } from '../../../config/models'
import { t } from '../../../i18n/translations'
const title = t('models.index.title')
const subtitle = t('models.index.subtitle')
const dirLabel: Record<string, string> = {
diffusion_models: 'Diffusion',
checkpoints: 'Checkpoint',
loras: 'LoRA',
controlnet: 'ControlNet',
clip_vision: 'CLIP Vision',
model_patches: 'Patch',
vae: 'VAE',
text_encoders: 'Text Encoder',
audio_encoders: 'Audio Encoder',
latent_upscale_models: 'Latent Upscale',
upscale_models: 'Upscale',
style_models: 'Style',
partner_nodes: 'Partner Node'
}
---
<BaseLayout
title={`${title} — Comfy`}
description={subtitle}
>
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-24">
<header class="mb-12">
<p
class="mb-2 text-sm font-medium uppercase tracking-widest text-primary-comfy-yellow"
>
{t('models.hero.eyebrow')}
</p>
<h1 class="text-4xl font-bold text-primary-comfy-canvas lg:text-6xl">
{title}
</h1>
<p class="mt-4 max-w-2xl text-lg text-primary-comfy-canvas/70">
{subtitle}
</p>
</header>
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{models.map((model) => (
<li>
<a
href={`/p/supported-models/${model.slug}`}
class="flex h-full flex-col gap-3 rounded-xl border border-white/10 p-5 transition-colors hover:border-white/25 hover:bg-white/5"
>
<div class="flex items-start justify-between gap-2">
<span class="text-base font-semibold leading-tight text-primary-comfy-canvas">
{model.displayName}
</span>
<span class="shrink-0 rounded-full bg-white/10 px-2 py-0.5 text-xs font-medium text-primary-comfy-canvas/70">
{dirLabel[model.directory] ?? model.directory}
</span>
</div>
<p class="mt-auto text-xs text-primary-comfy-canvas/50">
{model.workflowCount} workflows
</p>
</a>
</li>
))}
</ul>
</div>
</BaseLayout>

View File

@@ -0,0 +1,42 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro'
import HeroSection from '../../../components/cloud-nodes/HeroSection.vue'
import PackGridSection from '../../../components/cloud-nodes/PackGridSection.vue'
import { t } from '../../../i18n/translations'
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
const packs = await loadPacksForBuild()
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL('/zh-CN/cloud/supported-nodes', siteBase).href
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Comfy Cloud 支持的自定义节点包',
url: pageUrl,
numberOfItems: packs.length,
itemListElement: packs.map((pack, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href,
name: pack.displayName,
image: pack.bannerUrl || pack.iconUrl
}))
}
---
<BaseLayout
title={t('cloudNodes.meta.title', 'zh-CN')}
description={t('cloudNodes.meta.description', 'zh-CN')}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(itemListJsonLd)}
/>
<HeroSection locale="zh-CN" client:visible />
<PackGridSection locale="zh-CN" packs={packs} client:visible />
</BaseLayout>

View File

@@ -0,0 +1,63 @@
---
import type { GetStaticPaths } from 'astro'
import type { Pack } from '../../../../data/cloudNodes'
import PackDetail from '../../../../components/cloud-nodes/PackDetail.vue'
import BaseLayout from '../../../../layouts/BaseLayout.astro'
import { t } from '../../../../i18n/translations'
import { loadPacksForBuild } from '../../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../../utils/escapeJsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
return packs.map((pack) => ({
params: { pack: pack.id },
props: { pack }
}))
}
const { pack } = Astro.props as { pack: Pack }
const fallbackDescription = t('cloudNodes.card.unavailableDescription', 'zh-CN')
const description = pack.description || fallbackDescription
const title = t('cloudNodes.detail.metaTitle', 'zh-CN').replace(
'{pack}',
pack.displayName
)
const metaDescription = t('cloudNodes.detail.metaDescription', 'zh-CN')
.replace('{pack}', pack.displayName)
.replace('{nodeCount}', String(pack.nodes.length))
.replace('{description}', description)
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href
const softwareJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: pack.displayName,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
url: pageUrl,
description,
image: pack.bannerUrl || pack.iconUrl,
softwareVersion: pack.latestVersion,
license: pack.license,
codeRepository: pack.repoUrl,
author: pack.publisher?.name
? { '@type': 'Person', name: pack.publisher.name }
: undefined,
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
}
---
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<PackDetail pack={pack} locale="zh-CN" />
</BaseLayout>

View File

@@ -22,7 +22,7 @@ function baseSnapshot(): RolesSnapshot {
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
jobUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
}
]
}
@@ -50,7 +50,7 @@ function freshOutcome(droppedCount = 0): FetchOutcome {
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
jobUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
}
]
}

View File

@@ -40,7 +40,7 @@ function makeSnapshot(roleCount = 2): RolesSnapshot {
title: `Snapshot Role ${i}`,
department: 'Engineering',
location: 'San Francisco',
applyUrl: `https://jobs.ashbyhq.com/comfy-org/snapshot-${i}`
jobUrl: `https://jobs.ashbyhq.com/comfy-org/snapshot-${i}`
}))
return {
fetchedAt: '2026-04-01T00:00:00.000Z',
@@ -85,26 +85,7 @@ describe('fetchRolesForBuild', () => {
if (outcome.status !== 'fresh') return
expect(outcome.droppedCount).toBe(0)
expect(outcome.snapshot.departments).toHaveLength(1)
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toMatch(
/design-engineer\/apply$/
)
})
it('falls back to jobUrl when applyUrl is missing and keeps the role', async () => {
const job = validJob()
delete (job as Record<string, unknown>).applyUrl
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [job] })
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toBe(
expect(outcome.snapshot.departments[0]!.roles[0]!.jobUrl).toBe(
'https://jobs.ashbyhq.com/comfy-org/design-engineer'
)
})

View File

@@ -243,13 +243,13 @@ function groupByDepartment(jobs: readonly AshbyJobPosting[]): Department[] {
}
function toDomainRole(job: AshbyJobPosting, department: string): Role {
const applyUrl = job.applyUrl ?? job.jobUrl
const jobUrl = job.jobUrl
return {
id: createHash('sha1').update(applyUrl).digest('hex').slice(0, 16),
id: createHash('sha1').update(jobUrl).digest('hex').slice(0, 16),
title: job.title,
department: capitalize(department),
location: (job.location ?? '').trim() || DEFAULT_LOCATION,
applyUrl
jobUrl
}
}

View File

@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FetchOutcome } from './cloudNodes'
import type { NodesSnapshot } from '../data/cloudNodes'
const fetchCloudNodesMock = vi.hoisted(() =>
vi.fn<() => Promise<FetchOutcome>>()
)
const reportCloudNodesOutcomeMock = vi.hoisted(() => vi.fn())
vi.mock('./cloudNodes', () => ({
fetchCloudNodesForBuild: fetchCloudNodesMock
}))
vi.mock('./cloudNodes.ci', () => ({
reportCloudNodesOutcome: reportCloudNodesOutcomeMock
}))
import { loadPacksForBuild } from './cloudNodes.build'
const SNAPSHOT: NodesSnapshot = {
fetchedAt: '2026-04-01T00:00:00.000Z',
packs: [
{
id: 'snapshot-pack',
displayName: 'Snapshot Pack',
nodes: [
{ name: 'SnapshotNode', displayName: 'Snapshot Node', category: 'x' }
]
}
]
}
describe('loadPacksForBuild', () => {
const savedVercelEnv = process.env.VERCEL_ENV
beforeEach(() => {
fetchCloudNodesMock.mockReset()
reportCloudNodesOutcomeMock.mockReset()
delete process.env.VERCEL_ENV
})
afterEach(() => {
if (savedVercelEnv === undefined) {
delete process.env.VERCEL_ENV
return
}
process.env.VERCEL_ENV = savedVercelEnv
})
it('returns packs when fetch is fresh', async () => {
fetchCloudNodesMock.mockResolvedValue({
status: 'fresh',
snapshot: SNAPSHOT,
droppedCount: 0,
droppedNodes: []
})
const packs = await loadPacksForBuild()
expect(packs).toBe(SNAPSHOT.packs)
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
})
it('returns snapshot packs when outcome is stale outside production', async () => {
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'missing WEBSITE_CLOUD_API_KEY'
})
const packs = await loadPacksForBuild()
expect(packs).toBe(SNAPSHOT.packs)
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
})
it('returns snapshot packs when outcome is stale on Vercel preview', async () => {
process.env.VERCEL_ENV = 'preview'
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'HTTP 503'
})
const packs = await loadPacksForBuild()
expect(packs).toBe(SNAPSHOT.packs)
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
})
it('throws when outcome is stale on Vercel production', async () => {
process.env.VERCEL_ENV = 'production'
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'missing WEBSITE_CLOUD_API_KEY'
})
await expect(loadPacksForBuild()).rejects.toThrow(
/stale data in a production build/
)
await expect(loadPacksForBuild()).rejects.toThrow(
/missing WEBSITE_CLOUD_API_KEY/
)
})
it('throws when outcome is failed regardless of environment', async () => {
fetchCloudNodesMock.mockResolvedValue({
status: 'failed',
reason: 'network error: ECONNREFUSED'
})
await expect(loadPacksForBuild()).rejects.toThrow(
/Cloud nodes fetch failed and no snapshot is available/
)
await expect(loadPacksForBuild()).rejects.toThrow(/ECONNREFUSED/)
})
it('still reports outcome before throwing on stale-in-production', async () => {
process.env.VERCEL_ENV = 'production'
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'HTTP 503'
})
await expect(loadPacksForBuild()).rejects.toThrow()
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,45 @@
import type { Pack } from '../data/cloudNodes'
import { fetchCloudNodesForBuild } from './cloudNodes'
import { reportCloudNodesOutcome } from './cloudNodes.ci'
const REFRESH_HINT =
'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot, ' +
'or re-run the `Release: Website` workflow with a valid WEBSITE_CLOUD_API_KEY.'
function isProductionBuild(): boolean {
return process.env.VERCEL_ENV === 'production'
}
/**
* Resolve the list of packs to render at build time.
*
* Used by both the index page and the per-pack detail pages so that the
* static index and the static detail routes are always derived from the
* same source. `fetchCloudNodesForBuild` is memoized on a module-level
* `inflight` promise, so repeated calls in the same build process share a
* single network round-trip and the same outcome.
*
* Production builds (VERCEL_ENV=production) fail hard on a stale outcome
* to prevent silently shipping out-of-date snapshot data. Preview and
* local builds continue to use the committed snapshot.
*/
export async function loadPacksForBuild(): Promise<Pack[]> {
const outcome = await fetchCloudNodesForBuild()
reportCloudNodesOutcome(outcome)
if (outcome.status === 'failed') {
throw new Error(
`Cloud nodes fetch failed and no snapshot is available. Reason: ${outcome.reason}. ${REFRESH_HINT}`
)
}
if (outcome.status === 'stale' && isProductionBuild()) {
throw new Error(
`Cloud nodes fetch returned stale data in a production build (VERCEL_ENV=production). ` +
`Reason: ${outcome.reason}. ${REFRESH_HINT}`
)
}
return outcome.snapshot.packs
}

View File

@@ -0,0 +1,121 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FetchOutcome } from './cloudNodes'
import type { NodesSnapshot } from '../data/cloudNodes'
import {
reportCloudNodesOutcome,
resetCloudNodesReporterForTests
} from './cloudNodes.ci'
function baseSnapshot(): NodesSnapshot {
return {
fetchedAt: new Date().toISOString(),
packs: [
{
id: 'pack-id',
displayName: 'Pack',
nodes: [
{
name: 'NodeClass',
displayName: 'Node Class',
category: 'misc'
}
]
}
]
}
}
function freshOutcome(droppedCount = 0): FetchOutcome {
return {
status: 'fresh',
droppedCount,
droppedNodes:
droppedCount === 0
? []
: [{ name: 'BadNode', reason: 'invalid display_name' }],
snapshot: baseSnapshot()
}
}
describe('reportCloudNodesOutcome', () => {
let writeSpy: ReturnType<typeof vi.spyOn>
let summaryDir: string
let summaryPath: string
const originalSummary = process.env.GITHUB_STEP_SUMMARY
beforeEach(() => {
resetCloudNodesReporterForTests()
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
summaryDir = mkdtempSync(join(tmpdir(), 'cloud-nodes-summary-'))
summaryPath = join(summaryDir, 'summary.md')
writeFileSync(summaryPath, '')
process.env.GITHUB_STEP_SUMMARY = summaryPath
})
afterEach(() => {
writeSpy.mockRestore()
rmSync(summaryDir, { recursive: true, force: true })
if (originalSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY
else process.env.GITHUB_STEP_SUMMARY = originalSummary
})
it('emits nothing on a clean fresh outcome', () => {
reportCloudNodesOutcome(freshOutcome(0))
expect(writeSpy).not.toHaveBeenCalled()
expect(readFileSync(summaryPath, 'utf8')).toContain('Fresh')
})
it('emits exactly one set of annotations across repeated calls', () => {
reportCloudNodesOutcome(freshOutcome(1))
reportCloudNodesOutcome(freshOutcome(1))
expect(writeSpy).toHaveBeenCalledTimes(1)
const annotation = writeSpy.mock.calls[0]?.[0] as string
expect(annotation).toContain(
'::warning title=Cloud nodes: dropped 1 invalid'
)
expect(readFileSync(summaryPath, 'utf8')).toContain('Dropped')
})
it('emits ::error for auth failures in a stale outcome', () => {
reportCloudNodesOutcome({
status: 'stale',
reason: 'HTTP 401 Unauthorized',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]?.[0] as string
expect(annotation).toContain(
'::error title=Cloud nodes authentication failed'
)
})
it('emits ::warning for missing-env stale outcomes', () => {
reportCloudNodesOutcome({
status: 'stale',
reason: 'missing WEBSITE_CLOUD_API_KEY',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]?.[0] as string
expect(annotation).toContain('::warning title=Cloud nodes integration')
})
it('emits ::error for a failed outcome and writes no fresh-only sections', () => {
reportCloudNodesOutcome({
status: 'failed',
reason: 'HTTP 500 Server Error'
})
const annotation = writeSpy.mock.calls[0]?.[0] as string
expect(annotation).toContain('::error title=Cloud nodes fetch failed')
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
})
it('does not throw when GITHUB_STEP_SUMMARY is not set', () => {
delete process.env.GITHUB_STEP_SUMMARY
expect(() => reportCloudNodesOutcome(freshOutcome(0))).not.toThrow()
})
})

View File

@@ -0,0 +1,115 @@
import { appendFileSync } from 'node:fs'
import type { FetchOutcome } from './cloudNodes'
let hasReported = false
export function resetCloudNodesReporterForTests(): void {
hasReported = false
}
export function reportCloudNodesOutcome(outcome: FetchOutcome): void {
if (hasReported) return
hasReported = true
const lines = buildAnnotations(outcome)
for (const line of lines) {
process.stdout.write(`${line}\n`)
}
const summaryPath = process.env.GITHUB_STEP_SUMMARY
if (summaryPath) {
try {
appendFileSync(summaryPath, buildStepSummary(outcome))
} catch {
// Writing the summary is best-effort; do not fail the build if the
// runner's summary file is unavailable (e.g. local dev).
}
}
}
function buildAnnotations(outcome: FetchOutcome): string[] {
if (outcome.status === 'fresh') {
if (outcome.droppedCount === 0) return []
const nodeCount = outcome.droppedCount === 1 ? 'node' : 'nodes'
const drops = outcome.droppedNodes
.map((d) => {
const name = escapeAnnotation(d.name ? `"${d.name}"` : '(unnamed)')
const reason = escapeAnnotation(d.reason)
return ` - ${name}: ${reason}`
})
.join('%0A')
return [
`::warning title=Cloud nodes: dropped ${outcome.droppedCount} invalid ${nodeCount}::Dropped nodes:%0A${drops}%0A%0AAction items:%0A 1. Verify node definitions returned by cloud /api/object_info.%0A 2. If a valid node shape changed, update @comfyorg/object-info-parser/src/schemas/nodeDefSchema.ts and add tests.%0A 3. Dropped nodes are not shown on /cloud/supported-nodes until fixed.`
]
}
if (outcome.status === 'stale') {
return [staleAnnotation(outcome.reason)]
}
return [
`::error title=Cloud nodes fetch failed and no snapshot is available::Cannot build cloud nodes page without data.%0A%0AReason: ${escapeAnnotation(outcome.reason)}%0A%0AAction items:%0A 1. Run \`pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot\` locally with a valid WEBSITE_CLOUD_API_KEY.%0A 2. Commit apps/website/src/data/cloud-nodes.snapshot.json.%0A 3. Push and re-run CI.`
]
}
function staleAnnotation(reason: string): string {
const escaped = escapeAnnotation(reason)
if (reason.startsWith('missing ')) {
return `::warning title=Cloud nodes integration::${escaped}. Falling back to committed snapshot.%0A%0AAction items:%0A 1. If you're a contributor without key access, this is expected. The snapshot will be used.%0A 2. If this is CI, check that the \`WEBSITE_CLOUD_API_KEY\` secret exists in the repo and is referenced in .github/workflows/ci-website-build.yaml.`
}
if (reason.startsWith('HTTP 401') || reason.startsWith('HTTP 403')) {
return `::error title=Cloud nodes authentication failed::${escaped}. The WEBSITE_CLOUD_API_KEY is missing, invalid, or revoked. Build continues with the last-known-good snapshot.%0A%0AAction items:%0A 1. Verify the Cloud API key is active and scoped for /api/object_info.%0A 2. Update the \`WEBSITE_CLOUD_API_KEY\` secret in GitHub Actions and Vercel.%0A 3. Re-run this workflow.`
}
if (reason.startsWith('envelope')) {
return `::error title=Cloud nodes schema mismatch::${escaped}. The Cloud API contract likely changed. Build continues with the snapshot, but future updates will fail until schema is fixed.%0A%0AAction items:%0A 1. Check cloud/services/ingest/openapi.yaml for /api/object_info changes.%0A 2. Update apps/website/src/utils/cloudNodes.schema.ts and @comfyorg/object-info-parser schema as needed.`
}
return `::warning title=Cloud nodes API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check cloud service health.%0A 2. Re-run this workflow once cloud.comfy.org is healthy.`
}
function escapeAnnotation(value: string): string {
return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
}
function buildStepSummary(outcome: FetchOutcome): string {
const header = '## ☁️ Cloud nodes\n'
const rows: Array<[string, string]> = []
if (outcome.status === 'fresh') {
rows.push(['Status', '✅ Fresh (fetched from Cloud API)'])
rows.push(['Packs', String(outcome.snapshot.packs.length)])
rows.push([
'Nodes',
String(outcome.snapshot.packs.reduce((n, p) => n + p.nodes.length, 0))
])
rows.push(['Dropped', String(outcome.droppedCount)])
} else if (outcome.status === 'stale') {
rows.push(['Status', '⚠️ Stale (using snapshot — Cloud fetch failed)'])
rows.push(['Packs', String(outcome.snapshot.packs.length)])
rows.push([
'Nodes',
String(outcome.snapshot.packs.reduce((n, p) => n + p.nodes.length, 0))
])
rows.push(['Reason', outcome.reason])
rows.push(['Snapshot age', describeSnapshotAge(outcome.snapshot.fetchedAt)])
} else {
rows.push(['Status', '❌ Failed (no snapshot available)'])
rows.push(['Reason', outcome.reason])
}
const table =
'| | |\n|---|---|\n' +
rows.map(([k, v]) => `| **${k}** | ${v} |`).join('\n') +
'\n'
return `${header}${table}\n`
}
function describeSnapshotAge(fetchedAt: string): string {
const fetched = new Date(fetchedAt).getTime()
if (Number.isNaN(fetched)) return 'unknown'
const days = Math.floor((Date.now() - fetched) / 86_400_000)
if (days <= 0) return 'today'
if (days === 1) return '1 day'
return `${days} days`
}

View File

@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest'
import {
DEFAULT_REGISTRY_BASE_URL,
fetchRegistryPacks
} from './cloudNodes.registry'
function jsonResponse(
body: unknown,
init: Partial<ResponseInit> = {}
): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
...init
})
}
describe('fetchRegistryPacks', () => {
it('requests node ids in batches of 50', async () => {
const ids = Array.from({ length: 120 }, (_, i) => `pack-${i}`)
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
const batchIds = url.searchParams.getAll('node_id')
return jsonResponse({
nodes: batchIds.map((id) => ({ id, name: id })),
total: batchIds.length,
page: 1,
limit: 50
})
})
const result = await fetchRegistryPacks(ids, {
fetchImpl: fetchImpl as typeof fetch
})
expect(fetchImpl).toHaveBeenCalledTimes(3)
expect(result.size).toBe(120)
const firstCallUrl = new URL(String(fetchImpl.mock.calls[0]?.[0]))
expect(firstCallUrl.origin).toBe(DEFAULT_REGISTRY_BASE_URL)
expect(firstCallUrl.pathname).toBe('/nodes')
expect(firstCallUrl.searchParams.getAll('node_id')).toHaveLength(50)
})
it('retries a failed batch once and then succeeds', async () => {
const fetchImpl = vi
.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>()
.mockResolvedValueOnce(new Response('{}', { status: 503 }))
.mockResolvedValueOnce(
jsonResponse({
nodes: [{ id: 'pack-1', name: 'Pack One' }],
total: 1,
page: 1,
limit: 50
})
)
const result = await fetchRegistryPacks(['pack-1'], {
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(fetchImpl).toHaveBeenCalledTimes(2)
expect(result.get('pack-1')?.name).toBe('Pack One')
})
it('returns an empty map when all batches fail (soft failure)', async () => {
const ids = ['pack-a', 'pack-b']
const fetchImpl = vi.fn(async () => new Response('{}', { status: 500 }))
const result = await fetchRegistryPacks(ids, {
fetchImpl: fetchImpl as typeof fetch
})
expect(fetchImpl).toHaveBeenCalledTimes(2)
expect(result.size).toBe(0)
})
})

View File

@@ -0,0 +1,197 @@
import { z } from 'zod'
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
export type RegistryPack = components['schemas']['Node']
const RegistryPackSchema = z
.object({
id: z.string().optional(),
name: z.string().optional(),
description: z.string().optional(),
icon: z.string().optional(),
banner_url: z.string().optional(),
repository: z.string().optional(),
license: z.string().optional(),
downloads: z.number().optional(),
github_stars: z.number().optional(),
created_at: z.string().optional(),
supported_os: z.array(z.string()).optional(),
supported_accelerators: z.array(z.string()).optional(),
publisher: z
.object({
id: z.string().optional(),
name: z.string().optional()
})
.passthrough()
.optional(),
latest_version: z
.object({
version: z.string().optional(),
createdAt: z.string().optional()
})
.passthrough()
.optional()
})
.passthrough()
const RegistryListResponseSchema = z
.object({
nodes: z.array(RegistryPackSchema)
})
.passthrough()
interface FetchRegistryOptions {
baseUrl?: string
timeoutMs?: number
fetchImpl?: typeof fetch
}
export async function fetchRegistryPacks(
packIds: readonly string[],
options: FetchRegistryOptions = {}
): Promise<Map<string, RegistryPack | null>> {
const uniquePackIds = [...new Set(packIds.filter((id) => id.length > 0))]
if (uniquePackIds.length === 0) {
return new Map()
}
const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_BASE_URL
const timeoutMs = clampTimeoutMs(options.timeoutMs)
const fetchImpl = options.fetchImpl ?? fetch
const batches = chunk(uniquePackIds, BATCH_SIZE)
const resolved = new Map<string, RegistryPack | null>()
let successCount = 0
let failureCount = 0
for (const batch of batches) {
const nodes = await fetchBatchWithRetry(
fetchImpl,
baseUrl,
batch,
timeoutMs
)
if (!nodes) {
failureCount += 1
for (const packId of batch) {
resolved.set(packId, null)
}
continue
}
successCount += 1
const nodesById = new Map(
nodes
.map((node) => [node.id, node] as const)
.filter(([id]) => typeof id === 'string' && id.length > 0)
)
for (const packId of batch) {
resolved.set(packId, nodesById.get(packId) ?? null)
}
}
if (failureCount > 0) {
console.warn(
`[cloud-nodes] registry enrichment: ${successCount}/${batches.length} batches succeeded, ${failureCount} failed`
)
}
if (successCount === 0) {
return new Map()
}
return resolved
}
async function fetchBatchWithRetry(
fetchImpl: typeof fetch,
baseUrl: string,
packIds: readonly string[],
timeoutMs: number
): Promise<RegistryPack[] | null> {
const firstAttempt = await fetchBatch(fetchImpl, baseUrl, packIds, timeoutMs)
if (firstAttempt.kind === 'ok') {
return firstAttempt.nodes
}
if (!firstAttempt.retryable) {
return null
}
const secondAttempt = await fetchBatch(fetchImpl, baseUrl, packIds, timeoutMs)
if (secondAttempt.kind === 'ok') {
return secondAttempt.nodes
}
return null
}
type BatchResponse =
| { kind: 'ok'; nodes: RegistryPack[] }
| { kind: 'err'; retryable: boolean }
async function fetchBatch(
fetchImpl: typeof fetch,
baseUrl: string,
packIds: readonly string[],
timeoutMs: number
): Promise<BatchResponse> {
const params = new URLSearchParams()
for (const packId of packIds) {
params.append('node_id', packId)
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetchImpl(`${baseUrl}/nodes?${params.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json'
},
signal: controller.signal
})
if (!res.ok) {
return {
kind: 'err',
retryable: res.status === 429 || (res.status >= 500 && res.status < 600)
}
}
const rawBody: unknown = await res.json()
const parsed = RegistryListResponseSchema.safeParse(rawBody)
if (!parsed.success) {
return { kind: 'err', retryable: false }
}
return { kind: 'ok', nodes: parsed.data.nodes as RegistryPack[] }
} catch {
return { kind: 'err', retryable: true }
} finally {
clearTimeout(timer)
}
}
function chunk<T>(values: readonly T[], size: number): T[][] {
const chunks: T[][] = []
for (let i = 0; i < values.length; i += size) {
chunks.push(values.slice(i, i + size))
}
return chunks
}
function clampTimeoutMs(candidate: number | undefined): number {
if (
typeof candidate !== 'number' ||
!Number.isFinite(candidate) ||
candidate <= 0
) {
return DEFAULT_TIMEOUT_MS
}
return Math.floor(candidate)
}

View File

@@ -0,0 +1,3 @@
import { z } from 'zod'
export const CloudNodesEnvelopeSchema = z.record(z.unknown())

View File

@@ -0,0 +1,309 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
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()))
const sanitizeCallSpy = vi.hoisted(() => vi.fn())
vi.mock('./cloudNodes.registry', () => ({
DEFAULT_REGISTRY_BASE_URL: 'https://api.comfy.org',
fetchRegistryPacks: fetchRegistryPacksMock
}))
vi.mock('@comfyorg/object-info-parser', async (importOriginal) => {
const actual = (await importOriginal()) as typeof ObjectInfoParser
return {
...actual,
sanitizeUserContent: (
defs: Parameters<typeof actual.sanitizeUserContent>[0]
) => {
sanitizeCallSpy(defs)
return actual.sanitizeUserContent(defs)
}
}
})
import {
fetchCloudNodesForBuild,
resetCloudNodesFetcherForTests
} from './cloudNodes'
const BASE_URL = 'https://cloud.test'
const KEY = 'cloud-secret'
function validNode(
overrides: Partial<Record<string, unknown>> = {}
): Record<string, unknown> {
return {
name: 'ImpactNode',
display_name: 'Impact Node',
description: 'Node description',
category: 'impact/testing',
output_node: false,
python_module: 'custom_nodes.comfyui-impact-pack.nodes',
...overrides
}
}
function response(body: unknown, init: Partial<ResponseInit> = {}): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
...init
})
}
function makeSnapshot(packCount = 1): NodesSnapshot {
const packs = Array.from({ length: packCount }, (_, i) => ({
id: `snapshot-pack-${i}`,
displayName: `Snapshot Pack ${i}`,
nodes: [
{
name: `SnapshotNode${i}`,
displayName: `Snapshot Node ${i}`,
category: 'snapshot'
}
]
}))
return {
fetchedAt: '2026-04-01T00:00:00.000Z',
packs
}
}
function withSnapshotDir(snapshot: NodesSnapshot | null): URL {
const dir = mkdtempSync(join(tmpdir(), 'cloud-nodes-test-'))
const file = join(dir, 'cloud-nodes.snapshot.json')
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
return pathToFileURL(file)
}
describe('fetchCloudNodesForBuild', () => {
const savedCloudApiKey = process.env.WEBSITE_CLOUD_API_KEY
beforeEach(() => {
resetCloudNodesFetcherForTests()
fetchRegistryPacksMock.mockReset()
fetchRegistryPacksMock.mockResolvedValue(new Map())
sanitizeCallSpy.mockReset()
delete process.env.WEBSITE_CLOUD_API_KEY
})
afterEach(() => {
vi.restoreAllMocks()
process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey
})
it('returns fresh when API succeeds', async () => {
fetchRegistryPacksMock.mockResolvedValue(
new Map([
[
'comfyui-impact-pack',
{
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
}
]
])
)
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.droppedCount).toBe(0)
expect(outcome.snapshot.packs).toHaveLength(1)
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
)
})
it('drops invalid nodes individually and keeps valid nodes', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () =>
response({
ValidNode: validNode({ name: 'ValidNode' }),
BrokenNode: {
name: 'BrokenNode',
python_module: 'custom_nodes.some-pack'
}
})
)
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.droppedCount).toBe(1)
expect(outcome.droppedNodes[0]?.name).toBe('BrokenNode')
expect(outcome.snapshot.packs[0]?.nodes).toHaveLength(1)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('applies sanitizer before grouping', async () => {
const fetchImpl = vi.fn(async () =>
response({
LoadImage: validNode({
name: 'LoadImage',
python_module: 'nodes',
input: {
required: {
image: [['private.png', 'public.webp'], {}]
}
}
}),
ImpactNode: validNode({
input: {
required: {
choice: [['safe', 'movie.mov'], {}]
}
}
})
})
)
await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
})
expect(sanitizeCallSpy).toHaveBeenCalledTimes(1)
})
it('returns stale with missing env when snapshot is present', async () => {
const snapshot = makeSnapshot()
const snapshotUrl = withSnapshotDir(snapshot)
const fetchImpl = vi.fn()
const outcome = await fetchCloudNodesForBuild({
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^missing /)
expect(fetchImpl).not.toHaveBeenCalled()
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('returns failed when env and snapshot are missing', async () => {
const snapshotUrl = withSnapshotDir(null)
const outcome = await fetchCloudNodesForBuild({
snapshotUrl,
fetchImpl: vi.fn() as unknown as typeof fetch
})
expect(outcome.status).toBe('failed')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('does not retry on HTTP 401', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({}, { status: 401 }))
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^HTTP 401/)
expect(fetchImpl).toHaveBeenCalledTimes(1)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('retries 5xx then falls back to snapshot', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({}, { status: 503 }))
const sleep = vi.fn(async () => undefined)
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
snapshotUrl,
retryDelaysMs: [1, 1, 1],
sleep,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('stale')
expect(fetchImpl).toHaveBeenCalledTimes(4)
expect(sleep).toHaveBeenCalledTimes(3)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('falls back to snapshot on envelope schema mismatch', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response(['unexpected-array-envelope']))
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^envelope schema/)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('memoizes within a single process', async () => {
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
const opts = {
apiKey: KEY,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
}
const [a, b] = await Promise.all([
fetchCloudNodesForBuild(opts),
fetchCloudNodesForBuild(opts)
])
expect(a).toBe(b)
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('throws when called twice with materially different options', async () => {
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
})
expect(() =>
fetchCloudNodesForBuild({
apiKey: 'different-key',
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
})
).toThrow(/called twice with different options/)
})
it('returns fresh even when registry enrichment fails', async () => {
fetchRegistryPacksMock.mockResolvedValue(new Map())
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
})
})

View File

@@ -0,0 +1,355 @@
import { readFile } from 'node:fs/promises'
import {
groupNodesByPack,
sanitizeUserContent,
validateComfyNodeDef
} from '@comfyorg/object-info-parser'
import type { RegistryPack } 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 { CloudNodesEnvelopeSchema } from './cloudNodes.schema'
const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
export interface DroppedNode {
name: string
reason: string
}
export type FetchOutcome =
| {
status: 'fresh'
snapshot: NodesSnapshot
droppedCount: number
droppedNodes: DroppedNode[]
}
| { status: 'stale'; snapshot: NodesSnapshot; reason: string }
| { status: 'failed'; reason: string }
interface FetchCloudNodesOptions {
apiKey?: string
baseUrl?: string
timeoutMs?: number
retryDelaysMs?: readonly number[]
fetchImpl?: typeof fetch
snapshotUrl?: URL
sleep?: (ms: number) => Promise<void>
}
let inflight: Promise<FetchOutcome> | undefined
let inflightOptions: FetchCloudNodesOptions | undefined
export function resetCloudNodesFetcherForTests(): void {
inflight = undefined
inflightOptions = undefined
}
function optionsDifferMaterially(
a: FetchCloudNodesOptions,
b: FetchCloudNodesOptions
): boolean {
return (
a.apiKey !== b.apiKey ||
a.baseUrl !== b.baseUrl ||
a.timeoutMs !== b.timeoutMs ||
a.snapshotUrl?.href !== b.snapshotUrl?.href
)
}
export function fetchCloudNodesForBuild(
options: FetchCloudNodesOptions = {}
): Promise<FetchOutcome> {
if (inflight && inflightOptions) {
if (optionsDifferMaterially(inflightOptions, options)) {
throw new Error(
'fetchCloudNodesForBuild called twice with different options; call resetCloudNodesFetcherForTests() between distinct configurations'
)
}
return inflight
}
inflightOptions = options
inflight = doFetchCloudNodesForBuild(options)
return inflight
}
async function doFetchCloudNodesForBuild(
options: FetchCloudNodesOptions
): Promise<FetchOutcome> {
const apiKey = options.apiKey ?? process.env.WEBSITE_CLOUD_API_KEY
if (!apiKey) {
return fallback('missing WEBSITE_CLOUD_API_KEY', options.snapshotUrl)
}
const result = await tryFetchAndParse(apiKey, options)
if (result.kind === 'ok') {
return {
status: 'fresh',
snapshot: {
fetchedAt: new Date().toISOString(),
packs: result.packs
},
droppedCount: result.droppedNodes.length,
droppedNodes: result.droppedNodes
}
}
return fallback(result.reason, options.snapshotUrl)
}
async function fallback(
reason: string,
snapshotUrl: URL | undefined
): Promise<FetchOutcome> {
const snapshot = await readSnapshot(snapshotUrl)
if (snapshot) return { status: 'stale', snapshot, reason }
return { status: 'failed', reason }
}
interface FetchOk {
kind: 'ok'
packs: Pack[]
droppedNodes: DroppedNode[]
}
interface FetchErr {
kind: 'err'
reason: string
}
async function tryFetchAndParse(
apiKey: string,
options: FetchCloudNodesOptions
): Promise<FetchOk | FetchErr> {
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS
const fetchImpl = options.fetchImpl ?? fetch
const sleep = options.sleep ?? defaultSleep
const url = `${baseUrl}/api/object_info`
let lastReason = 'unknown error'
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
if (attempt > 0) await sleep(retryDelaysMs[attempt - 1])
const response = await callOnce(fetchImpl, url, apiKey, timeoutMs)
if (response.kind === 'err') {
lastReason = response.reason
if (!response.retryable) return response
continue
}
const envelope = CloudNodesEnvelopeSchema.safeParse(response.body)
if (!envelope.success) {
return {
kind: 'err',
reason: `envelope schema validation failed: ${envelope.error.issues
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
.join('; ')}`
}
}
if (Object.keys(envelope.data).length === 0) {
return {
kind: 'err',
reason:
'envelope schema validation failed: <root>: expected non-empty object'
}
}
return parseCloudNodes(envelope.data, options)
}
return { kind: 'err', reason: lastReason }
}
type CallResponse =
| { kind: 'ok'; body: unknown }
| { kind: 'err'; reason: string; retryable: boolean }
async function callOnce(
fetchImpl: typeof fetch,
url: string,
apiKey: string,
timeoutMs: number
): Promise<CallResponse> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetchImpl(url, {
method: 'GET',
headers: {
Accept: 'application/json',
'X-API-Key': apiKey
},
signal: controller.signal
})
if (res.ok) {
return { kind: 'ok', body: await res.json() }
}
const retryable =
res.status === 429 || (res.status >= 500 && res.status < 600)
return {
kind: 'err',
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim(),
retryable
}
} catch (error) {
const reason =
error instanceof Error
? `network error: ${error.message}`
: 'network error'
return { kind: 'err', reason, retryable: true }
} finally {
clearTimeout(timer)
}
}
async function parseCloudNodes(
envelope: Record<string, unknown>,
options: FetchCloudNodesOptions
): Promise<FetchOk> {
const validDefs: Record<string, ReturnType<typeof validateComfyNodeDef>> = {}
const droppedNodes: DroppedNode[] = []
for (const [name, rawDef] of Object.entries(envelope)) {
let validationError = 'unknown validation error'
const parsed = validateComfyNodeDef(rawDef, (error) => {
validationError = error
})
if (!parsed) {
droppedNodes.push({ name, reason: validationError })
continue
}
validDefs[name] = parsed
}
const sanitizedDefs = sanitizeUserContent(
validDefs as Record<string, NonNullable<(typeof validDefs)[string]>>
)
const grouped = groupNodesByPack(sanitizedDefs)
let registryMap = new Map<string, RegistryPack | null>()
try {
registryMap = await fetchRegistryPacks(
grouped.map((pack) => pack.id),
{ fetchImpl: options.fetchImpl }
)
} catch {
registryMap = new Map()
}
const packs = grouped.map((pack) =>
toDomainPack(
pack.id,
pack.displayName,
pack.nodes,
registryMap.get(pack.id)
)
)
return { kind: 'ok', packs, droppedNodes }
}
function safeExternalUrl(value: string | undefined): string | undefined {
if (!value) return undefined
try {
const url = new URL(value)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
if (!url.host) return undefined
return url.toString()
} catch {
return undefined
}
}
function toDomainPack(
packId: string,
fallbackDisplayName: string,
nodes: Array<{
className: string
def: {
display_name: string
category: string
description: string
deprecated?: boolean
experimental?: boolean
}
}>,
registryPack: RegistryPack | null | undefined
): Pack {
return {
id: packId,
registryId: registryPack?.id,
displayName: registryPack?.name?.trim() || fallbackDisplayName || packId,
description: registryPack?.description?.trim() || undefined,
bannerUrl: safeExternalUrl(registryPack?.banner_url),
iconUrl: safeExternalUrl(registryPack?.icon),
repoUrl: safeExternalUrl(registryPack?.repository),
publisher: registryPack?.publisher?.id
? {
id: registryPack.publisher.id,
name: registryPack.publisher.name
}
: undefined,
downloads: registryPack?.downloads,
githubStars: registryPack?.github_stars,
latestVersion: registryPack?.latest_version?.version,
license: registryPack?.license,
lastUpdated:
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))
}
}
function toDomainNode(
className: string,
def: {
display_name: string
category: string
description: string
deprecated?: boolean
experimental?: boolean
}
): PackNode {
return {
name: className,
displayName: def.display_name,
category: def.category,
description: def.description || undefined,
deprecated: def.deprecated,
experimental: def.experimental
}
}
async function readSnapshot(
snapshotUrl: URL | undefined
): Promise<NodesSnapshot | null> {
if (!snapshotUrl) {
return isNodesSnapshot(bundledSnapshot) ? bundledSnapshot : null
}
try {
const text = await readFile(snapshotUrl, 'utf8')
const parsed: unknown = JSON.parse(text)
if (isNodesSnapshot(parsed)) return parsed
return null
} catch {
return null
}
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@@ -0,0 +1,24 @@
/**
* Serialize an object to a JSON string safe to embed inside an inline
* `<script type="application/ld+json">` tag.
*
* Pack metadata flows in from the public ComfyUI registry, where any user
* can publish a pack with a `displayName`, `description`, or
* `publisher.name` that contains `</script>` or `<!--`. Without escaping,
* those sequences would close the surrounding `<script>` tag and allow
* stored XSS.
*
* Escapes:
* - Every `<` (covers `</script>`, `<script`, `<!--`)
* - U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR (which are
* line terminators in HTML script context but valid characters
* elsewhere in JSON, so `JSON.stringify` does not escape them)
*
* Reference: https://v8.dev/features/subsume-json
*/
export function escapeJsonLd(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029')
}

View File

@@ -0,0 +1,45 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [50, 120],
"size": [400, 200],
"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": [
"147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png[output]",
"image"
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,84 @@
{
"last_node_id": 3,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [50, 120],
"size": [400, 200],
"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": ["ComfyUI_00001_.png [output]", "image"]
},
{
"id": 2,
"type": "LoadVideo",
"pos": [430, 120],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VIDEO",
"type": "VIDEO",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadVideo"
},
"widgets_values": ["clip.mp4 [output]", "image"]
},
{
"id": 3,
"type": "LoadAudio",
"pos": [810, 120],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "AUDIO",
"type": "AUDIO",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadAudio"
},
"widgets_values": ["sound.wav [output]", null, ""]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

Binary file not shown.

View File

@@ -0,0 +1,90 @@
{
"id": "06e5b524-5a40-40b9-b561-199dfab18cf0",
"revision": 0,
"last_node_id": 12,
"last_link_id": 10,
"nodes": [
{
"id": 10,
"type": "KSampler",
"pos": [230, 110],
"size": [270, 317.5666809082031],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"name": "denoise",
"type": "FLOAT",
"widget": {
"name": "denoise"
},
"link": 10
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 11,
"type": "PrimitiveFloat",
"pos": [-80.55032348632812, 375.2260443115233],
"size": [270, 80.23332977294922],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "FLOAT",
"type": "FLOAT",
"links": [10]
}
],
"properties": {
"Node name for S&R": "PrimitiveFloat"
},
"widgets_values": [0]
}
],
"links": [[10, 11, 0, 10, 4, "FLOAT"]],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.8264462809917354,
"offset": [1335.8909766107738, 692.7345403667316]
},
"frontendVersion": "1.45.4"
},
"version": 0.4
}

View File

@@ -285,10 +285,12 @@ export class ComfyPage {
async setup({
clearStorage = true,
mockReleases = true
mockReleases = true,
url
}: {
clearStorage?: boolean
mockReleases?: boolean
url?: string
} = {}) {
// Mock release endpoint to prevent changelog popups (before navigation)
if (mockReleases) {
@@ -320,7 +322,7 @@ export class ComfyPage {
}, this.id)
}
await this.goto()
await this.goto({ url })
await this.page.waitForFunction(() => document.fonts.ready)
await this.waitForAppReady()
@@ -347,8 +349,8 @@ export class ComfyPage {
return assetPath(fileName)
}
async goto() {
await this.page.goto(this.url)
async goto({ url }: { url?: string } = {}) {
await this.page.goto(url ? new URL(url, this.url).toString() : this.url)
}
async nextFrame() {
@@ -470,6 +472,7 @@ const COLLECT_COVERAGE = process.env.COLLECT_COVERAGE === 'true'
export const comfyPageFixture = base.extend<{
initialFeatureFlags: Record<string, unknown>
initialSettings: Record<string, unknown>
comfyPage: ComfyPage
comfyMouse: ComfyMouse
comfyFiles: ComfyFiles
@@ -477,6 +480,10 @@ export const comfyPageFixture = base.extend<{
// Allows configuring feature flags for tests with before initial setup:
// `test.use({ initialFeatureFlags: { my_flag: true } })`.
initialFeatureFlags: [{}, { option: true }],
// Allows seeding user settings before initial page load:
// `test.use({ initialSettings: { 'Comfy.Locale': 'zh' } })`. Merged on top of
// the fixture's defaults so per-test values win.
initialSettings: [{}, { option: true }],
page: async ({ page, browserName }, use) => {
if (browserName !== 'chromium' || !COLLECT_COVERAGE) {
@@ -494,7 +501,11 @@ export const comfyPageFixture = base.extend<{
await mcr.add(coverage)
},
comfyPage: async ({ page, request, initialFeatureFlags }, use, testInfo) => {
comfyPage: async (
{ page, request, initialFeatureFlags, initialSettings },
use,
testInfo
) => {
const comfyPage = new ComfyPage(page, request)
const { parallelIndex } = testInfo
@@ -529,7 +540,8 @@ export const comfyPageFixture = base.extend<{
// Disable errors tab to prevent missing model detection from
// rendering error indicators on nodes during unrelated tests.
'Comfy.RightSidePanel.ShowErrorsTab': false,
...(isVueNodes && { 'Comfy.VueNodes.Enabled': true })
...(isVueNodes && { 'Comfy.VueNodes.Enabled': true }),
...initialSettings
})
} catch (e) {
console.error(e)

View File

@@ -246,4 +246,18 @@ export class VueNodeHelpers {
position: { x: box.width / 2, y: box.height * 0.75 }
})
}
async isSlotConnected(slot: Locator) {
const key = await slot.getByTestId('slot-dot').getAttribute('data-slot-key')
if (!key) return false
return await this.page.evaluate((key) => {
const [nodeId, type, slotId] = key.split('-')
const node = app?.canvas?.graph?.getNodeById(nodeId)
if (!node) return false
return type === 'in'
? node.inputs[Number(slotId)]?.link !== null
: !!node.outputs[Number(slotId)].links?.length
}, key)
}
}

View File

@@ -6,12 +6,16 @@ export class ContextMenu {
public readonly litegraphMenu: Locator
public readonly litegraphContextMenu: Locator
public readonly menuItems: Locator
protected readonly anyMenu: Locator
constructor(public readonly page: Page) {
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
this.litegraphMenu = page.locator('.litemenu')
this.litegraphContextMenu = page.locator('.litecontextmenu')
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
this.anyMenu = this.primeVueMenu
.or(this.litegraphMenu)
.or(this.litegraphContextMenu)
}
async clickMenuItem(name: string): Promise<void> {
@@ -36,16 +40,7 @@ export class ContextMenu {
}
async isVisible(): Promise<boolean> {
const primeVueVisible = await this.primeVueMenu
.isVisible()
.catch(() => false)
const litegraphVisible = await this.litegraphMenu
.isVisible()
.catch(() => false)
const litegraphContextVisible = await this.litegraphContextMenu
.isVisible()
.catch(() => false)
return primeVueVisible || litegraphVisible || litegraphContextVisible
return await this.anyMenu.isVisible()
}
async assertHasItems(items: string[]): Promise<void> {
@@ -58,7 +53,7 @@ export class ContextMenu {
async openFor(locator: Locator): Promise<this> {
await locator.click({ button: 'right' })
await expect.poll(() => this.isVisible()).toBe(true)
await expect(this.anyMenu).toBeVisible()
return this
}

View File

@@ -95,6 +95,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
public readonly allTab: Locator
public readonly blueprintsTab: Locator
public readonly sortButton: Locator
public readonly nodePreview: Locator
constructor(public override readonly page: Page) {
super(page, 'node-library')
@@ -103,6 +104,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
this.allTab = this.getTab('All')
this.blueprintsTab = this.getTab('Blueprints')
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
}
getTab(name: string) {
@@ -384,11 +386,14 @@ export class AssetsSidebarTab extends SidebarTab {
return this.page.locator('.p-contextmenu').getByText(label)
}
override async open() {
override async open({ waitForAssets = true } = {}) {
// Remove any toast notifications that may overlay the sidebar button
await this.dismissToasts()
await super.open()
await this.generatedTab.waitFor({ state: 'visible' })
if (waitForAssets) {
await this.waitForAssets()
}
}
/** Dismiss all visible toast notifications by clicking their close buttons. */

View File

@@ -0,0 +1,81 @@
import type { Locator } from '@playwright/test'
import { comfyExpect as expect } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { dragByIndex } from '@e2e/fixtures/utils/dragAndDrop'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
export class SubgraphEditor {
public readonly root: Locator
public readonly promotionItems: Locator
constructor(protected readonly comfyPage: ComfyPage) {
this.root = this.comfyPage.menu.propertiesPanel.root
this.promotionItems = this.root.getByTestId(
TestIds.subgraphEditor.widgetItem
)
}
async open(subgraphNode: Locator) {
await new VueNodeFixture(subgraphNode).select()
const menu = await this.comfyPage.contextMenu.openFor(subgraphNode)
await menu.clickMenuItemExact('Edit Subgraph Widgets')
await expect(this.root, 'Open Properties Panel').toBeVisible()
}
resolveItem(options: {
nodeName?: string
nodeId?: string
widgetName: string
}): Locator {
const nodeItems =
options.nodeId !== undefined
? this.comfyPage.page.locator(`[data-nodeid="${options.nodeId}"]`)
: options.nodeName !== undefined
? this.promotionItems.filter({
has: this.comfyPage.page
.getByTestId(TestIds.subgraphEditor.nodeName)
.filter({ hasText: options.nodeName })
})
: this.promotionItems
return nodeItems.filter({
has: this.comfyPage.page
.getByTestId(TestIds.subgraphEditor.widgetLabel)
.filter({ hasText: options.widgetName })
})
}
getToggleButton(item: Locator) {
return item.getByTestId(TestIds.subgraphEditor.widgetToggle)
}
async togglePromotionOnItem(item: Locator, toState?: boolean) {
const toggleIcon = item.getByTestId(TestIds.subgraphEditor.iconEye)
if (toState !== undefined) {
const expectedIcon = `icon-[lucide--eye${toState ? '-off' : ''}]`
await expect(toggleIcon).toContainClass(expectedIcon)
}
await toggleIcon.click()
}
async togglePromotion(
subgraphNode: Locator,
options: {
nodeName?: string
nodeId?: string
widgetName: string
toState?: boolean
}
) {
await this.open(subgraphNode)
const item = this.resolveItem(options)
await this.togglePromotionOnItem(item, options.toState)
}
async dragItem(fromIndex: number, toIndex: number) {
await dragByIndex(this.promotionItems, fromIndex, toIndex)
await this.comfyPage.nextFrame()
}
}

View File

@@ -2,34 +2,7 @@ import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
/**
* Drag an element from one index to another within a list of locators.
* Uses mousedown/mousemove/mouseup to trigger the DraggableList library.
*
* DraggableList toggles position when the dragged item's center crosses
* past an idle item's center. To reliably land at the target position,
* we overshoot slightly past the target's far edge.
*/
async function dragByIndex(items: Locator, fromIndex: number, toIndex: number) {
const fromBox = await items.nth(fromIndex).boundingBox()
const toBox = await items.nth(toIndex).boundingBox()
if (!fromBox || !toBox) throw new Error('Item not visible for drag')
const draggingDown = toIndex > fromIndex
const targetY = draggingDown
? toBox.y + toBox.height * 0.9
: toBox.y + toBox.height * 0.1
const page = items.page()
await page.mouse.move(
fromBox.x + fromBox.width / 2,
fromBox.y + fromBox.height / 2
)
await page.mouse.down()
await page.mouse.move(toBox.x + toBox.width / 2, targetY, { steps: 10 })
await page.mouse.up()
}
import { dragByIndex } from '@e2e/fixtures/utils/dragAndDrop'
export class BuilderSelectHelper {
/** All IoItem locators in the current step sidebar. */

View File

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

View File

@@ -9,12 +9,17 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyW
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor'
import { TestIds } from '@e2e/fixtures/selectors'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
export class SubgraphHelper {
constructor(private readonly comfyPage: ComfyPage) {}
public readonly editor: SubgraphEditor
constructor(private readonly comfyPage: ComfyPage) {
this.editor = new SubgraphEditor(comfyPage)
}
private get page(): Page {
return this.comfyPage.page
@@ -327,6 +332,23 @@ export class SubgraphHelper {
await this.comfyPage.nextFrame()
}
async promoteWidget(nodeLocator: Locator, widgetName: string): Promise<void> {
const widget = nodeLocator.getByLabel(widgetName, { exact: true })
await this.comfyPage.contextMenu
.openFor(widget)
.then((m) => m.clickMenuItemExact(`Promote Widget: ${widgetName}`))
}
async unpromoteWidget(
nodeLocator: Locator,
widgetName: string
): Promise<void> {
const widget = nodeLocator.getByLabel(widgetName, { exact: true })
await this.comfyPage.contextMenu
.openFor(widget)
.then((m) => m.clickMenuItemExact(`Un-Promote Widget: ${widgetName}`))
}
async isInSubgraph(): Promise<boolean> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph

View File

@@ -4,10 +4,16 @@ import type { Locator, Page } from '@playwright/test'
export class ToastHelper {
public readonly visibleToasts: Locator
public readonly toastErrors: Locator
public readonly toastSuccesses: Locator
public readonly toastWarnings: Locator
constructor(private readonly page: Page) {
this.visibleToasts = page.locator('.p-toast-message:visible')
this.toastErrors = page.locator('.p-toast-message.p-toast-message-error')
this.toastSuccesses = page.locator(
'.p-toast-message.p-toast-message-success'
)
this.toastWarnings = page.locator('.p-toast-message.p-toast-message-warn')
}
async closeToasts(requireCount = 0): Promise<void> {

View File

@@ -8,6 +8,7 @@ export const TestIds = {
toolbar: 'side-toolbar',
nodeLibrary: 'node-library-tree',
nodeLibrarySearch: 'node-library-search',
nodePreviewCard: 'node-preview-card',
workflows: 'workflows-sidebar',
modeToggle: 'mode-toggle'
},
@@ -103,14 +104,16 @@ export const TestIds = {
errorsTab: 'panel-tab-errors'
},
subgraphEditor: {
toggle: 'subgraph-editor-toggle',
shownSection: 'subgraph-editor-shown-section',
hiddenSection: 'subgraph-editor-hidden-section',
widgetToggle: 'subgraph-widget-toggle',
widgetLabel: 'subgraph-widget-label',
iconLink: 'icon-link',
iconEye: 'icon-eye',
widgetActionsMenuButton: 'widget-actions-menu-button'
iconLink: 'icon-link',
nodeName: 'subgraph-widget-node-name',
shownSection: 'subgraph-editor-shown-section',
toggle: 'subgraph-editor-toggle',
widgetActionsMenuButton: 'widget-actions-menu-button',
widgetItem: 'subgraph-widget-item',
widgetLabel: 'subgraph-widget-label',
widgetToggle: 'subgraph-widget-toggle'
},
node: {
titleInput: 'node-title-input',

View File

@@ -0,0 +1,33 @@
import type { Locator } from '@playwright/test'
/**
* Drag an element from one index to another within a list of locators.
* Uses mousedown/mousemove/mouseup to trigger the DraggableList library.
*
* DraggableList toggles position when the dragged item's center crosses
* past an idle item's center. To reliably land at the target position,
* we overshoot slightly past the target's far edge.
*/
export async function dragByIndex(
items: Locator,
fromIndex: number,
toIndex: number
) {
const fromBox = await items.nth(fromIndex).boundingBox()
const toBox = await items.nth(toIndex).boundingBox()
if (!fromBox || !toBox) throw new Error('Item not visible for drag')
const draggingDown = toIndex > fromIndex
const targetY = draggingDown
? toBox.y + toBox.height * 0.9
: toBox.y + toBox.height * 0.1
const page = items.page()
await page.mouse.move(
fromBox.x + fromBox.width / 2,
fromBox.y + fromBox.height / 2
)
await page.mouse.down()
await page.mouse.move(toBox.x + toBox.width / 2, targetY, { steps: 10 })
await page.mouse.up()
}

View File

@@ -15,6 +15,7 @@ export class VueNodeFixture {
public readonly root: Locator
public readonly widgets: Locator
public readonly imagePreview: Locator
public readonly content: Locator
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
@@ -27,6 +28,7 @@ export class VueNodeFixture {
this.root = locator
this.widgets = this.locator.locator('.lg-node-widget')
this.imagePreview = locator.locator('.image-preview')
this.content = locator.locator('.lg-node-content')
}
async getTitle(): Promise<string> {
@@ -39,6 +41,10 @@ export class VueNodeFixture {
await this.titleEditor.setTitle(value)
}
async select() {
await this.header.click()
}
async toggleCollapse(): Promise<void> {
await this.collapseButton.click()
}
@@ -60,4 +66,15 @@ export class VueNodeFixture {
boundingBox(): ReturnType<Locator['boundingBox']> {
return this.locator.boundingBox()
}
getSlot(nameOrLocator: string | Locator) {
const slotLocators = this.root
.getByTestId('node-widget')
.or(this.root.locator('.lg-slot'))
const filteredLocator =
typeof nameOrLocator === 'string'
? slotLocators.filter({ hasText: nameOrLocator })
: slotLocators.filter({ has: nameOrLocator })
return filteredLocator.getByTestId('slot-dot').locator('..')
}
}

View File

@@ -0,0 +1,199 @@
/**
* FE-230: Deleting an asset must clear the Load Image node preview, widget
* value, and mark the workflow dirty.
*
* Local run (requires cloud build of the frontend):
* pnpm build:cloud
* pnpm exec playwright test --project=cloud \
* browser_tests/tests/assetDeleteClearsLoadImage.spec.ts --reporter=list
*
* The cloud project is required because input-asset deletion is gated on
* `isCloud === true` (see `useMediaAssetActions.deleteAssetApi`).
*/
import type { Page, Route } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
STABLE_CHECKPOINT,
STABLE_INPUT_IMAGE
} from '@e2e/fixtures/data/assetFixtures'
// The asset name must match the dropped file so that the deletion flow's
// widget-value matching (name + `name [input]`) actually targets the same
// value the drag-and-drop set on the Load Image widget.
const DROPPED_FILE = 'image64x64.webp'
const TARGET_ASSET: Asset = {
...STABLE_INPUT_IMAGE,
name: DROPPED_FILE,
mime_type: 'image/webp'
}
const SEEDED_ASSETS: Asset[] = [STABLE_CHECKPOINT, TARGET_ASSET]
// MediaAssetCard renders the basename without extension, so card-text
// matching uses the stripped form.
const TARGET_CARD_TEXT = TARGET_ASSET.name.replace(/\.[^.]+$/, '')
type AssetMockApi = {
readonly deleteCalls: ReadonlyArray<string>
}
const assetMockByPage = new WeakMap<Page, { deleteCalls: string[] }>()
function filterByTags(assets: Asset[], url: URL): Asset[] {
const includeTags = parseTagParam(url.searchParams.get('include_tags'))
const excludeTags = parseTagParam(url.searchParams.get('exclude_tags'))
return assets.filter(
(asset) =>
includeTags.every((tag) => (asset.tags ?? []).includes(tag)) &&
excludeTags.every((tag) => !(asset.tags ?? []).includes(tag))
)
}
function parseTagParam(value: string | null): string[] {
return (
value
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}
// Narrow the route patterns to the cloud API endpoints we actually want to
// intercept. Using a broader pattern (e.g. `**/assets**`) collides with the
// cloud build's static bundle paths under `/assets/*.js` and starves the app
// of its own JavaScript, leaving it stuck on the loading splash.
async function registerAssetMocks(
page: Page,
assets: Asset[],
deleteCalls: string[]
): Promise<void> {
await page.route(/\/api\/assets(?:\?.*)?$/, (route: Route) => {
if (route.request().method() !== 'GET') return route.fallback()
const url = new URL(route.request().url())
const filtered = filterByTags(assets, url)
const body: ListAssetsResponse = {
assets: filtered,
total: filtered.length,
has_more: false
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
})
await page.route(/\/api\/assets\/([^/?#]+)$/, (route: Route) => {
const method = route.request().method()
const id = new URL(route.request().url()).pathname.split('/').pop() ?? ''
if (method === 'DELETE') {
deleteCalls.push(id)
return route.fulfill({ status: 204, body: '' })
}
if (method === 'GET') {
const found = assets.find((asset) => asset.id === id)
if (found) return route.fulfill({ status: 200, json: found })
return route.fulfill({ status: 404, json: { error: 'Not found' } })
}
return route.fallback()
})
}
const baseTest = comfyPageFixture.extend<{ assetMock: AssetMockApi }>({
page: async ({ page }, use) => {
const deleteCalls: string[] = []
await registerAssetMocks(page, SEEDED_ASSETS, deleteCalls)
assetMockByPage.set(page, { deleteCalls })
await use(page)
assetMockByPage.delete(page)
},
assetMock: async ({ page }, use) => {
const state = assetMockByPage.get(page)
if (!state) throw new Error('assetMock state missing for page')
await use({
get deleteCalls() {
return state.deleteCalls
}
})
}
})
baseTest.describe(
'FE-230 asset delete clears Load Image preview',
{ tag: '@cloud' },
() => {
baseTest(
'deleting an input asset clears widget value, preview cache, and marks workflow modified',
async ({ comfyPage, assetMock }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
// Drive the production drag-and-drop flow to point the Load Image
// widget at the asset we are about to delete and populate the preview
// cache. FE-230 is asserting that the deletion tears these down.
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const { x, y } = await loadImageNode.getPosition()
await comfyPage.dragDrop.dragAndDropFile(DROPPED_FILE, {
dropPosition: { x, y },
waitForUpload: true
})
const imageWidget = await loadImageNode.getWidget(0)
await expect.poll(() => imageWidget.getValue()).toBe(DROPPED_FILE)
// Re-baseline the change tracker so the deletion-side mutation is the
// only thing that can flip `isModified` later.
await comfyPage.page.evaluate(() => {
const tracker =
window.app?.extensionManager?.workflow?.activeWorkflow
?.changeTracker
tracker?.reset?.()
})
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(false)
// Drive the real production flow: assets sidebar → Imported tab →
// right-click asset card → Delete → confirm dialog.
const sidebar = comfyPage.menu.assetsTab
// The default `open()` waits for assets on the Generated tab; we seed
// only an input asset, so skip that wait and let `waitForAssets(1)`
// gate on the Imported tab instead.
await sidebar.open({ waitForAssets: false })
await sidebar.switchToImported()
await sidebar.waitForAssets(1)
await sidebar.rightClickAsset(TARGET_CARD_TEXT)
const deleteMenuItem = sidebar.contextMenuItem('Delete')
await expect(deleteMenuItem).toBeVisible()
await deleteMenuItem.click()
await comfyPage.confirmDialog.click('delete')
// Mocked DELETE was issued.
await expect
.poll(() => assetMock.deleteCalls.includes(TARGET_ASSET.id))
.toBe(true)
// Widget value was cleared.
await expect.poll(() => imageWidget.getValue()).toBe('')
// Preview cache was cleared.
await expect
.poll(() =>
comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
return node?.imgs?.length ?? 0
}, loadImageNode.id)
)
.toBe(0)
// Workflow was marked dirty by changeTracker.captureCanvasState().
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(true)
}
)
}
)

View File

@@ -0,0 +1,101 @@
import type { Page } from '@playwright/test'
import type { CustomNodesI18n } from '@/schemas/apiSchema'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
const NODE_TYPE = 'DevToolsNodeWithStringInput'
const LOCALIZED_ZH = '本地化字符串输入 (ZH)'
const LOCALIZED_ZH_TW = '本地化字串輸入 (ZH-TW)'
const LOCALIZED_EN = 'Localized String Input (EN)'
async function routeCustomNodesI18n(page: Page, body: CustomNodesI18n) {
await page.route('**/api/i18n', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
})
}
test.describe(
'Custom node locales loading',
{ tag: ['@ui', '@vue-nodes'] },
() => {
test.describe('shipped base tag', () => {
test.use({ initialSettings: { 'Comfy.Locale': 'zh' } })
test.beforeEach(async ({ page }) => {
await routeCustomNodesI18n(page, {
zh: { nodeDefs: { [NODE_TYPE]: { display_name: LOCALIZED_ZH } } }
})
})
// Regression test for PR #7214 (issue #7025): custom-node i18n data was
// clobbered when a non-English locale was lazily loaded, so nodes from
// custom packs lost their translated display_name on locale switch.
test('preserves custom-node /api/i18n translation through lazy locale load', async ({
comfyPage
}) => {
await comfyPage.nodeOps.addNode(NODE_TYPE)
await expect(
comfyPage.vueNodes.getNodeByTitle(LOCALIZED_ZH)
).toHaveCount(1)
})
})
test.describe('unsupported tag clamps to en', () => {
// Regression test for PR #11712 (issue #10563): when Comfy.Locale holds
// an unsupported tag, the boundary helper clamps it to 'en'. Custom-node
// 'en' translations must still merge into the active locale messages.
test.use({ initialSettings: { 'Comfy.Locale': 'de' } })
test.beforeEach(async ({ page }) => {
await routeCustomNodesI18n(page, {
en: { nodeDefs: { [NODE_TYPE]: { display_name: LOCALIZED_EN } } }
})
})
test('renders en custom-node translation when locale clamps to en', async ({
comfyPage
}) => {
await comfyPage.nodeOps.addNode(NODE_TYPE)
await expect(
comfyPage.vueNodes.getNodeByTitle(LOCALIZED_EN)
).toHaveCount(1)
})
})
test.describe('regional tag preserved', () => {
// Regression test for PR #11712: full-tag match must beat base-tag
// fallback, so a shipped regional tag like 'zh-TW' is not collapsed to
// its base ('zh'). Both keys are present in the payload — the active
// locale must merge the regional variant.
test.use({ initialSettings: { 'Comfy.Locale': 'zh-TW' } })
test.beforeEach(async ({ page }) => {
await routeCustomNodesI18n(page, {
zh: { nodeDefs: { [NODE_TYPE]: { display_name: LOCALIZED_ZH } } },
'zh-TW': {
nodeDefs: { [NODE_TYPE]: { display_name: LOCALIZED_ZH_TW } }
}
})
})
test('uses zh-TW custom-node translation, not zh base-tag fallback', async ({
comfyPage
}) => {
await comfyPage.nodeOps.addNode(NODE_TYPE)
await expect(
comfyPage.vueNodes.getNodeByTitle(LOCALIZED_ZH_TW)
).toHaveCount(1)
})
})
}
)

View File

@@ -247,6 +247,14 @@ test.describe('Cloud notification dialog', () => {
await dialog.back.click()
await expect(dialog.root).toBeHidden()
})
test('Should not advertise free monthly credits', async ({ comfyPage }) => {
const dialog = new CloudNotification(comfyPage.page)
await dialog.open()
await expect(dialog.root.getByText(/Free Credits/i)).toHaveCount(0)
await expect(dialog.root.getByText(/400/)).toHaveCount(0)
})
})
test('Blueprint overwrite', { tag: ['@subgraph'] }, async ({ comfyPage }) => {

View File

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

View File

@@ -74,6 +74,90 @@ test.describe(
})
})
test.describe('Image without workflow', () => {
test('places LoadImage at the drop cursor when graph_mouse is stale', async ({
comfyPage
}) => {
await comfyPage.nodeOps.clearGraph()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await comfyPage.page.evaluate(() => {
window.app!.canvas.graph_mouse[0] = -9999
window.app!.canvas.graph_mouse[1] = -9999
})
const dropPosition = { x: 480, y: 320 }
await comfyPage.dragDrop.dragAndDropFile('image32x32.webp', {
dropPosition,
waitForUpload: true
})
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
const { nodePos, expectedPos } = await comfyPage.page.evaluate(
(drop) => {
const canvas = window.app!.canvas
const expected = canvas.convertEventToCanvasOffset(
new MouseEvent('drop', {
clientX: drop.x,
clientY: drop.y
})
) as [number, number]
const node = window.app!.graph.nodes[0]
return {
nodePos: [node.pos[0], node.pos[1]] as [number, number],
expectedPos: expected
}
},
dropPosition
)
expect(nodePos[0]).toBeCloseTo(expectedPos[0], 0)
expect(nodePos[1]).toBeCloseTo(expectedPos[1], 0)
expect(nodePos[0]).not.toBe(-9999)
expect(nodePos[1]).not.toBe(-9999)
})
test('places LoadImage above existing nodes (zIndex)', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
const initialNodeIds = await comfyPage.vueNodes.getNodeIds()
expect(initialNodeIds.length).toBeGreaterThan(0)
await comfyPage.dragDrop.dragAndDropFile('image32x32.webp', {
dropPosition: { x: 540, y: 380 },
waitForUpload: true
})
await expect
.poll(() => comfyPage.vueNodes.getNodeCount())
.toBe(initialNodeIds.length + 1)
const newNodeIds = await comfyPage.vueNodes.getNodeIds()
const addedNodeId = newNodeIds.find(
(id) => !initialNodeIds.includes(id)
)
expect(addedNodeId).toBeDefined()
const newNodeZ = await comfyPage.vueNodes
.getNodeLocator(addedNodeId!)
.evaluate((el) => Number((el as HTMLElement).style.zIndex))
const existingZIndexes = await comfyPage.vueNodes.nodes.evaluateAll(
(els, id) =>
els
.filter((el) => el.getAttribute('data-node-id') !== id)
.map((el) => Number((el as HTMLElement).style.zIndex)),
addedNodeId!
)
expect(newNodeZ).toBeGreaterThan(Math.max(0, ...existingZIndexes))
})
})
test('Load workflow from URL dropped onto Vue node', async ({
comfyPage
}) => {

View File

@@ -25,6 +25,21 @@ const FIXTURES: readonly MetadataFixture[] = [
{ fileName: 'with_metadata.webm', parser: 'ebml (webm)' }
] as const
// NaN-variant fixtures embed only an API-format prompt containing bare
// `NaN`/`Infinity` tokens (Python's `json.dumps` default). The loader must
// tolerate Python generated JSON for these to import successfully.
const NAN_FIXTURES: readonly MetadataFixture[] = [
{ fileName: 'with_nan_metadata.json', parser: 'json' },
{ fileName: 'with_nan_metadata.png', parser: 'png' },
{ fileName: 'with_nan_metadata.avif', parser: 'avif' },
{ fileName: 'with_nan_metadata.webp', parser: 'webp' },
{ fileName: 'with_nan_metadata.flac', parser: 'flac' },
{ fileName: 'with_nan_metadata.mp3', parser: 'mp3' },
{ fileName: 'with_nan_metadata.opus', parser: 'ogg' },
{ fileName: 'with_nan_metadata.mp4', parser: 'isobmff' },
{ fileName: 'with_nan_metadata.webm', parser: 'ebml (webm)' }
] as const
test.describe(
'Metadata drop-to-load workflow import',
{ tag: ['@workflow'] },
@@ -58,5 +73,42 @@ test.describe(
})
})
}
for (const { fileName, parser } of NAN_FIXTURES) {
test(`loads Python JSON prompt with NaN/Infinity from ${fileName} (${parser})`, async ({
comfyPage
}) => {
await test.step(`drop ${fileName} on canvas`, async () => {
await comfyPage.dragDrop.dragAndDropFilePath(
metadataFixturePath(fileName)
)
})
await test.step('graph contains only the embedded KSampler', async () => {
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(1)
const ksamplers =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
expect(
ksamplers,
'exactly one KSampler should have been loaded from the NaN-laden prompt'
).toHaveLength(1)
})
await test.step('NaN-coerced widget values are 0', async () => {
const [ksampler] =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
for (const widgetName of ['cfg', 'denoise']) {
const widget = await ksampler.getWidgetByName(widgetName)
expect(
await widget.getValue(),
`${widgetName} should be 0 after NaN coercion to null`
).toBe(0)
}
})
})
}
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -549,7 +549,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
expect(uploadCount, 'should upload exactly once').toBe(1)
})
test('Empty canvas does not upload on serialization', async ({
test('Empty canvas uploads a transparent placeholder on serialization', async ({
comfyPage
}) => {
let uploadCount = 0
@@ -566,7 +566,10 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await triggerSerialization(comfyPage.page)
expect(uploadCount, 'empty canvas should not upload').toBe(0)
expect(
uploadCount,
'empty canvas should upload a transparent PNG so the backend receives a valid asset reference (Painter.execute treats painter_alpha=0 as no-mask)'
).toBe(1)
})
test('Upload failure shows error toast', async ({ comfyPage }) => {

View File

@@ -0,0 +1,357 @@
import { expect, mergeTests } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import {
assetRequestIncludesTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import {
createMockJob,
createMockJobRecords
} from '@e2e/fixtures/utils/jobFixtures'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
const ossTest = mergeTests(comfyPageFixture, jobsApiMockFixture)
const outputHash =
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
const plainVideoFileName = 'plain_video.mp4'
const graphDropPosition = { x: 500, y: 300 }
const missingMediaUploadObservationMs = 1_000
const missingMediaUploadPollMs = 100
const cloudOutputAsset: Asset = {
id: 'test-output-hash-001',
name: 'ComfyUI_00001_.png',
asset_hash: outputHash,
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const cloudUploadedVideoAsset: Asset = {
id: 'test-uploaded-video-001',
name: plainVideoFileName,
asset_hash: plainVideoFileName,
size: 1_024,
mime_type: 'video/mp4',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
// The Cloud test app starts with a default LoadImage node. Keep that baseline
// input resolvable so this spec only observes the media it creates.
const cloudDefaultGraphInputAsset: Asset = {
id: 'test-default-input-001',
name: '00000000000000000000000Aexample.png',
asset_hash: '00000000000000000000000Aexample.png',
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'
}
interface CloudUploadAssetState {
isUploadedAssetAvailable: boolean
}
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset])
const cloudUploadAssetStateByPage = new WeakMap<Page, CloudUploadAssetState>()
const cloudUploadRaceTest = comfyPageFixture.extend<{
markUploadedCloudAssetAvailable: () => void
}>({
page: async ({ page }, use) => {
const state: CloudUploadAssetState = {
isUploadedAssetAvailable: false
}
cloudUploadAssetStateByPage.set(page, state)
const assetsRouteHandler = async (route: Route) => {
const allAssets = [
cloudDefaultGraphInputAsset,
...(state.isUploadedAssetAvailable ? [cloudUploadedVideoAsset] : [])
]
const includeTags =
new URL(route.request().url()).searchParams
.get('include_tags')
?.split(',')
.filter(Boolean) ?? []
const assets = includeTags.length
? allAssets.filter((asset) =>
asset.tags?.some((tag) => includeTags.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)
})
}
await page.route(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
await use(page)
await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
cloudUploadAssetStateByPage.delete(page)
},
markUploadedCloudAssetAvailable: async ({ page }, use) => {
await use(() => {
const state = cloudUploadAssetStateByPage.get(page)
if (state) state.isUploadedAssetAvailable = true
})
}
})
async function enableErrorsTab(comfyPage: ComfyPage) {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
}
function getErrorOverlay(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
}
async function expectNoErrorsTab(comfyPage: ComfyPage) {
await expect(getErrorOverlay(comfyPage)).toBeHidden()
const panel = new PropertiesPanelHelper(comfyPage.page)
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
}
async function delayNextUpload(comfyPage: ComfyPage) {
let releaseUpload!: () => void
let resolveUploadStarted!: () => void
const uploadStarted = new Promise<void>((resolve) => {
resolveUploadStarted = resolve
})
const release = new Promise<void>((resolve) => {
releaseUpload = resolve
})
const uploadRouteHandler = async (route: Route) => {
resolveUploadStarted()
await release
await route.continue()
}
await comfyPage.page.route('**/upload/image', uploadRouteHandler)
return {
waitForUploadStarted: () => uploadStarted,
finishUpload: async () => {
const uploadResponse = comfyPage.page.waitForResponse(
(response) =>
response.url().includes('/upload/image') && response.status() === 200,
{ timeout: 10_000 }
)
releaseUpload()
try {
await uploadResponse
} finally {
await comfyPage.page.unroute('**/upload/image', uploadRouteHandler)
}
}
}
}
async function expectLoadVideoUploading(comfyPage: ComfyPage) {
await expect
.poll(
() =>
comfyPage.page.evaluate(() =>
window.app!.graph.nodes.some(
(node) => node.type === 'LoadVideo' && node.isUploading
)
),
{ timeout: 5_000 }
)
.toBe(true)
}
async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) {
await comfyPage.nextFrame()
await comfyPage.nextFrame()
let sawErrorOverlay = false
const startedAt = Date.now()
await expect
.poll(
async () => {
sawErrorOverlay =
sawErrorOverlay || (await getErrorOverlay(comfyPage).isVisible())
return (
!sawErrorOverlay &&
Date.now() - startedAt >= missingMediaUploadObservationMs
)
},
{
timeout: missingMediaUploadObservationMs + missingMediaUploadPollMs * 5,
intervals: [missingMediaUploadPollMs]
}
)
.toBe(true)
}
function outputHistoryJobs() {
return createMockJobRecords([
createMockJob({
id: 'history-output-image',
preview_output: {
filename: 'ComfyUI_00001_.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
}),
createMockJob({
id: 'history-output-video',
preview_output: {
filename: 'clip.mp4',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'video'
}
}),
createMockJob({
id: 'history-output-audio',
preview_output: {
filename: 'sound.wav',
subfolder: '',
type: 'output',
nodeId: '3',
mediaType: 'audio'
}
})
])
}
ossTest.describe(
'Errors tab - OSS missing media runtime sources',
{ tag: '@ui' },
() => {
ossTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
})
ossTest(
'resolves annotated output media from job history',
async ({ comfyPage, jobsApi }) => {
await jobsApi.mockJobs(outputHistoryJobs())
await comfyPage.workflow.loadWorkflow(
'missing/missing_media_output_annotations'
)
await expectNoErrorsTab(comfyPage)
}
)
ossTest(
'does not surface missing media while dropped video upload is in progress',
async ({ comfyFiles, comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
const delayedUpload = await delayNextUpload(comfyPage)
await comfyPage.dragDrop.dragAndDropFile(plainVideoFileName, {
dropPosition: graphDropPosition
})
await delayedUpload.waitForUploadStarted()
comfyFiles.deleteAfterTest({
filename: plainVideoFileName,
type: 'input'
})
await expectLoadVideoUploading(comfyPage)
await expectNoMissingMediaDuringUpload(comfyPage)
await delayedUpload.finishUpload()
await expect(getErrorOverlay(comfyPage)).toBeHidden()
}
)
}
)
cloudOutputTest.describe(
'Errors tab - Cloud missing media runtime sources',
{ tag: '@cloud' },
() => {
cloudOutputTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
})
cloudOutputTest(
'resolves compact annotated output media from output assets',
async ({ cloudAssetRequests, comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_media_cloud_output_annotation'
)
await expect
.poll(() =>
cloudAssetRequests.some((url) =>
assetRequestIncludesTag(url, 'output')
)
)
.toBe(true)
await expectNoErrorsTab(comfyPage)
}
)
}
)
cloudUploadRaceTest.describe(
'Errors tab - Cloud missing media upload race',
{ tag: '@cloud' },
() => {
cloudUploadRaceTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
})
cloudUploadRaceTest(
'does not surface missing media while dropped video upload is in progress',
async ({ comfyFiles, comfyPage, markUploadedCloudAssetAvailable }) => {
await comfyPage.nodeOps.clearGraph()
const delayedUpload = await delayNextUpload(comfyPage)
await comfyPage.dragDrop.dragAndDropFile(plainVideoFileName, {
dropPosition: graphDropPosition
})
await delayedUpload.waitForUploadStarted()
comfyFiles.deleteAfterTest({
filename: plainVideoFileName,
type: 'input'
})
await expectLoadVideoUploading(comfyPage)
await expectNoMissingMediaDuringUpload(comfyPage)
markUploadedCloudAssetAvailable()
await delayedUpload.finishUpload()
await expect(getErrorOverlay(comfyPage)).toBeHidden()
}
)
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

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

View File

@@ -74,14 +74,16 @@ test.describe(
throw new Error('Could not open More Options menu - popover not showing')
}
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
test('hides Node Info from More Options menu when the new menu is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
await openMoreOptions(comfyPage)
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
exact: true
const nodeInfoButton = comfyPage.page.getByRole('menuitem', {
name: 'Node Info'
})
await expect(nodeInfoButton).toBeVisible()
await nodeInfoButton.click()
await comfyPage.nextFrame()
await expect(nodeInfoButton).toBeHidden()
})
test('changes node shape via Shape submenu', async ({ comfyPage }) => {

View File

@@ -82,6 +82,19 @@ const JOB_GAMMA_DETAIL: JobDetail = {
}
]
}
},
workflow: {
extra_data: {
extra_pnginfo: {
workflow: {
version: 0.4,
last_node_id: 0,
last_link_id: 0,
nodes: [],
links: []
}
}
}
}
}
@@ -112,7 +125,7 @@ test.describe('Assets sidebar - empty states', () => {
test('Shows empty-state copy for generated tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.open({ waitForAssets: false })
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
@@ -120,7 +133,7 @@ test.describe('Assets sidebar - empty states', () => {
test('Shows empty-state copy for imported tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.open({ waitForAssets: false })
await tab.switchToImported()
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
@@ -129,7 +142,7 @@ test.describe('Assets sidebar - empty states', () => {
test('No asset cards are rendered when empty', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.open({ waitForAssets: false })
await expect(tab.assetCards).toHaveCount(0)
})
@@ -209,7 +222,6 @@ test.describe('Assets sidebar - grid view display', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
})
@@ -270,7 +282,6 @@ test.describe('Assets sidebar - view mode toggle', () => {
test('Can switch to list view via settings menu', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Open settings menu and select list view
await tab.openSettingsMenu()
@@ -283,7 +294,6 @@ test.describe('Assets sidebar - view mode toggle', () => {
test('Can switch back to grid view', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Switch to list view
await tab.openSettingsMenu()
@@ -326,7 +336,6 @@ test.describe('Assets sidebar - search', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
@@ -340,7 +349,6 @@ test.describe('Assets sidebar - search', () => {
test('Clearing search restores all assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
@@ -355,7 +363,6 @@ test.describe('Assets sidebar - search', () => {
test('Search with no matches shows empty state', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.searchInput.fill('nonexistent_file_xyz')
await expect(tab.assetCards).toHaveCount(0)
@@ -380,7 +387,6 @@ test.describe('Assets sidebar - selection', () => {
test('Clicking an asset card selects it', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Click first asset card
await tab.assetCards.first().click()
@@ -392,7 +398,6 @@ test.describe('Assets sidebar - selection', () => {
test('Ctrl+click adds to selection', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const cards = tab.assetCards
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
@@ -411,7 +416,6 @@ test.describe('Assets sidebar - selection', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
@@ -423,7 +427,6 @@ test.describe('Assets sidebar - selection', () => {
test('Deselect all clears selection', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
@@ -441,7 +444,6 @@ test.describe('Assets sidebar - selection', () => {
test('Selection is cleared when switching tabs', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
@@ -475,7 +477,6 @@ test.describe('Assets sidebar - context menu', () => {
test('Right-clicking an asset shows context menu', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Right-click first asset
await tab.assetCards.first().click({ button: 'right' })
@@ -490,7 +491,6 @@ test.describe('Assets sidebar - context menu', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
@@ -505,7 +505,6 @@ test.describe('Assets sidebar - context menu', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
@@ -520,7 +519,6 @@ test.describe('Assets sidebar - context menu', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
@@ -535,7 +533,6 @@ test.describe('Assets sidebar - context menu', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
@@ -550,7 +547,6 @@ test.describe('Assets sidebar - context menu', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
@@ -563,12 +559,79 @@ test.describe('Assets sidebar - context menu', () => {
await expect(tab.contextMenuItem('Export workflow')).toBeVisible()
})
test('Cancelling export-workflow filename prompt does not show an error toast', async ({
comfyPage
}) => {
// job-gamma is the first card; its detail carries a valid workflow so
// extraction succeeds and the filename prompt opens.
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.assetCards.first().click({ button: 'right' })
await tab.contextMenuItem('Export workflow').click()
const promptDialog = comfyPage.page.getByRole('dialog', {
name: 'Export Workflow'
})
await expect(promptDialog).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(promptDialog).toBeHidden()
await expect(comfyPage.toast.toastErrors).toBeHidden({ timeout: 1500 })
})
test('Confirming export-workflow prompt downloads the file and shows a success toast', async ({
comfyPage
}) => {
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.assetCards.first().click({ button: 'right' })
await tab.contextMenuItem('Export workflow').click()
const promptDialog = comfyPage.page.getByRole('dialog', {
name: 'Export Workflow'
})
await expect(promptDialog).toBeVisible()
const downloadPromise = comfyPage.page.waitForEvent('download')
await promptDialog.getByRole('button', { name: 'Confirm' }).click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('abstract_art.json')
await expect(comfyPage.toast.toastSuccesses).toBeVisible()
})
test('Export-workflow shows a warning toast when the asset has no workflow', async ({
comfyPage
}) => {
// Strip the workflow field so extraction yields null and the export
// action returns { success: false, error: 'No workflow…' }.
const { workflow: _, ...detailWithoutWorkflow } = JOB_GAMMA_DETAIL
await comfyPage.assets.mockJobDetail('job-gamma', detailWithoutWorkflow)
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.assetCards.first().click({ button: 'right' })
await tab.contextMenuItem('Export workflow').click()
// Filename prompt should be skipped: extraction fails before the prompt.
await expect(comfyPage.toast.toastWarnings).toBeVisible()
await expect(comfyPage.toast.toastSuccesses).toBeHidden({ timeout: 1500 })
})
test('Bulk context menu shows when multiple assets selected', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const cards = tab.assetCards
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
@@ -623,7 +686,6 @@ test.describe('Assets sidebar - bulk actions', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click()
@@ -636,7 +698,6 @@ test.describe('Assets sidebar - bulk actions', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click()
@@ -647,7 +708,6 @@ test.describe('Assets sidebar - bulk actions', () => {
test('Selection count displays correct number', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select the two single-output assets (job-alpha, job-beta).
// The count reflects total outputs, not cards — job-gamma has
@@ -676,7 +736,6 @@ cloudTest.describe('Assets sidebar - cloud exports', { tag: '@cloud' }, () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click()
await expect(tab.downloadSelectedButton).toBeVisible()
@@ -701,7 +760,6 @@ cloudTest.describe('Assets sidebar - cloud exports', { tag: '@cloud' }, () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards
.first()
@@ -737,7 +795,6 @@ cloudTest.describe('Assets sidebar - cloud exports', { tag: '@cloud' }, () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.nth(1).click()
await comfyPage.page.keyboard.down('Control')
@@ -784,7 +841,6 @@ test.describe('Assets sidebar - pagination', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const req = await firstRequest
const url = new URL(req.url())
@@ -840,7 +896,6 @@ test.describe('Assets sidebar - delete confirmation', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await tab.contextMenuItem('Delete').click()
@@ -858,7 +913,6 @@ test.describe('Assets sidebar - delete confirmation', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
@@ -880,7 +934,6 @@ test.describe('Assets sidebar - delete confirmation', () => {
test('Cancelling delete preserves asset', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
@@ -977,7 +1030,6 @@ test.describe('Assets sidebar - media type filter', () => {
test('Unchecking image filter hides image assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = tab.assetCards
await expect(
@@ -998,7 +1050,6 @@ test.describe('Assets sidebar - media type filter', () => {
test('Re-enabling filter restores hidden assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()

View File

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

View File

@@ -607,3 +607,218 @@ test.describe(
)
}
)
test('Promote/Demote by Context Menu @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(steps).toBeVisible()
})
await test.step('Un-promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(subgraphNode).toBeVisible()
await expect(steps).toBeHidden()
})
})
test('Properties panel operations @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'cfg',
toState: true
})
await expect(cfg, 'Promote widget').toBeVisible()
await test.step('widgets display in order promoted', async () => {
await expect(editor.promotionItems.first()).toContainText('steps')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'steps'
)
})
await test.step('Reorder widgets', async () => {
await editor.dragItem(0, 1)
await expect(editor.promotionItems.first()).toContainText('cfg')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'cfg'
)
})
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: false
})
await expect(steps, 'Un-promote widget').toBeHidden()
})
test('Can intermix linked and proxy @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await comfyPage.subgraph.promoteWidget(ksampler.root, 'cfg')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await expect(
subgraphNode.locator('.lg-node-widget').first(),
'linked widgets are first by default'
).toHaveText('steps')
await editor.open(subgraphNode)
await editor.dragItem(0, 1)
await expect(
editor.promotionItems.first(),
'Swap widget order'
).toContainText('cfg')
// FIXME: solve actual bug and remove the not
await expect(
subgraphNode.locator('.lg-node-widget').first(),
'Linked widget is first on node'
).not.toHaveText('cfg')
})
test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await expect(steps).toHaveCount(1)
})
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 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')
})
await test.step('Convert both nodes to subgraph', async () => {
await comfyPage.canvas.focus()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.contextMenu
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
})
const { editor } = comfyPage.subgraph
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
await test.step('Promote both image previews', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: true
})
await expect(subgraph.content).toHaveCount(1)
await editor.togglePromotion(subgraph.root, {
nodeId: '2',
widgetName: '$$canvas-image-preview',
toState: true
})
await expect(subgraph.content).toHaveCount(2)
})
// FUTURE: Add test for re-ordering previews?
await test.step('Demote image', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: false
})
await expect(subgraph.content).toHaveCount(1)
})
})
test('Linked widgets can not be demoted @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await editor.open(subgraphNode)
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
})

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