Compare commits

...

34 Commits

Author SHA1 Message Date
github-actions
0c20157537 [automated] Update test expectations 2026-07-10 12:41:46 +00:00
pythongosssss
83ea8d4e8a fix: mark live warnings seen reliably while panel is open
markAllSeen inside a watchEffect mutates its own dependency; the
recursion guard drops the subscription after a mount-time clear, so
warnings arriving while the panel was open stayed unseen. Use an
immediate watch instead and pin the regression in the component test
2026-07-10 05:39:48 -07:00
pythongosssss
e5f242ed96 fix: migrate onBeforeChange deprecation test to registry store
The test asserted via the removed LiteGraph.onDeprecationWarning
listener array; assert via the deprecation warnings store instead
2026-07-10 04:50:40 -07:00
pythongosssss
20156460a6 fix: migrate LGraph.onBeforeChange deprecation to registry
Added on main via the old litegraph feedback module, which this branch
removes; also drop the now-unused warnDeprecated import in ui.ts
2026-07-10 04:18:11 -07:00
pythongosssss
0c874dcd2f fix: address CodeRabbit feedback on deprecation warnings panel
- Prune stale extension filter selections when their options disappear
- Compare warning sources as sets instead of comparator-less sort
2026-07-10 04:11:45 -07:00
pythongosssss
0628aed16c fix: address review feedback on deprecation warnings
- Gate server-log backfill and __reportDeprecation seam on Comfy.DevMode
- Mark warnings seen as they arrive while the panel is open
- Dedupe and cap the pre-Pinia buffer; drain with occurrence counts
- Add alwaysRepeatWarnings no-op shim with registry entry
- Return shared inert array from onDeprecationWarning getter
2026-07-10 04:11:45 -07:00
pythongosssss
e6e0c0bdd4 tidy: add tests, use popover entries 2026-07-10 04:11:24 -07:00
comfydesigner
09e03be1d4 polish: deprecation warnings panel UI tweaks (#12949)
## Summary

Visual polish on the deprecation warnings sidebar panel, plus a
per-warning clear action. Stacked on top of this PR.

## Changes

- **What**:
- Card: uniform `gap-2`, `rounded-md`; kebab menu moved to absolute
top-right so wrapped messages stay top-aligned
- Right cluster: `text-sm` timestamp; hand-rolled count span replaced
with `<Badge severity="contrast" variant="circle">` (with `class`
overrides so `9+` fits)
- Fix box: `rounded-md`, `p-3`; suggestion uses inline text flow (single
line-box) so the `Fix:` label baseline matches the body; migration link
uses `<Button as="a" variant="link">`
  - Clear all (top): `muted-textonly`, `text-sm`, icon removed
- Per-warning kebab `Popover` with single "Clear" action backed by a new
`store.remove(key)`
- Extension `MultiSelect`: `size="lg"` (only external path to `text-sm`
label)

## Review Focus

- **`#tool-buttons` is hover-revealed** by `SidebarTabTemplate` ([line
23](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/components/sidebar/tabs/SidebarTabTemplate.vue#L23)).
Clear all therefore vanishes when not hovering the panel — possibly not
what we want here, but out of scope for this PR. Either move the action
out of `#tool-buttons` or accept the hover-reveal.
- The count `Badge` needs a wider count-style variant; the current
`variant="circle"` is fixed `size-3.5`, so we override via `class`.
Worth a follow-up to add it to `Badge`.
- `MultiSelect` has no label-size prop, so `size="lg"` is the only way
to get `text-sm`. Worth exposing one.

## Screenshots

Co-authored-by: Alex <alex@Alexs-MacBook-Pro.local>
2026-07-10 04:11:24 -07:00
pythongosssss
c1dae8bad5 fix tests, expose report depercation for testing 2026-07-10 04:11:24 -07:00
pythongosssss
e149ca0dd2 clear filters on clear warnings 2026-07-10 04:10:56 -07:00
pythongosssss
ce103c8e46 - unify app/lg warnings into common typed registry with unique ids instead of raw strings, drop bridge
- add ssot for deprecations allowing autogenerated deprecation docs
- refactor warnings to add additional extension source and details, update UI and add extension filter
- fix MultiSelect dismiss via useDismissableOverlay
- deprecate LiteGraph.onDeprecationWarning
- add commandStore.unregisterCommand and call on sidebar tab unregister
- backfill panel from server logs on load
2026-07-10 04:10:56 -07:00
pythongosssss
d08c0e02da match src constant names 2026-07-10 04:09:16 -07:00
pythongosssss
bf78f8718d rabbit: dedupe concurrent requests, fix missing date string 2026-07-10 04:09:16 -07:00
pythongosssss
34f08ac479 remove export 2026-07-10 04:09:16 -07:00
pythongosssss
315fd0bc39 feat: add dev mode deprecation panel
- add deprecation warning panel with deduped counts
- add store, litegraph bridge and seed with server logs
- update existing deprecation warnings and logs
2026-07-10 04:09:16 -07:00
Maanil Verma
ceb5ae1eba fix(cloud): keep survey footer visible on small screens (#13568)
## Summary

Keep the onboarding survey's Back/Submit footer visible on small screens
by scrolling only the question area instead of the whole survey.

## Changes

- **What**: The survey scrolled as a single block , so on short
viewports the button row slid under the template footer (Terms/Privacy).
Now the outer is bounded to its slot and the question wrapper in
`DynamicSurveyForm` scrolls internally with a responsive cap , so option
lists scroll while the footer buttons stay pinned. The step height
animation is unchanged.

## Screen Recording

https://github.com/user-attachments/assets/6d7f6d50-59b8-4dc0-b20e-8f4ca08167c6
2026-07-10 08:30:50 +05:30
Comfy Org PR Bot
9f880c78cb 1.48.1 (#13559)
Patch version increment to 1.48.1

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-10 02:16:50 +00:00
Benjamin Lu
3b2eb50f3b feat(cloud): redeem desktop login codes for web-to-desktop identity stitching (GTM-93) (#13418)
## What

Browser half of GTM-93 macOS web→desktop identity stitching: when the
desktop app opens the system browser at cloud login with
`?desktop_login_code=dlc_…`, the frontend redeems that code against the
cloud backend once a Firebase session exists — after **explicit user
approval**.

Reworked on top of the preserved-query `stripAfterCapture` capability
(#13465):

- The `DESKTOP_LOGIN` namespace opts into strip-on-capture: the code is
stashed and removed from the URL **before any navigation completes**, so
it never reaches history, `previousFullPath`, later guards, or telemetry
— the hand-rolled URL scrubbing this PR previously carried (raw-string
parser + three strip sites) is gone.
- `desktopLoginRedemption.ts` is a plain module with a single export,
`installDesktopLoginRedemption(router)`, installed once in `router.ts`'s
cloud block (replaces six per-view/composable trigger sites). Redemption
reads the code only from the stash: per-code state (approval + 2-attempt
transient budget, so a second code gets its own approval and budget),
approval dialog, `POST /api/auth/desktop-login-codes/redeem` with the
raw Firebase ID token (backend route is Firebase-JWT-only), 10s fetch
timeout.
- Triggers: `router.afterEach` (the cloud auth guard settles Firebase
init before navigations complete) plus a lazy watcher on
`authStore.currentUser` for sessions that appear without a navigation
(OAuth-resume error branch, dialog sign-in). One bounded in-page retry
(5s) guarantees an approved sign-in always ends in a success or failure
toast.
- Terminal rejections (400/403/404/409/410) drop the code with an error
toast; transient failures (401/5xx/timeout/network) retry once; budget
exhaustion now surfaces a failure toast instead of dying silently.

## Why

Windows stitches web→desktop at download time via installer stamping;
macOS DMGs can't be stamped, so we stitch at login. The browser is where
both halves meet: the existing `posthog.identify(uid)` merges the web
anon person into the Firebase uid, and the backend emits
`comfy.cloud.identity.login_attributed` (uid ↔ installation_id) at
redeem. The desktop app polls the backend and receives a one-time custom
token — no auth material posted to a desktop loopback server (the
concern that stalled #12983, which this supersedes).

## Security

- **Approval dialog before redeem** — redemption mints the desktop a
sign-in token for *your* account, so a lured click must not be enough
(device-code phishing mitigation). Cancel clears the stash and does
nothing.
- Only the opaque single-use code ever appears in a URL; the tracker
strips it on first sight, pre-navigation, and it is never logged.

## Testing

Vitest, driven through a real router (createRouter/createMemoryHistory,
no vue-router mocks) and the real preserved-query manager: capture/stash
lifecycle, approval gate (no fetch before approve; decline/dismiss
clears; per-code approval), Bearer/body shape, terminal-vs-transient
statuses, timeout abort, bounded in-page retry + failure toast on budget
exhaustion, per-code regressions (second code after
success/decline/exhaustion redeems independently), auth-watcher trigger
(session appearing without navigation), unauthenticated no-op, trigger
coalescing. Typecheck/lint/format clean.

Types are hand-written with a `TODO(@comfyorg/ingest-types)` — the
generated types land automatically once the cloud PR merges and the
type-gen workflow runs.

## Landing order

1. Cloud backend: https://github.com/Comfy-Org/cloud/pull/4736 (until it
ships, redemption never triggers — this PR is inert)
2. #13465 preserved-query strip-on-capture (base of this PR)
3. #13466 global-prompt FIFO queue (runtime dependency: the approval
confirm must settle even if another prompt is open)
4. **This PR**
5. Desktop (activates the flow):
https://github.com/Comfy-Org/Comfy-Desktop/pull/1222

GTM-93 · Supersedes #12983

---------

Co-authored-by: AustinMroz <austin@comfy.org>
2026-07-10 02:11:00 +00:00
Matt Miller
2ef341dcd8 test: E2E for BYOK secret add / list / delete flow (#13510)
## ELI-5

The settings screen now has a "Secrets" panel where you can save API
keys for
model/AI providers. This adds an end-to-end test that plays out the
whole story
like a real user: open the panel, add a key, watch it show up in the
list, then
delete it. It also checks the security promise — the key you type is
sent to the
server but is **never** shown back to you afterward — and that an
account without
access to the gated providers never even sees them in the dropdown.

## What

Adds `browser_tests/tests/cloudSecrets.spec.ts`, a Playwright spec
covering the
secrets (API keys) surface in the cloud app:

- **Entitled account, full CRUD round-trip:** empty state -> add a
provider key
(pick provider, name, secret value, save) -> the key appears in the list
->
  delete it via the confirm dialog -> back to empty state.
- **Secret value is write-only:** asserts the create request carried the
plaintext
value, but the value is never echoed back into the DOM (the
list-response schema
  is metadata-only).
- **Entitlement gate:** an account whose provider allowlist is empty
never sees
  the gated providers anywhere in the add dialog.

Follows the existing cloud E2E conventions: drives a raw `page` and
reuses the
`mockCloudBoot` / `bootCloud` helpers so the app boots signed-in against
fully
mocked endpoints. A small stateful in-memory handler backs the secrets
endpoints
(list / create / delete + the provider allowlist) so the flow is
deterministic
and never touches a real backend.

## Why

Verification capstone for the secrets settings surface — proves the add
/ list /
delete flow works against the documented API behavior
(`GET`/`POST`/`DELETE` on
the secrets collection, `GET` on the provider allowlist) and locks in
the two
contracts that matter: the secret value is never returned after
creation, and the
provider allowlist is the only thing that surfaces gated providers to
the user.

## Tests

- `browser_tests/tests/cloudSecrets.spec.ts` — new, two cases (tagged
`@cloud`).
- Static checks pass locally: oxlint (0 warnings/errors) and oxfmt
formatting.
- The browser run itself needs a served app + the E2E harness (CI), so
it was not
executed in this environment; the spec is self-contained and mocks all
network.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 21:20:03 +00:00
Mobeen Abdullah
1815c7f7a4 feat(website): add JSON-LD structured data across the site (#13480)
## Summary

This PR adds schema.org **JSON-LD structured data across the whole
marketing site**, built from one shared, CMS-ready module and gated by a
small CI validator. It replaces the old global block (which had a stale
logo, wrong social links, disconnected nodes, and a head slot that
rendered three times) with a single connected `@graph` on every page.
Structured data only — there is no visual or runtime change for users.
Tracks Linear **FE-1170**.

The design goal was that structured data should be impossible to get
subtly wrong: one place builds it, honesty rules are enforced in code,
and a build-time validator fails the build if any page ships a broken
`@id` graph or a fabricated price/rating.

## Changes

- **One builder, one sink.** `utils/jsonLd.ts` holds pure, node-testable
builders; `components/common/JsonLdGraph.astro` is the single escaped
`<script type="application/ld+json">` sink (prevents `</script>`
breakout XSS).
- **The layout owns the page entity.** `BaseLayout` emits a baseline
`Organization` + `WebSite` + `WebPage` graph on every page from its own
`title`/`description`/canonical props. Enriched pages pass only what is
specific to them: `pageType`, `breadcrumbs`, `mainEntityId`, and
`extraJsonLd` nodes. This makes it impossible for a page's meta tags and
its structured data to drift apart.
- **Corrected site-wide entity.** Raster PNG logo (Google does not index
SVG logos), real `sameAs` handles sourced from the footer links,
`@id`-linked `Organization`/`WebSite`, and the triple-rendered head slot
fixed.
- **Honesty is enforced, not just intended.** No fabricated
`Review`/`AggregateRating`/`Offer`. Pricing offers are parsed only from
plain `$N` copy (a future "Contact us" drops the offer instead of
shipping a garbage price). Third-party node packs and listed models do
**not** claim Comfy Org as author/publisher. `noindex` pages (404,
payment) emit no structured data.
- **CI validator.** `pnpm --filter @comfyorg/website validate:jsonld`
runs over `dist/` in the website build workflow and fails on invalid
JSON, an unresolved `@id`, a fake rating, or an empty/non-numeric offer
price.
- **Breaking:** none.

## Coverage (also a manual QA checklist for the preview)

Every public page carries at least `Organization` + `WebSite` +
`WebPage`. The pages below add a page-specific primary entity, in
**English and zh-CN**:

| Page | Path (example) | Adds to the graph |
|---|---|---|
| Home | `/`, `/zh-CN` | `SoftwareApplication` (ComfyUI, free) +
`SoftwareSourceCode` |
| Download | `/download` | `SoftwareApplication` (ComfyUI desktop) |
| Pricing | `/cloud/pricing` | `Product` + 3 monthly `Offer`s
($20/$35/$100) + Breadcrumb |
| Models catalog | `/p/supported-models` | `CollectionPage` + `ItemList`
(313, lean) + Breadcrumb |
| Model detail | `/p/supported-models/4x-ultrasharp` |
`SoftwareApplication` + `FAQPage` + Breadcrumb |
| Nodes catalog | `/cloud/supported-nodes` | `CollectionPage` +
`ItemList` (58 packs) + Breadcrumb |
| Node-pack detail | `/cloud/supported-nodes/ComfyQR` |
`SoftwareApplication` (+ free `Offer`) + Breadcrumb |
| Demos | `/demos/community-workflows` | `LearningResource` + Breadcrumb
|
| About / Contact | `/about`, `/contact` | `AboutPage` / `ContactPage`
(Org as `mainEntity`) + Breadcrumb |
| Careers | `/careers` | `CollectionPage` + `ItemList` of open roles +
Breadcrumb |
| Affiliates | `/affiliates` | `FAQPage` + Breadcrumb |

Pages deliberately left at the baseline `WebPage` (generic landings,
legal, coming-soon) and pages with **no** structured data (`noindex`:
`/404`, `/payment/*`; redirect URLs) are intentional.

## Review Focus

- **Layout-owns-WebPage design.** `BaseLayout` builds the `WebPage`;
pages contribute only extra nodes. This is the main structural decision
and is what removes meta-vs-schema drift by construction.
- **Honesty guardrails.** Worth confirming: pricing offers, third-party
author omission on packs/models, and that `noindex` pages emit nothing.
- **`@id` and URL consistency.** All cross-page links and `@id`s resolve
to the canonical trailing-slash form; zh-CN breadcrumbs are rooted under
`/zh-CN`; the singleton `WebSite`/`#software` entities carry one
consistent definition across pages and locales.
- **The validator.** It is a bespoke ~100-line script scoped to the
website build job (not the prod deploy). Happy to make it non-blocking
or drop it if the team prefers.
- **Coordination with #13468.** Both branches add
`components/common/JsonLdGraph.astro`. Customer pages are intentionally
excluded here; #13468 can converge onto this shared builder.

## Verification

`astro check` 0 errors · 166 unit tests · `knip` 0 · `eslint` 0 · build
497 pages · validator passes across 500 pages · JSON-LD e2e specs 24/24.
(The 3 pre-existing demo e2e timeouts are an external Arcade-embed flake
on one slug, reproduced identically on `main`.)

## Screenshots

Not applicable — head-only structured data, no visual change. Validate
on the Vercel preview with the Rich Results Test and the schema.org
validator. Note: `@id`/URL values render as `comfy.org` (from
`astro.config` `site`) even on the preview host, which is correct.
2026-07-09 21:03:43 +00:00
imick-io
287b9eb980 feat(website): add HeroBackdrop01 block component (#13549)
## Summary

Adds the `HeroBackdrop01` hero block component to the website app. This
lands the component on its own so it can be merged to `main` ahead of
the EDU page work (on another branch) that consumes it.

The component renders a responsive hero with an optional image/video
backdrop (in-flow rounded card on mobile, full-bleed background on
desktop), an optional product badge, title, subtitle, and footnote. It
respects `prefers-reduced-motion` by not autoplaying the looping
backdrop video (WCAG 2.2.2).

## Notes

- No consumer imports the component yet — it is intentionally added
ahead of the page that will use it. The `knip` unused-file check flags
this, which is expected for this staging PR.
- Depends on existing `useReducedMotion` composable and
`ProductHeroBadge` component, both already present on `main`.

## Test plan

- [ ] `pnpm typecheck:website` passes (verified locally via pre-commit
hook)
- [ ] Component renders correctly once wired into a page

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:43:49 +00:00
Matt Miller
06b0471257 test: pin unknown-provider flow-through in server-driven provider options (#13546)
## ELI-5

The provider picker for BYOK secrets is now driven by the server's
`availableProviders` list, not a hardcoded frontend list. The point is
that a provider the server offers but the frontend has never heard of
should still show up — using its raw id as the label and no logo. This
adds the one test that actually pins that behavior, so nobody can later
re-add a "only show providers the frontend knows about" filter without a
test going red.

## Summary

Regression test for the server-driven provider options introduced in
#13509: asserts that an unknown provider id passes through
`providerOptions` rather than being filtered against the local
presentational registry.

## Changes

- **What**: Adds one `useSecretForm.test.ts` case asserting that a
create-mode `availableProviders` list containing an id absent from the
local `SECRET_PROVIDERS` registry (`'brand-new-provider'`) yields a
single `providerOptions` entry rendered with the raw id as its label and
`logo: undefined`.

## Review Focus

The existing suite already covers the registry fallback
(`providers.test.ts`) and server-listed *known* ids (`runway`/`gemini`),
but every one of those cases uses an id that exists in the local
registry — so a future change that re-filtered `availableProviders`
against `SECRET_PROVIDERS` could pass all current tests while silently
dropping unknown providers. This test closes that gap by using an id
that is deliberately absent from the registry, so it fails if
pass-through is ever broken. Follow-up to the optional review ask on
#13509. Test-only; no production code changes.
2026-07-09 19:20:56 +00:00
Maanil Verma
8120142f49 feat(cloud): redesign the cloud onboarding survey (#13518)
## Summary

New users get a cleaner, faster onboarding survey: one question per
screen on tappable cards that advance the moment you pick an answer,
with follow-up questions that appear only when they're relevant. The
questions themselves were reworked to learn what people actually want to
do with ComfyUI.

## Changes

**What**

- Replaced the radio/checkbox list with a card-based,
one-question-at-a-time wizard. Choosing a single-select answer advances
automatically — no separate Next click — while multi-select and
free-text steps still wait for you to confirm.
- Reworked the question set: what you want to make, how well you know
ComfyUI, and how you found us. Two questions are now conditional — a
"what are you building?" follow-up appears only for workflow/API
builders, and a "which platform?" follow-up appears only when you say
you found us on social media.
- Added an "other" free-text escape hatch to the intent and source
questions, required before you can move on so we don't capture an empty
"other".
- Polished the whole surface to the comfy-canvas theme with animated
step-height and cross-fade transitions between questions, and hid the
marketing hero on the survey and user-check routes so the form has room.
- Errors now surface only after you've interacted with a field, not on
first paint.
- Extended the telemetry survey-response and remote-config option shapes
to carry the new fields (including per-option icons), leaving the older
fields in place so historical responses still typecheck.

**Breaking** — None. The remote-config survey schema stays
backend-overridable, hidden branch answers are zeroed in the submitted
payload, and the telemetry field names line up 1:1 with the schema. The
backend dynamic config already ships the matching version-3 schema.


## Testing

Behavioral coverage over the wizard's real interactions rather than DOM
structure, since the risk is in navigation/branching/validation, not
markup. `vue-i18n` is mounted for real with the actual locale file so
tests assert on rendered copy.

-
[DynamicSurveyForm.test.ts](src/platform/cloud/onboarding/survey/DynamicSurveyForm.test.ts)
— auto-advance on single-select, no-advance on multi/other, Back
navigation, branch reveal/hide and its submitted payload,
required-"other" gating, post-interaction error surfacing, and
survey-prop reset.
-
[DynamicSurveyField.test.ts](src/platform/cloud/onboarding/survey/DynamicSurveyField.test.ts)
— card rendering/selection state, stable option ids, multi-select emit,
the conditional "other" input, and label resolution via key / locale map
/ id fallback.
-
[surveySchema.test.ts](src/platform/cloud/onboarding/survey/surveySchema.test.ts)
— default-schema branching (which steps show), hidden-field zeroing and
free-text-over-sentinel in the payload, and the shared
`hasNonEmptyValue` truth table.

Gates: `vue-tsc` typecheck clean; eslint/oxfmt clean on touched files;
survey suite green (68 tests across the three files). Manual: run the
cloud onboarding flow, pick a workflow/apps intent to confirm the
"building" follow-up, pick social to confirm the platform follow-up, and
verify an empty "other" blocks advancing.

## Screen Recording



https://github.com/user-attachments/assets/1908ca18-93d1-41a2-a55b-1f04a6df2268
2026-07-09 18:31:52 +00:00
AustinMroz
3164e6ab61 On workflow swap, restore 'Preview as Text' text (#13536)
Also adds proper typing for `onNodeOutputsUpdated`

See also: #12877 and #13427, which include near equivalent changes for
the bug itself, but different tests. If I had more time and had not
already made my own fix, I would have liked to spend more time getting
either of them cleaned up.
2026-07-09 17:03:19 +00:00
pythongosssss
731512c655 test: remove timeout causing flake (#13543)
## Summary

The timeout resolved 1 second after the test completed sometimes
throwing:
```
⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯
ReferenceError: window is not defined
 ❯ resolveMessageFormat node_modules/.pnpm/@intlify+core-base@9.14.5/node_modules/@intlify/core-base/dist/core-base.mjs:1357:13
 ❯ translate node_modules/.pnpm/@intlify+core-base@9.14.5/node_modules/@intlify/core-base/dist/core-base.mjs:1216:11
 ❯ node_modules/.pnpm/vue-i18n@9.14.5_vue@3.5.34_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:581:48
 ❯ wrapWithDeps node_modules/.pnpm/vue-i18n@9.14.5_vue@3.5.34_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:526:19
 ❯ t node_modules/.pnpm/vue-i18n@9.14.5_vue@3.5.34_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:581:16
 ❯ onNodePackChange src/workbench/extensions/manager/components/manager/PackVersionSelectorPopover.vue:201:10
    199|   // Add Latest option with actual version number
    200|   const latestLabel = latestVersionNumber
    201|     ? `${t('manager.latestVersion')} (${latestVersionNumber})`
       |          ^

    202|     : t('manager.latestVersion')
    203|

This error originated in "src/workbench/extensions/manager/components/manager/PackVersionSelectorPopover.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.
```

The timeout was not required for the test to validate the loading text.

## Changes

- **What**: Remove timeout
2026-07-09 16:27:28 +00:00
imick-io
c0ad1e98c2 test(website): add e2e specs for the learning page (#13529)
## Summary

Add end-to-end test coverage for the `/learning` page, which previously
had none.

## Changes

- **What**: New `e2e/learning.spec.ts` covering the EN page smoke
behaviour (hero, featured workflow, tutorial grid rendered from the
`learningTutorials` data source, per-tutorial Try Workflow links, and
the contact-sales CTA), the tutorial video dialog open/close/Escape
interactions, and the zh-CN localized page.

## Review Focus

Assertions are driven off the `learningTutorials` data source and `t()`
i18n keys rather than hardcoded strings to avoid change-detector tests.
Media is stubbed by the auto-applied `blockExternalMedia` fixture, so
the specs have no network dependency. The tutorial-dialog interaction
uses a `toPass` retry to accommodate `client:visible` hydration.
2026-07-09 16:23:07 +00:00
Comfy Org PR Bot
bd9fab2d2f 1.48.0 (#13541)
Minor version increment to 1.48.0

**Base branch:** `main`

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-07-09 07:21:27 +00:00
Comfy Org PR Bot
c7fe6a23ec 1.47.7 (#13246)
Patch version increment to 1.47.7

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-07-09 05:47:17 +00:00
nav-tej
df9b5bfa0a feat(website): rework MCP page setup + hero, hide promo banner on /mcp (#13514)
## Summary

Two changes to the comfy.org `/mcp` experience: reframe the Setup
section around the agent-driven install and tidy the hero CTAs, and stop
the sitewide MCP promo banner from showing on the page it links to.

## Changes

- **Setup Step 1** is now **"Ask your agent to install Comfy MCP"** with
a multi-line, copyable prompt (`Help me install Comfy MCP. / Follow the
setup guide at https://docs.comfy.org/agent-tools/cloud`).
`CopyableField` gains a `multiline` variant (wraps the text, top-aligns
the copy button); `FeatureGrid01`'s `code` action threads the flag
through.
- **Step 2** becomes the optional manual-connector path (**"Or add it by
hand"**) so the three-step flow stays coherent — no dangling "paste the
URL" that Step 1 no longer copies.
- **Hero** swaps the **"Run a workflow"** primary CTA for **"Install
MCP"**, which anchors to the on-page `#setup` steps; **"View Docs"**
stays as the secondary CTA.
- **Announcement banner** no longer advertises the page you're already
on. `evaluateBannerVisibility` gains a build-time gate: when the current
path matches the banner's CTA destination it's suppressed. Locale prefix
is stripped (so `/zh-CN/mcp` matches an unprefixed `/mcp` href),
trailing slashes are tolerated, and external CTA links never suppress.
Result: the MCP banner shows everywhere except `/mcp` and `/zh-CN/mcp`.
- New i18n keys (`mcp.setup.step1.command`, `mcp.hero.installMcp`) with
en + zh-CN parity; 5 new unit tests cover the banner suppression logic.
- **Breaking**: none.

## Review Focus

- @deepme987 @bertfy — copy + flow check. The Step 1 prompt is a
paraphrase of the `agent-tools/cloud` install guide. The raw
`cloud.comfy.org/mcp` URL is no longer surfaced on the page (the manual
path now points to the MCP docs instead) — flag if you'd rather keep the
raw URL visible in Step 2.
- Banner suppression keys off the CTA's `link.href` — general, so any
future banner pointing at an internal page auto-hides on that page. It's
a **build-time** gate (this is a static site), consistent with the
existing `startsAt`/`endsAt` behavior.
- Only the **hero** CTA changed. The "How it works" section deliberately
keeps its "Run a workflow" CTA.
- zh-CN strings are machine-drafted; a native check would be welcome.

## Screenshots

Verified locally against a production build (`astro build` + preview):

- **Hero** — `INSTALL MCP` + `VIEW DOCS` (the "Run a workflow" button is
gone). "Install MCP" scrolls to `#setup`.
- **Setup** — Step 1 shows the agent-install prompt in a multi-line
copyable field; Step 2 "Or add it by hand"; Step 3 unchanged.
- **Banner** — present on `/`, `/download`, `/cloud`; absent on `/mcp`
and `/zh-CN/mcp`.

A Vercel preview deploy attaches automatically for a live view.

---------

Co-authored-by: imick-io <153135517+imick-io@users.noreply.github.com>
2026-07-09 04:11:23 +00:00
Yousef R. Gamaleldin
a6b7ce11aa feat: add preview support for save text node (CORE:-176) (#12521)
## Summary

Add a frontend preview extension for the SaveText node that displays
saved text content after execution.

## Changes

The extension add a multiline text widget into the SaveText node and
populates it upon onExecuted
PR on core: https://github.com/Comfy-Org/ComfyUI/pull/14102

## Review Focus

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

Extension follows the same pattern as previewAny.ts

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

## Screenshots
<img width="1127" height="421" alt="Screenshot 2026-05-29 194925"
src="https://github.com/user-attachments/assets/e7a72807-858b-47c7-be07-595f9e539a49"
/>

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

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-07-08 23:27:32 -04:00
Matt Miller
d3b100be8d feat: render BYOK provider surface (labels / logos / help) from server data (#13509)
## ELI-5

The "API Keys & Secrets" settings screen lets you save a key for a
provider (HuggingFace, Civitai, and now video/image API providers). The
list of which providers you can pick is decided by the server. This PR
makes the picker and the saved-keys list show a nice name, logo, and
short help text for each provider the server offers — and, crucially,
actually render providers the server newly lists instead of silently
dropping them.

## What

- The provider dropdown in the add-secret dialog is now driven by the
providers the server returns from `GET /secrets/providers`. Each id is
mapped to its display label, logo, and optional help text through a
small presentational registry.
- Previously the dropdown took a hardcoded known-provider array and
*intersected* it with the server list, so any provider the server listed
that wasn't already hardcoded could never appear. That intersection is
gone: once the server list loads, it renders verbatim.
- Unknown provider ids fall back gracefully to the raw id with no logo,
so adding a provider server-side requires no frontend change; giving it
a first-class label/logo is an optional enhancement.
- The saved-keys list already resolved label/logo through the same
registry, so it picks up the new providers automatically.
- Added provider-specific help text under the picker (falls back to the
generic hint), plus placeholder logo assets under
`public/assets/images/` for the two new API providers.

## Why

Keeps the provider surface data-driven end to end: the server owns
*which* providers are configurable, and the frontend owns *how* each one
renders. This removes the last hardcoded gate that stopped server-listed
providers from showing up.

## Tests

- New `providers.test.ts`: label/logo/help lookups for all known
providers, graceful fallback for unknown ids and `undefined`, and that
the not-loaded fallback list stays the pre-existing baseline (does not
silently include the new providers).
- Extended `useSecretForm.test.ts`: server-listed providers render with
correct labels + logos; providers the server omits do not appear;
provider-specific vs generic help text selection.
- Full secrets suite green (66 tests). Changed files typecheck clean.

Note: the two new provider logos are simple placeholder SVGs and can be
swapped for final brand assets.

---------

Co-authored-by: GitHub Action <action@github.com>
2026-07-09 03:07:47 +00:00
Hunter
54b0c10148 fix(auth): stop workspace auth from oscillating to personal identity (#13511)
## Summary

Fixes a "weird stale auth" bug where cloud requests oscillated between
workspace-scoped and personal (Firebase) identity.

**Root cause:** workspace membership lives in two decoupled places —
`teamWorkspaceStore.activeWorkspaceId` (durable intent) and
`workspaceAuthStore` (the mintable token). When the token was
transiently missing while `activeWorkspaceId` was still set (bootstrap
mint in flight, expired token, or a context cleared by a recoverable
refresh failure), `getAuthHeader`/`getAuthToken` silently downgraded to
the personal Firebase token. Depending on timing, consecutive requests
carried different identities, so the backend saw the user flip between
workspace and personal scope.

## Changes

- **On-demand recovery, fail closed:** when a workspace is active,
`getAuthHeader`/`getAuthToken` route through
`ensureWorkspaceToken(activeWorkspaceId)`, which re-mints the token on
demand and returns `null` rather than downgrading. Recovery also
revalidates expiry, so an expired token is reminted instead of sent
stale.
- **`getAuthToken` parity:** WebSocket/queue auth now recovers the same
way (previously only `getAuthHeader` did).
- **Coalescing:** a burst of callers collapses onto a single in-flight
mint (loop re-checks the in-flight promise), and only a token minted for
the requested workspace is accepted.
- **Backoff:** a 5s cooldown after any failed/empty recovery prevents
hammering `POST /auth/token`; reset on a successful mint and on context
teardown.
- **Lifecycle hygiene:** `clearWorkspaceContext()` now resets
`recoveryCooldownUntil` and `inFlightSwitchPromise` so logout/re-login
without a reload isn't wedged.
- **Transient vs permanent:** a missing Firebase ID token while the user
is still signed in (e.g. `NETWORK_REQUEST_FAILED`, which `getIdToken()`
swallows) is treated as transient, not a revoked session.
- **Revoked-workspace reconciliation:** on
`ACCESS_DENIED`/`WORKSPACE_NOT_FOUND`,
`teamWorkspaceStore.forgetRevokedActiveWorkspace()` drops the persisted
selection and reloads to fall back to the personal workspace (skipping
the personal workspace itself to avoid reload loops).
`INVALID_FIREBASE_TOKEN`/`NOT_AUTHENTICATED` do not trigger this.

## Testing

- `pnpm test:unit` for the three affected stores: **210 tests pass**.
- `pnpm lint` and `pnpm typecheck` pass locally (run with a raised Node
heap; the pre-commit/`pnpm typecheck` step OOMs in this environment, so
commits used `--no-verify` — CI should re-run the gates).

Draft pending green CI.

---------

Co-authored-by: GitHub Action <action@github.com>
2026-07-09 02:10:13 +00:00
jaeone94
2b540a5281 fix: resolve cloud output video missing-media false positives (#13507)
## Summary

Fix Cloud missing-media false positives for output videos inserted as
loader nodes when the widget value includes a subfoldered output path
such as `video/<hash>.mp4 [output]`, while Cloud output assets expose
the generated media by a flat hash.

## Changes

- **What**: resolve Cloud output candidates with subfolders against
output asset hashes by falling back from the normalized candidate
basename only for Cloud output media.
- **What**: keep that fallback hash-only so unrelated flat output asset
names do not satisfy subfoldered candidates.
- **What**: make Cloud output pagination completion hash-aware so a
colliding `asset.name` does not stop loading before the hash match
appears.
- **What**: add unit regressions plus a small Cloud E2E fixture covering
`LoadVideo` with `video/cloud-video-hash.mp4 [output]`.
- No breaking changes or dependency changes.

## Review Focus

Root cause: Cloud output videos can be inserted as loader nodes with
widget values from workflow metadata that include a media subfolder,
e.g. `video/<hash>.mp4 [output]`. The Cloud output asset list resolves
generated media by a flat hash. The existing missing-media scan compared
the subfoldered candidate against flat asset identifiers, so valid
generated output media could be flagged as missing. Images often did not
reproduce because their metadata commonly lacked the subfolder, so the
existing exact/compact matching happened to work.

Why the fix is scoped to missing-media scan: the inserted widget value
is valid node/workflow state and may carry folder information for other
Cloud/runtime paths. Stripping the subfolder at insertion would broaden
the behavioral change to asset insertion and loader widgets.
Missing-media scan owns the decision of whether a candidate resolves to
an existing Cloud asset, so the smallest production change is to
recognize the Cloud output hash shape there.

Why this is safe for Cloud: the basename fallback is applied only for
candidates annotated as output and only in Cloud. It matches against
output asset hashes, not flat asset names, which prevents unrelated
assets named `<hash>.mp4` from masking a real miss. Input media and
non-Cloud exact path behavior continue to use the existing identifier
matching. Cloud output pagination early-exit is also hash-aware now, so
a flat name collision cannot stop paging before the real hash match is
fetched.

Red-green and validation: added the unit regression first to reproduce
`video/<hash>.mp4 [output]` as missing before the production fix, then
verified it green after the scanner/resolver change. Added a compact
Cloud E2E for the same `LoadVideo` subfoldered output case. Local
validation run:

- `pnpm test:unit src/platform/missingMedia/missingMediaScan.test.ts
src/platform/missingMedia/missingMediaAssetResolver.test.ts` - 59 passed
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5175
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm exec playwright test
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
--project=cloud --grep "subfoldered output video" --repeat-each=5
--reporter=line` - 5 passed
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5175
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm exec playwright test
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
--project=cloud --grep "resolves compact annotated output media|resolves
subfoldered output video" --reporter=line` - 2 passed
- `pnpm typecheck`
- `pnpm typecheck:browser`
- `pnpm knip`
- `pnpm lint`
- staged pre-commit hooks: oxfmt, oxlint, eslint, typecheck,
typecheck:browser

## Screenshots (if applicable)

Before


https://github.com/user-attachments/assets/4d980546-a981-4764-9c81-4eaed69e4679


After


https://github.com/user-attachments/assets/6a4ddb26-6c16-4cbf-b7bc-b9cd55eece58
2026-07-09 00:31:25 +00:00
ShihChi Huang
51156c5503 chore: route CodeRabbit test reviews through guidance docs (#13486)
## Summary

Adds CodeRabbit `path_instructions` so changed test files are reviewed
with the repo's existing test guidance docs.

## Changes

- **What**: Routes Vitest `**/*.test.ts` files to
`.agents/checks/test-quality.md`, `docs/testing/README.md`, and
`docs/guidance/vitest.md`.
- **What**: Routes Playwright
`{browser_tests,apps/website/e2e}/**/*.spec.ts` files to
`.agents/checks/test-quality.md`, `docs/testing/README.md`, and
`docs/guidance/playwright.md`.
- **What**: Routes LiteGraph Vitest files to the same Vitest docs plus
`docs/testing/litegraph-testing.md`, which now only documents shared
factory usage and preferring real LiteGraph instances where practical.

## Review Focus

Confirm the path globs cover the intended test files and that
LiteGraph-specific review guidance stays scoped to LiteGraph tests.

## Testing

- Parsed `.coderabbit.yaml` with Ruby YAML
- Ran `git diff --check`
- Commit hooks ran `oxfmt`, `oxlint`, `eslint`, and `pnpm typecheck`
- Push hook ran `knip --cache`

Created by Codex

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Documentation and CodeRabbit review configuration only; no runtime or
test execution behavior changes.
> 
> **Overview**
> Configures **CodeRabbit `path_instructions`** so automated reviews of
changed tests pull in the repo’s written testing standards instead of
generic heuristics.
> 
> **Vitest** (`**/*.test.ts`) reviews must treat
`.agents/checks/test-quality.md`, `docs/testing/README.md`, and
`docs/guidance/vitest.md` as required context. **LiteGraph Vitest**
under `src/lib/litegraph/**/*.test.ts` adds
`docs/testing/litegraph-testing.md`. **Playwright** specs under
`browser_tests/` and `apps/website/e2e/` use the test-quality and
testing README docs plus `docs/guidance/playwright.md`.
> 
> The testing index now lists a fourth guide, **LiteGraph Testing**, and
the new `litegraph-testing.md` doc steers authors toward
`litegraphTestUtils` factories and real LiteGraph instances over broad
mocks.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
efa11d080e. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-09 00:03:11 +00:00
218 changed files with 11803 additions and 6917 deletions

View File

@@ -63,3 +63,14 @@ reviews:
Pass if none of these patterns are found in the diff.
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
path_instructions:
- path: '**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
- path: 'src/lib/litegraph/**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, `docs/guidance/vitest.md`, and `docs/testing/litegraph-testing.md` as required review context for every changed litegraph Vitest test file.
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/playwright.md` as required review context for every changed Playwright test file.

View File

@@ -40,3 +40,6 @@ jobs:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: pnpm --filter @comfyorg/website build
- name: Validate JSON-LD structured data
run: pnpm --filter @comfyorg/website validate:jsonld

View File

@@ -76,10 +76,14 @@ test.describe('Affiliates landing — desktop interactions', () => {
return match?.textContent ?? null
})
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
const parsed = JSON.parse(faqJsonLd!)
expect(parsed['@type']).toBe('FAQPage')
expect(Array.isArray(parsed.mainEntity)).toBe(true)
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
const graph = JSON.parse(faqJsonLd!)['@graph'] as {
'@type': string
mainEntity?: unknown[]
}[]
const faqPage = graph.find((node) => node['@type'] === 'FAQPage')
expect(faqPage, 'FAQPage node in @graph').toBeDefined()
expect(Array.isArray(faqPage!.mainEntity)).toBe(true)
expect(faqPage!.mainEntity!.length).toBe(FAQ_COUNT)
})
test('Apply Now CTA opens the application form in a new tab', async ({

View File

@@ -0,0 +1,158 @@
import { expect } from '@playwright/test'
import { learningTutorials } from '../src/data/learningTutorials'
import { t } from '../src/i18n/translations'
import { test } from './fixtures/blockExternalMedia'
const tutorialButtonName = (title: string, locale: 'en' | 'zh-CN') =>
`${t('learning.tutorials.titlePrefix', locale)} ${title}`
test.describe('Learning page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/learning')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Learning — Comfy')
})
test('hero headline references ComfyUI', async ({ page }) => {
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
await expect(heading).toContainText(t('learning.heroTitle.before', 'en'))
await expect(heading).toContainText('ComfyUI')
await expect(heading).toContainText(t('learning.heroTitle.line2', 'en'))
})
test('featured workflow section shows title and author', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.featured.title', 'en'),
level: 2
})
).toBeVisible()
await expect(
page.getByText(t('learning.featured.author', 'en'))
).toBeVisible()
})
test('renders every tutorial from the data source', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.tutorials.heading', 'en'),
level: 2
})
).toBeVisible()
for (const tutorial of learningTutorials) {
await expect(
page.getByRole('button', {
name: tutorialButtonName(tutorial.title.en, 'en')
})
).toBeVisible()
}
})
test('tutorials with a workflow link expose an external Try Workflow link', async ({
page
}) => {
const linkedTutorials = learningTutorials.filter(
(tutorial) => tutorial.href
)
const workflowLinks = page.getByRole('link', {
name: t('cta.tryWorkflow', 'en')
})
const hrefs = await workflowLinks.evaluateAll((links) =>
links.map((link) => link.getAttribute('href'))
)
for (const tutorial of linkedTutorials) {
expect(hrefs).toContain(tutorial.href)
}
})
test('call to action links to contact sales', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.cta.heading', 'en'),
level: 2
})
).toBeVisible()
await expect(
page.getByRole('link', { name: t('learning.cta.contactSales', 'en') })
).toHaveAttribute('href', '/contact')
})
})
test.describe('Learning tutorial dialog', () => {
test('opens a tutorial video and dismisses via the close button', async ({
page
}) => {
const [firstTutorial] = learningTutorials
await page.goto('/learning')
const openButton = page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title.en, 'en')
})
await openButton.scrollIntoViewIfNeeded()
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
// TutorialsSection is hydrated via `client:visible`; retry the click until
// Vue responds by opening the dialog.
await expect(async () => {
await openButton.click()
await expect(dialog).toBeVisible({ timeout: 1_000 })
}).toPass({ timeout: 10_000 })
await expect(
dialog.getByRole('heading', { level: 2, name: firstTutorial.title.en })
).toBeVisible()
await dialog
.getByRole('button', { name: t('gallery.detail.close', 'en') })
.click()
await expect(dialog).toBeHidden()
})
test('dismisses the dialog with the Escape key', async ({ page }) => {
const [firstTutorial] = learningTutorials
await page.goto('/learning')
const openButton = page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title.en, 'en')
})
await openButton.scrollIntoViewIfNeeded()
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
await expect(async () => {
await openButton.click()
await expect(dialog).toBeVisible({ timeout: 1_000 })
}).toPass({ timeout: 10_000 })
await page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
})
})
test.describe('Learning page (zh-CN) @smoke', () => {
test('renders localized title, headings, and tutorials', async ({ page }) => {
await page.goto('/zh-CN/learning')
await expect(page).toHaveTitle('学习 — Comfy')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
/[一-鿿]/
)
await expect(
page.getByRole('heading', {
name: t('learning.tutorials.heading', 'zh-CN'),
level: 2
})
).toBeVisible()
const [firstTutorial] = learningTutorials
await expect(
page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title['zh-CN'], 'zh-CN')
})
).toBeVisible()
})
})

View File

@@ -17,7 +17,8 @@
"test:visual:update": "playwright test --project visual --update-snapshots",
"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"
"generate:models": "tsx ./scripts/generate-models.ts",
"validate:jsonld": "tsx ./scripts/validate-jsonld.ts"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",

View File

@@ -0,0 +1,129 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { collectGraphIds } from '../src/utils/jsonLd'
const DIST_DIR = join(process.cwd(), 'dist')
const JSON_LD_BLOCK =
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
interface Violation {
file: string
message: string
}
function htmlFiles(dir: string): string[] {
return readdirSync(dir, { recursive: true })
.map(String)
.filter((entry) => entry.endsWith('.html'))
.map((entry) => join(dir, entry))
}
function typesOf(node: Record<string, unknown>): string[] {
const type = node['@type']
if (typeof type === 'string') return [type]
if (Array.isArray(type)) {
return type.filter((t): t is string => typeof t === 'string')
}
return []
}
function hasValidPrice(node: Record<string, unknown>): boolean {
const price = node.price
const priceStr = price == null ? '' : String(price).trim()
return priceStr !== '' && !Number.isNaN(Number(priceStr))
}
function checkHonesty(
value: unknown,
file: string,
violations: Violation[]
): void {
const walk = (node: unknown): void => {
if (Array.isArray(node)) {
node.forEach(walk)
return
}
if (!node || typeof node !== 'object') return
const record = node as Record<string, unknown>
const types = typesOf(record)
if (types.includes('Review') || types.includes('AggregateRating')) {
violations.push({
file,
message: `dishonest node type ${types.join('/')}`
})
}
if ('aggregateRating' in record || 'review' in record) {
violations.push({
file,
message: 'node carries a review/aggregateRating'
})
}
if (
types.includes('Offer') &&
(!hasValidPrice(record) || !record.priceCurrency)
) {
violations.push({
file,
message: 'Offer missing priceCurrency or a concrete price'
})
}
Object.values(record).forEach(walk)
}
walk(value)
}
function validateFile(file: string): Violation[] {
const html = readFileSync(file, 'utf8')
const violations: Violation[] = []
const definedIds = new Set<string>()
const referencedIds: string[] = []
for (const match of html.matchAll(JSON_LD_BLOCK)) {
let parsed: unknown
try {
parsed = JSON.parse(match[1])
} catch (error) {
violations.push({ file, message: `invalid JSON-LD: ${String(error)}` })
continue
}
checkHonesty(parsed, file, violations)
const { defined, references } = collectGraphIds(parsed)
defined.forEach((id) => definedIds.add(id))
referencedIds.push(...references)
}
for (const id of referencedIds) {
if (!definedIds.has(id)) {
violations.push({ file, message: `unresolved @id reference: ${id}` })
}
}
return violations
}
function main(): void {
const files = htmlFiles(DIST_DIR)
if (files.length === 0) {
console.error(
`JSON-LD validation found no HTML in ${DIST_DIR} — build first.`
)
process.exit(1)
}
const violations = files.flatMap(validateFile)
if (violations.length > 0) {
console.error(`JSON-LD validation failed (${violations.length} issue(s)):`)
for (const { file, message } of violations) {
console.error(` ${file.replace(DIST_DIR, 'dist')}: ${message}`)
}
process.exit(1)
}
process.stdout.write(
`JSON-LD validation passed across ${files.length} page(s).\n`
)
}
main()

View File

@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import HeroBackdrop01 from './HeroBackdrop01.vue'
const sampleImage =
'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=1600&q=80'
const meta: Meta<typeof HeroBackdrop01> = {
title: 'Website/Blocks/HeroBackdrop01',
component: HeroBackdrop01,
tags: ['autodocs'],
args: {
backdrop: { type: 'image', src: sampleImage, alt: 'Abstract gradient' },
title: 'Build anything\nwith ComfyUI',
subtitle:
'A powerful, modular visual interface for building and running AI workflows.'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithBadge: Story = {
args: {
badgeText: 'New'
}
}
export const WithFootnote: Story = {
args: {
footnote: 'Available on Windows, macOS, and Linux.'
}
}
export const NoBackdrop: Story = {
args: {
backdrop: undefined
}
}

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
type Backdrop =
| { type: 'image'; src: string; alt?: string }
| { type: 'video'; src: string; poster?: string; alt?: string }
const {
backdrop,
mobileBackdrop,
badgeText,
badgeLogoSrc,
badgeLogoAlt,
title,
subtitle,
footnote,
class: className
} = defineProps<{
backdrop?: Backdrop
mobileBackdrop?: Backdrop
badgeText?: string
badgeLogoSrc?: string
badgeLogoAlt?: string
title: string
subtitle?: string
footnote?: string
class?: HTMLAttributes['class']
}>()
// Respect prefers-reduced-motion: don't autoplay the looping backdrop video
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
const reduceMotion = computed(() => prefersReducedMotion())
// Removing the reactive `autoplay` attribute only suppresses the *initial*
// play; it can't pause a video the browser has already started. That is
// exactly the SSR case: the server renders `autoplay` (it can't read the
// client's motion preference), the browser begins playback on parse, and the
// post-hydration attribute removal is too late. Pause on mount so
// reduced-motion users get the poster frame instead of a looping video.
const pauseIfReduced = (el: unknown) => {
if (el instanceof HTMLVideoElement && reduceMotion.value) el.pause()
}
// On mobile the backdrop is an in-flow rounded card above the content; on
// desktop it is the full-bleed background behind it. A single element serves
// both roles via responsive classes — mobileBackdrop only swaps the source.
const sharedBackdropClass =
'relative aspect-3/2 w-full rounded-3xl object-cover lg:absolute lg:inset-0 lg:aspect-auto lg:size-full lg:rounded-none'
// When both breakpoints use images, serve them from a single responsive <img>
// so the browser fetches only the source matching the viewport. Two
// `hidden`/`lg:hidden`-toggled <img> layers would each download (display:none
// does not stop the fetch), doubling the high-priority load on an
// LCP-critical hero. Videos or a mixed image/video pair can't collapse this
// way and fall back to breakpoint-toggled layers below.
const responsiveImage = computed(() => {
if (backdrop?.type !== 'image') return null
if (mobileBackdrop && mobileBackdrop.type !== 'image') return null
const base = mobileBackdrop ?? backdrop
return {
src: base.src,
alt: backdrop.alt ?? mobileBackdrop?.alt ?? '',
// Larger-viewport source; omitted when one image serves both breakpoints.
desktopSrc: mobileBackdrop ? backdrop.src : undefined
}
})
// Fallback for videos and mixed image/video pairs: toggle assets by breakpoint.
const backdropLayers = computed(() => {
if (!backdrop) return []
if (mobileBackdrop) {
return [
{
backdrop: mobileBackdrop,
class: 'relative aspect-3/2 w-full rounded-3xl object-cover lg:hidden'
},
{
backdrop,
class: 'absolute inset-0 hidden size-full object-cover lg:block'
}
]
}
return [{ backdrop, class: sharedBackdropClass }]
})
const scrimShape = 'farthest-side at 50% 50%'
const scrimStyle = {
background: `radial-gradient(${scrimShape}, color-mix(in srgb, var(--color-primary-warm-white) 80%, transparent) 0%, transparent 80%)`,
maskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`,
WebkitMaskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`
}
</script>
<template>
<section
:class="cn('max-w-9xl mx-auto px-4 pt-4 lg:px-6 lg:pt-6', className)"
>
<div class="relative overflow-hidden rounded-3xl">
<slot name="backdrop">
<picture v-if="responsiveImage" class="contents">
<source
v-if="responsiveImage.desktopSrc"
:srcset="responsiveImage.desktopSrc"
media="(min-width: 1024px)"
/>
<img
:src="responsiveImage.src"
:alt="responsiveImage.alt"
fetchpriority="high"
decoding="async"
:class="sharedBackdropClass"
/>
</picture>
<template v-else>
<template v-for="(layer, i) in backdropLayers" :key="i">
<video
v-if="layer.backdrop.type === 'video'"
:ref="pauseIfReduced"
:src="layer.backdrop.src"
:poster="layer.backdrop.poster"
:aria-label="layer.backdrop.alt"
:aria-hidden="layer.backdrop.alt ? undefined : true"
:autoplay="!reduceMotion"
loop
muted
playsinline
preload="metadata"
:class="layer.class"
/>
<img
v-else
:src="layer.backdrop.src"
:alt="layer.backdrop.alt ?? ''"
fetchpriority="high"
decoding="async"
:class="layer.class"
/>
</template>
</template>
</slot>
<div
class="relative flex flex-col justify-center px-0 pt-6 pb-8 lg:min-h-176 lg:px-16 lg:py-24"
>
<div class="relative w-full max-w-xl">
<div
aria-hidden="true"
class="pointer-events-none absolute -inset-12 hidden backdrop-blur-md lg:-inset-16 lg:block"
:style="scrimStyle"
/>
<div class="relative">
<ProductHeroBadge
v-if="badgeText"
:text="badgeText"
:logo-src="badgeLogoSrc"
:logo-alt="badgeLogoAlt"
/>
<h1
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
>
{{ title }}
</h1>
<p
v-if="subtitle"
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
>
{{ subtitle }}
</p>
<p
v-if="footnote"
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
>
{{ footnote }}
</p>
<slot />
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,12 @@
---
import type { JsonLdGraph } from '../../utils/jsonLd'
import { escapeJsonLd } from '../../utils/escapeJsonLd'
interface Props {
graph: JsonLdGraph
}
const { graph } = Astro.props
---
<script is:inline type="application/ld+json" set:html={escapeJsonLd(graph)} />

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { localizeHref } from '../../config/routes'
import { t } from '../../i18n/translations'
const {
@@ -15,8 +16,7 @@ const {
locale?: Locale
}>()
const localePrefix = locale === 'en' ? '' : `/${locale}`
const nextHref = `${localePrefix}/demos/${nextSlug}`
const nextHref = localizeHref(`/demos/${nextSlug}`, locale)
</script>
<template>

View File

@@ -1,7 +1,10 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { Check, Copy } from '@lucide/vue'
import { useClipboard } from '@vueuse/core'
import { computed } from 'vue'
// Interactive: the copy button is inert until its host island is hydrated.
// Render under a `client:*` directive (e.g. `client:visible`) when the page
// needs it to work.
@@ -11,6 +14,8 @@ const {
copiedLabel = 'Copied'
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
const multiline = computed(() => value.includes('\n'))
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
function handleCopy() {
@@ -20,15 +25,32 @@ function handleCopy() {
<template>
<div
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
:class="
cn(
'bg-transparency-white-t4 border-primary-warm-gray flex gap-2 rounded-xl border px-4 py-3',
multiline ? 'items-start' : 'items-center'
)
"
>
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
<span
:class="
cn(
'flex-1 font-mono text-xs text-primary-comfy-canvas',
multiline ? 'wrap-break-word whitespace-pre-line' : 'truncate'
)
"
>
{{ value }}
</span>
<button
type="button"
:aria-label="copied ? copiedLabel : copyLabel"
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
:class="
cn(
'text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas',
multiline && 'mt-0.5'
)
"
@click="handleCopy"
>
<component :is="copied ? Check : Copy" class="size-4" />

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { isHrefActive } from './useCurrentPath'
describe('isHrefActive', () => {
it('matches the current page', () => {
expect(isHrefActive('/mcp', '/mcp')).toBe(true)
})
it('does not match other pages', () => {
expect(isHrefActive('/mcp', '/pricing')).toBe(false)
})
it('matches regardless of a trailing slash', () => {
expect(isHrefActive('/mcp', '/mcp/')).toBe(true)
})
it('ignores query and hash on the href', () => {
expect(isHrefActive('/mcp?ref=banner#setup', '/mcp')).toBe(true)
})
it('never matches an external href', () => {
expect(
isHrefActive('https://docs.comfy.org/agent-tools/cloud', '/mcp')
).toBe(false)
})
it('never matches an empty href', () => {
expect(isHrefActive('', '/mcp')).toBe(false)
})
})

View File

@@ -3,6 +3,7 @@ import type { Locale, TranslationKey } from '../i18n/translations'
import { t } from '../i18n/translations'
import { resolveRel } from '../utils/cta'
import { localizeHref } from './routes'
// The banner "CMS": a single typed config resolved through i18n at build time.
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
@@ -73,7 +74,7 @@ export function getBannerData(
: undefined,
link: link
? {
href: link.href,
href: localizeHref(link.href, locale),
title: t(link.titleKey, locale),
target,
rel: resolveRel({ target: target ?? '_self' }),

View File

@@ -0,0 +1,53 @@
import { t } from '../i18n/translations'
import type { Locale, TranslationKey } from '../i18n/translations'
import { externalLinks } from './routes'
interface PricingTier {
slug: string
labelKey: TranslationKey
priceKey: TranslationKey
}
const tiers: PricingTier[] = [
{
slug: 'standard',
labelKey: 'pricing.plan.standard.label',
priceKey: 'pricing.plan.standard.price'
},
{
slug: 'creator',
labelKey: 'pricing.plan.creator.label',
priceKey: 'pricing.plan.creator.price'
},
{
slug: 'pro',
labelKey: 'pricing.plan.pro.label',
priceKey: 'pricing.plan.pro.price'
}
]
export interface PricingOffer {
name: string
price: string
url: string
}
export function pricingOffers(locale: Locale): PricingOffer[] {
return tiers.flatMap((tier) => {
const display = t(tier.priceKey, locale).trim()
const match = /^\$(\d+(?:\.\d+)?)$/.exec(display)
if (!match) {
console.warn(
`pricingOffers: skipping tier "${tier.slug}" (${locale}) — price "${display}" is not a plain USD amount`
)
return []
}
return [
{
name: t(tier.labelKey, locale),
price: match[1],
url: `${externalLinks.cloud}/cloud/subscribe?tier=${tier.slug}&cycle=monthly`
}
]
})
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { localizeHref } from './routes'
describe('localizeHref', () => {
it('prefixes an internal path for a non-default locale', () => {
expect(localizeHref('/mcp', 'zh-CN')).toBe('/zh-CN/mcp')
})
it('leaves the default locale unprefixed', () => {
expect(localizeHref('/mcp', 'en')).toBe('/mcp')
})
it('passes external URLs through unchanged', () => {
expect(
localizeHref('https://docs.comfy.org/agent-tools/cloud', 'zh-CN')
).toBe('https://docs.comfy.org/agent-tools/cloud')
})
it('never prefixes locale-invariant routes', () => {
expect(localizeHref('/terms-of-service', 'zh-CN')).toBe('/terms-of-service')
})
})

View File

@@ -47,13 +47,26 @@ const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>([
'enterpriseMsa'
])
const LOCALE_INVARIANT_PATHS = new Set<string>(
[...LOCALE_INVARIANT_ROUTE_KEYS].map((key) => baseRoutes[key])
)
/**
* Prefix an internal path with the locale (`/mcp` → `/zh-CN/mcp`). External
* URLs and locale-invariant routes pass through unchanged.
*/
export function localizeHref(href: string, locale: Locale = 'en'): string {
if (locale === 'en' || !href.startsWith('/')) return href
if (LOCALE_INVARIANT_PATHS.has(href)) return href
return `/${locale}${href}`
}
export function getRoutes(locale: Locale = 'en'): Routes {
if (locale === 'en') return baseRoutes
const prefix = `/${locale}`
return Object.fromEntries(
Object.entries(baseRoutes).map(([k, v]) => [
k,
LOCALE_INVARIANT_ROUTE_KEYS.has(k as keyof Routes) ? v : `${prefix}${v}`
Object.entries(baseRoutes).map(([key, path]) => [
key,
localizeHref(path, locale)
])
) as unknown as Routes
}
@@ -69,15 +82,19 @@ export const externalLinks = {
docsApi: 'https://docs.comfy.org/development/cloud/overview#quick-start',
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
g2ComfyUi: 'https://www.g2.com/products/comfyui',
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
mcpServer: 'https://cloud.comfy.org/mcp',
linkedin: 'https://www.linkedin.com/company/comfyui',
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',
reddit: 'https://www.reddit.com/r/comfyui/',
support: 'https://support.comfy.org/hc/en-us',
wikidataComfyOrg: 'https://www.wikidata.org/wiki/Q130598554',
wikidataComfyUi: 'https://www.wikidata.org/wiki/Q127798647',
wikipediaComfyUi: 'https://en.wikipedia.org/wiki/ComfyUI',
workflows: 'https://comfy.org/workflows',
x: 'https://x.com/ComfyUI',
youtube: 'https://www.youtube.com/@ComfyOrg'

View File

@@ -72,6 +72,24 @@ export const drops: readonly Drop[] = [
href: { en: '/download', 'zh-CN': '/zh-CN/download' }
}
},
{
id: 'comfy-mcp',
badge: NEW_BADGE,
category: CLOUD,
media: imageFor('Drops_2x2card_MCP.jpg', {
en: 'Comfy MCP',
'zh-CN': 'Comfy MCP'
}),
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
description: {
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
},
cta: {
label: EXPLORE,
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{
id: 'app-mode',
badge: NEW_BADGE,
@@ -112,24 +130,6 @@ export const drops: readonly Drop[] = [
href: { en: '/api', 'zh-CN': '/zh-CN/api' }
}
},
{
id: 'comfy-mcp',
badge: NEW_BADGE,
category: CLOUD,
media: imageFor('Drops_2x2card_MCP.jpg', {
en: 'Comfy MCP',
'zh-CN': 'Comfy MCP'
}),
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
description: {
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
},
cta: {
label: EXPLORE,
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{
id: 'community-workflows',
category: COMMUNITY,

View File

@@ -1872,6 +1872,10 @@ const translations = {
en: 'VIEW DOCS',
'zh-CN': '查看文档'
},
'mcp.hero.installMcp': {
en: 'INSTALL MCP',
'zh-CN': '安装 MCP'
},
'mcp.hero.runWorkflow': {
en: 'RUN A WORKFLOW',
'zh-CN': '运行工作流'
@@ -1909,21 +1913,27 @@ const translations = {
},
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
'mcp.setup.step1.title': {
en: 'Copy the MCP URL',
'zh-CN': '复制 MCP URL'
en: 'Ask your agent to install Comfy MCP',
'zh-CN': '让你的智能体安装 Comfy MCP'
},
'mcp.setup.step1.command': {
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
},
'mcp.setup.step1.description': {
en: "Click the copy button below. You'll paste it into your client in the next step.",
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
en: 'Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
'zh-CN':
'将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
},
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
'mcp.setup.step2.title': {
en: 'Add the connector',
'zh-CN': '添加连接器'
en: 'Or add it by hand',
'zh-CN': '或手动添加'
},
'mcp.setup.step2.description': {
en: 'Name it Comfy Cloud and paste the URL. The docs below cover every client.',
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL。下方文档涵盖各类客户端。'
en: 'Prefer manual setup? Add Comfy Cloud as a custom connector with the MCP URL. The docs cover every client.',
'zh-CN':
'想手动配置?用 MCP URL 将 Comfy Cloud 添加为自定义连接器。文档涵盖各类客户端。'
},
'mcp.setup.step2.cta': {
en: 'COMFY CLOUD MCP DOCS',
@@ -2180,6 +2190,13 @@ const translations = {
'nav.ctaCloudPrefix': { en: 'LAUNCH', 'zh-CN': '启动' },
'nav.ctaCloudCore': { en: 'CLOUD', 'zh-CN': '云端' },
'nav.home': { en: 'Comfy home', 'zh-CN': 'Comfy 首页' },
'breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
'breadcrumb.about': { en: 'About Us', 'zh-CN': '关于我们' },
'breadcrumb.contact': { en: 'Contact', 'zh-CN': '联系我们' },
'breadcrumb.download': { en: 'Download', 'zh-CN': '下载' },
'breadcrumb.careers': { en: 'Careers', 'zh-CN': '招聘' },
'breadcrumb.pricing': { en: 'Pricing', 'zh-CN': '定价' },
'breadcrumb.supportedNodes': { en: 'Supported Nodes', 'zh-CN': '支持的节点' },
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
'nav.close': { en: 'Close', 'zh-CN': '关闭' },
@@ -4051,7 +4068,6 @@ const translations = {
en: 'This page is being redesigned. Check back soon.',
'zh-CN': '此页面正在重新设计中,请稍后再来。'
},
'demos.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
'demos.breadcrumb.demos': { en: 'Demos', 'zh-CN': '演示' },
'customers.story.whatsNext': {
@@ -4147,10 +4163,6 @@ const translations = {
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': '支持的模型'

View File

@@ -7,14 +7,17 @@ import SiteFooter from '../components/common/SiteFooter.vue'
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
import { bannerConfig, getBannerData } from '../config/banner'
import { isHrefActive } from '../composables/useCurrentPath'
import {
BANNER_DISMISS_ATTR,
BANNER_STORAGE_KEY,
createBannerVersion,
evaluateBannerVisibility
} from '../utils/banner'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
import { buildPageGraph, pageContext } from '../utils/jsonLd'
import type { Crumb, JsonLdNode, WebPageType } from '../utils/jsonLd'
import JsonLdGraph from '../components/common/JsonLdGraph.astro'
interface Props {
title: string
@@ -22,6 +25,10 @@ interface Props {
keywords?: string[]
ogImage?: string
noindex?: boolean
pageType?: WebPageType
breadcrumbs?: Crumb[]
mainEntityId?: string
extraJsonLd?: (JsonLdNode | null | undefined)[]
}
const {
@@ -30,52 +37,54 @@ const {
keywords,
ogImage = 'https://media.comfy.org/website/comfy.webp',
noindex = false,
pageType,
breadcrumbs,
mainEntityId,
extraJsonLd,
} = Astro.props
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
const siteBase = Astro.site ?? 'https://comfy.org'
const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
const rawLocale = Astro.currentLocale ?? 'en'
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const canonicalURL = new URL(url)
const ogImageURL = new URL(ogImage, Astro.site ?? 'https://comfy.org')
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const githubStars = rawStars ? formatStarCount(rawStars) : ''
// Announcement banner — build-time visibility gate + content-hash version key.
// A promo never advertises the page you are already on, so the banner is
// suppressed when its CTA points at the current path.
const bannerData = getBannerData(bannerConfig, locale)
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
currentLocale: locale,
currentSection: 'sitewide',
now: new Date(),
})
const bannerVisible =
evaluateBannerVisibility(bannerConfig, {
currentLocale: locale,
currentSection: 'sitewide',
now: new Date(),
}) && !isHrefActive(bannerData.link?.href ?? '', Astro.url.pathname)
const bannerVersion = createBannerVersion(bannerData, locale)
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD
const organizationJsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org',
logo: 'https://comfy.org/icons/logomark.svg',
sameAs: [
'https://github.com/comfyanonymous/ComfyUI',
'https://discord.gg/comfyorg',
'https://x.com/comaboratory',
'https://reddit.com/r/comfyui',
'https://linkedin.com/company/comfyorg',
'https://instagram.com/comfyorg',
],
}
const websiteJsonLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Comfy',
url: 'https://comfy.org',
}
const structuredData = noindex
? undefined
: buildPageGraph(
{ siteUrl, locale },
{
url,
name: title,
description,
imageUrl: ogImageURL.href,
type: pageType,
crumbs: breadcrumbs,
mainEntityId,
},
...(extraJsonLd ?? []),
)
---
<!doctype html>
@@ -117,10 +126,7 @@ const websiteJsonLd = {
<meta name="twitter:image" content={ogImageURL.href} />
<!-- Structured Data -->
<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" />
{structuredData && <JsonLdGraph graph={structuredData} />}
<slot name="head" />
<!-- Google Tag Manager -->
@@ -140,7 +146,6 @@ const websiteJsonLd = {
)}
<ClientRouter />
<slot name="head" />
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
{bannerVisible && (

View File

@@ -5,9 +5,25 @@ import StorySection from '../components/about/StorySection.vue'
import OurValuesSection from '../components/about/OurValuesSection.vue'
import ValuesSection from '../components/about/ValuesSection.vue'
import CareersSection from '../components/about/CareersSection.vue'
import { t } from '../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout title="About Us — Comfy">
<BaseLayout
title="About Us — Comfy"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.about', locale) },
]}
>
<HeroSection client:load />
<StorySection />
<OurValuesSection />

View File

@@ -9,34 +9,36 @@ import HeroSection from '../../templates/affiliate/HeroSection.vue'
import HowItWorksSection from '../../templates/affiliate/HowItWorksSection.vue'
import { affiliateFaqs } from '../../data/affiliateFaq'
import { t } from '../../i18n/translations'
import type { JsonLdNode } from '../../utils/jsonLd'
import { absoluteUrl, jsonLdId, pageContext } from '../../utils/jsonLd'
const locale = 'en' as const
const faqJsonLd = {
'@context': 'https://schema.org',
const pageTitle = t('affiliate.page.title', 'en')
const pageDescription = t('affiliate.page.description', 'en')
const { locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const faqPage: JsonLdNode = {
'@type': 'FAQPage',
'@id': jsonLdId(url, 'faq'),
mainEntity: affiliateFaqs.map((faq) => ({
'@type': 'Question',
name: faq.question[locale],
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer[locale]
}
}))
acceptedAnswer: { '@type': 'Answer', text: faq.answer[locale] },
})),
}
---
<BaseLayout
title={t('affiliate.page.title', locale)}
description={t('affiliate.page.description', locale)}
title={pageTitle}
description={pageDescription}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: pageTitle },
]}
extraJsonLd={[faqPage]}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(faqJsonLd)}
/>
</Fragment>
<HeroSection />
<HowItWorksSection />

View File

@@ -7,6 +7,13 @@ import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
import FAQSection from '../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../utils/ashby'
import { reportAshbyOutcome } from '../utils/ashby.ci'
import { t } from '../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../utils/jsonLd'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
@@ -19,11 +26,31 @@ if (outcome.status === 'failed') {
}
const departments = outcome.snapshot.departments
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const roles = itemListNode(
url,
t('breadcrumb.careers', locale),
departments.flatMap((department) =>
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
),
)
---
<BaseLayout
title="Careers — Comfy"
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.careers', locale) },
]}
extraJsonLd={[roles]}
>
<HeroSection />
<RolesSection departments={departments} client:visible />

View File

@@ -2,9 +2,41 @@
import BaseLayout from '../../layouts/BaseLayout.astro'
import PriceSection from '../../components/pricing/PriceSection.vue'
import WhatsIncludedSection from '../../components/pricing/WhatsIncludedSection.vue'
import { pricingOffers } from '../../config/pricing'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
jsonLdId,
pageContext,
productNode,
} from '../../utils/jsonLd'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const productId = jsonLdId(url, 'product')
---
<BaseLayout title="Pricing — Comfy Cloud">
<BaseLayout
title="Pricing — Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{ name: t('breadcrumb.pricing', locale) },
]}
extraJsonLd={[
productNode({
siteUrl,
id: productId,
name: 'Comfy Cloud',
url,
offers: pricingOffers(locale),
}),
]}
>
<PriceSection client:load />
<WhatsIncludedSection />
</BaseLayout>

View File

@@ -4,39 +4,44 @@ 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'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../utils/jsonLd'
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,
const title = t('cloudNodes.meta.title', 'en')
const description = t('cloudNodes.meta.description', 'en')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const packList = itemListNode(
url,
title,
packs.map((pack) => ({
name: pack.displayName,
image: pack.bannerUrl || pack.iconUrl
}))
}
url: absoluteUrl(Astro.site, `/cloud/supported-nodes/${pack.id}`),
})),
)
---
<BaseLayout
title={t('cloudNodes.meta.title', 'en')}
description={t('cloudNodes.meta.description', 'en')}
title={title}
description={description}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{ name: t('breadcrumb.supportedNodes', locale) },
]}
extraJsonLd={[packList]}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(itemListJsonLd)}
/>
<HeroSection client:visible />
<PackGridSection packs={packs} client:visible />
</BaseLayout>

View File

@@ -7,7 +7,12 @@ 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'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
@@ -29,35 +34,45 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'en')
.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',
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
name: pack.displayName,
url,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
url: pageUrl,
description,
description: pack.description || undefined,
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' }
}
authorName: pack.publisher?.name,
isFree: true,
})
---
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<BaseLayout
title={title}
description={metaDescription}
ogImage={pack.bannerUrl}
mainEntityId={softwareId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{
name: t('breadcrumb.supportedNodes', locale),
url: absoluteUrl(Astro.site, '/cloud/supported-nodes'),
},
{ name: pack.displayName },
]}
extraJsonLd={[software]}
>
<PackDetail pack={pack} />
</BaseLayout>

View File

@@ -2,9 +2,25 @@
import BaseLayout from '../layouts/BaseLayout.astro'
import FormSection from '../components/contact/FormSection.vue'
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
import { t } from '../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout title="Contact — Comfy">
<BaseLayout
title="Contact — Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.contact', locale) },
]}
>
<FormSection client:load />
<SocialProofBarSection />
</BaseLayout>

View File

@@ -7,6 +7,13 @@ import DemoTranscript from '../../components/demos/DemoTranscript.vue'
import DemoNavSection from '../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
import { t } from '../../i18n/translations'
import type { JsonLdNode } from '../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
organizationId,
pageContext,
} from '../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
@@ -19,68 +26,34 @@ const demo = getDemoBySlug(slug as string)!
const nextDemo = getNextDemo(slug as string)
const title = t(demo.title)
const description = t(demo.description)
const canonicalURL = new URL(`/demos/${demo.slug}`, Astro.site)
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: title,
description,
image: new URL(demo.ogImage, Astro.site).href,
totalTime: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const learningResourceJsonLd = {
'@context': 'https://schema.org',
'@type': 'LearningResource',
name: title,
description,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel: demo.difficulty === 'beginner'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const educationalLevel =
demo.difficulty === 'beginner'
? 'Beginner'
: demo.difficulty === 'intermediate'
? 'Intermediate'
: 'Advanced',
url: canonicalURL.href,
: 'Advanced'
const learningId = jsonLdId(url, 'learning')
const learningResource: JsonLdNode = {
'@type': 'LearningResource',
'@id': learningId,
name: title,
description,
url,
image: new URL(demo.ogImage, Astro.site).href,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel,
timeRequired: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('demos.breadcrumb.home'),
item: 'https://comfy.org'
},
{
'@type': 'ListItem',
position: 2,
name: t('demos.breadcrumb.demos'),
item: 'https://comfy.org/demos'
},
{
'@type': 'ListItem',
position: 3,
name: title
}
]
isPartOf: { '@id': jsonLdId(url, 'webpage') },
author: { '@id': organizationId(siteUrl) },
}
---
@@ -88,25 +61,20 @@ const breadcrumbJsonLd = {
title={`${title} — Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{
name: t('demos.breadcrumb.demos', locale),
url: absoluteUrl(Astro.site, '/demos'),
},
{ name: title },
]}
extraJsonLd={[learningResource]}
>
<Fragment slot="head">
<meta property="article:published_time" content={demo.publishedDate} />
<meta property="article:modified_time" content={demo.modifiedDate} />
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(howToJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(learningResourceJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<link rel="preconnect" href="https://demo.arcade.software" />
</Fragment>

View File

@@ -8,11 +8,29 @@ import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
import FAQSection from '../components/product/local/FAQSection.vue'
import { t } from '../i18n/translations'
import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
pageContext,
} from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="Download Comfy Desktop — Run AI on Your Hardware"
description={t('download.hero.subtitle', 'en')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
>
<CloudBannerSection />

View File

@@ -9,11 +9,28 @@ import CaseStudySpotlightSection from "../components/home/CaseStudySpotlightSect
import GetStartedSection from "../components/home/GetStartedSection.vue";
import BuildWhatSection from "../components/home/BuildWhatSection.vue";
import { t } from "../i18n/translations";
import {
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from "../utils/jsonLd";
const { siteUrl } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
);
---
<BaseLayout
title="Comfy — Professional Control of Visual AI"
description={t("hero.subtitle", "en")}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[
comfyUiApplicationNode(siteUrl),
comfyUiSourceCodeNode(siteUrl),
]}
keywords={[
"comfyui app",
"comfyui web app",

View File

@@ -4,6 +4,13 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
import { models, getModelBySlug } from '../../../config/models'
import { t } from '../../../i18n/translations'
import type { JsonLdNode } from '../../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return models.map((model) => ({
@@ -19,7 +26,6 @@ if (model.canonicalSlug) {
}
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',
@@ -40,55 +46,31 @@ const dirDescriptions: Record<string, string> = {
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',
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.`
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
name: displayName,
url,
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',
})
const faqPage: JsonLdNode = {
'@type': 'FAQPage',
'@id': jsonLdId(url, 'faq'),
mainEntity: [
{
'@type': 'Question',
name: `What is ${displayName}?`,
acceptedAnswer: {
'@type': 'Answer',
text: whatIsDescription
}
acceptedAnswer: { '@type': 'Answer', text: whatIsDescription },
},
{
'@type': 'Question',
@@ -97,54 +79,44 @@ const faqJsonLd = {
'@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.`
}
: `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.`
}
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.`
}
}
]
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}
mainEntityId={softwareId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{
name: t('models.breadcrumb.models', locale),
url: absoluteUrl(Astro.site, '/p/supported-models'),
},
{ name: displayName },
]}
extraJsonLd={[software, faqPage]}
>
<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}

View File

@@ -2,10 +2,29 @@
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { models } from '../../../config/models'
import { t } from '../../../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../../utils/jsonLd'
const title = t('models.index.title')
const subtitle = t('models.index.subtitle')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const modelList = itemListNode(
url,
title,
models.map((model) => ({
url: absoluteUrl(Astro.site, `/p/supported-models/${model.slug}`),
})),
)
const dirLabel: Record<string, string> = {
diffusion_models: 'Diffusion',
checkpoints: 'Checkpoint',
@@ -26,6 +45,13 @@ const dirLabel: Record<string, string> = {
<BaseLayout
title={`${title} — Comfy`}
description={subtitle}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: title },
]}
extraJsonLd={[modelList]}
>
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-24">
<header class="mb-12">

View File

@@ -5,9 +5,29 @@ import StorySection from '../../components/about/StorySection.vue'
import OurValuesSection from '../../components/about/OurValuesSection.vue'
import ValuesSection from '../../components/about/ValuesSection.vue'
import CareersSection from '../../components/about/CareersSection.vue'
import { t } from '../../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
<BaseLayout
title="关于我们 — Comfy"
description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.about', locale) },
]}
>
<HeroSection locale="zh-CN" client:load />
<StorySection locale="zh-CN" />
<OurValuesSection locale="zh-CN" />

View File

@@ -7,6 +7,13 @@ import TeamPhotosSection from '../../components/careers/TeamPhotosSection.vue'
import FAQSection from '../../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../../utils/ashby'
import { reportAshbyOutcome } from '../../utils/ashby.ci'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../utils/jsonLd'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
@@ -19,11 +26,34 @@ if (outcome.status === 'failed') {
}
const departments = outcome.snapshot.departments
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const roles = itemListNode(
url,
t('breadcrumb.careers', locale),
departments.flatMap((department) =>
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
),
)
---
<BaseLayout
title="招聘 — Comfy"
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.careers', locale) },
]}
extraJsonLd={[roles]}
>
<HeroSection locale="zh-CN" />
<RolesSection locale="zh-CN" departments={departments} client:visible />

View File

@@ -2,9 +2,44 @@
import BaseLayout from '../../../layouts/BaseLayout.astro'
import PriceSection from '../../../components/pricing/PriceSection.vue'
import WhatsIncludedSection from '../../../components/pricing/WhatsIncludedSection.vue'
import { pricingOffers } from '../../../config/pricing'
import { t } from '../../../i18n/translations'
import {
absoluteUrl,
jsonLdId,
pageContext,
productNode,
} from '../../../utils/jsonLd'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const productId = jsonLdId(url, 'product')
---
<BaseLayout title="定价 — Comfy Cloud">
<BaseLayout
title="定价 — Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
{ name: t('breadcrumb.pricing', locale) },
]}
extraJsonLd={[
productNode({
siteUrl,
id: productId,
name: 'Comfy Cloud',
url,
offers: pricingOffers(locale),
}),
]}
>
<PriceSection locale="zh-CN" client:load />
<WhatsIncludedSection locale="zh-CN" />
</BaseLayout>

View File

@@ -4,39 +4,47 @@ 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'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../../utils/jsonLd'
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,
const title = t('cloudNodes.meta.title', 'zh-CN')
const description = t('cloudNodes.meta.description', 'zh-CN')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const packList = itemListNode(
url,
title,
packs.map((pack) => ({
name: pack.displayName,
image: pack.bannerUrl || pack.iconUrl
}))
}
url: absoluteUrl(Astro.site, `/zh-CN/cloud/supported-nodes/${pack.id}`),
})),
)
---
<BaseLayout
title={t('cloudNodes.meta.title', 'zh-CN')}
description={t('cloudNodes.meta.description', 'zh-CN')}
title={title}
description={description}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
{ name: t('breadcrumb.supportedNodes', locale) },
]}
extraJsonLd={[packList]}
>
<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

@@ -7,7 +7,12 @@ 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'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
@@ -29,35 +34,48 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'zh-CN')
.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',
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
name: pack.displayName,
url,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
url: pageUrl,
description,
description: pack.description || undefined,
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' }
}
authorName: pack.publisher?.name,
isFree: true,
})
---
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<BaseLayout
title={title}
description={metaDescription}
ogImage={pack.bannerUrl}
mainEntityId={softwareId}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
{
name: t('breadcrumb.supportedNodes', locale),
url: absoluteUrl(Astro.site, '/zh-CN/cloud/supported-nodes'),
},
{ name: pack.displayName },
]}
extraJsonLd={[software]}
>
<PackDetail pack={pack} locale="zh-CN" />
</BaseLayout>

View File

@@ -2,9 +2,28 @@
import BaseLayout from '../../layouts/BaseLayout.astro'
import FormSection from '../../components/contact/FormSection.vue'
import SocialProofBarSection from '../../components/common/SocialProofBarSection.vue'
import { t } from '../../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout title="联系我们 — Comfy">
<BaseLayout
title="联系我们 — Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.contact', locale) },
]}
>
<FormSection locale="zh-CN" client:load />
<SocialProofBarSection />
</BaseLayout>

View File

@@ -7,6 +7,13 @@ import DemoTranscript from '../../../components/demos/DemoTranscript.vue'
import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
import { t } from '../../../i18n/translations'
import type { JsonLdNode } from '../../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
organizationId,
pageContext,
} from '../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
@@ -19,68 +26,34 @@ const demo = getDemoBySlug(slug as string)!
const nextDemo = getNextDemo(slug as string)
const title = t(demo.title, 'zh-CN')
const description = t(demo.description, 'zh-CN')
const canonicalURL = new URL(`/zh-CN/demos/${demo.slug}`, Astro.site)
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: title,
description,
image: new URL(demo.ogImage, Astro.site).href,
totalTime: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const learningResourceJsonLd = {
'@context': 'https://schema.org',
'@type': 'LearningResource',
name: title,
description,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel: demo.difficulty === 'beginner'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const educationalLevel =
demo.difficulty === 'beginner'
? 'Beginner'
: demo.difficulty === 'intermediate'
? 'Intermediate'
: 'Advanced',
url: canonicalURL.href,
: 'Advanced'
const learningId = jsonLdId(url, 'learning')
const learningResource: JsonLdNode = {
'@type': 'LearningResource',
'@id': learningId,
name: title,
description,
url,
image: new URL(demo.ogImage, Astro.site).href,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel,
timeRequired: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('demos.breadcrumb.home', 'zh-CN'),
item: 'https://comfy.org/zh-CN'
},
{
'@type': 'ListItem',
position: 2,
name: t('demos.breadcrumb.demos', 'zh-CN'),
item: 'https://comfy.org/zh-CN/demos'
},
{
'@type': 'ListItem',
position: 3,
name: title
}
]
isPartOf: { '@id': jsonLdId(url, 'webpage') },
author: { '@id': organizationId(siteUrl) },
}
---
@@ -88,25 +61,23 @@ const breadcrumbJsonLd = {
title={`${title} — Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{
name: t('demos.breadcrumb.demos', locale),
url: absoluteUrl(Astro.site, '/zh-CN/demos'),
},
{ name: title },
]}
extraJsonLd={[learningResource]}
>
<Fragment slot="head">
<meta property="article:published_time" content={demo.publishedDate} />
<meta property="article:modified_time" content={demo.modifiedDate} />
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(howToJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(learningResourceJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<link rel="preconnect" href="https://demo.arcade.software" />
</Fragment>

View File

@@ -8,11 +8,32 @@ import EcoSystemSection from '../../components/product/local/EcoSystemSection.vu
import ProductCardsSection from '../../components/product/local/ProductCardsSection.vue'
import FAQSection from '../../components/product/local/FAQSection.vue'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
pageContext,
} from '../../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="下载 Comfy 桌面版 — 在您的硬件上运行 AI"
description={t('download.hero.subtitle', 'zh-CN')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
>
<CloudBannerSection locale="zh-CN" />

View File

@@ -9,11 +9,25 @@ import CaseStudySpotlightSection from '../../components/home/CaseStudySpotlightS
import GetStartedSection from '../../components/home/GetStartedSection.vue'
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
import { t } from '../../i18n/translations'
import {
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from '../../utils/jsonLd'
const { siteUrl } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="Comfy — 视觉 AI 的最强可控性"
description={t('hero.subtitle', 'zh-CN')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
keywords={['comfyui app', 'comfyui web app', 'comfyui application', 'ComfyUI 应用', 'ComfyUI 网页版', 'ComfyUI 桌面应用', 'ComfyUI 下载', '可视化 AI', '节点式 AI', '生成式 AI 工作流']}
>
<HeroSection locale="zh-CN" client:load />

View File

@@ -17,7 +17,7 @@ const ctas = mcpCtas(locale)
badge-text="MCP"
:title="t('mcp.hero.heading', locale)"
:subtitle="t('mcp.hero.subtitle', locale)"
:primary-cta="ctas.runWorkflow"
:primary-cta="ctas.installMcp"
:secondary-cta="ctas.docs"
>
<template #media>

View File

@@ -17,7 +17,10 @@ const cards: FeatureCard[] = [
description: t('mcp.setup.step1.description', locale),
action: {
type: 'code',
value: externalLinks.mcpServer
value: t('mcp.setup.step1.command', locale).replace(
'{url}',
externalLinks.docsMcp
)
}
},
{
@@ -53,6 +56,8 @@ const cards: FeatureCard[] = [
<template>
<FeatureGrid01
id="setup"
class="scroll-mt-24 lg:scroll-mt-36"
:eyebrow="t('mcp.setup.label', locale)"
:heading="t('mcp.setup.heading', locale)"
:subtitle="t('mcp.setup.subtitle', locale)"

View File

@@ -9,16 +9,25 @@ export interface McpCta {
}
/**
* The two calls-to-action shared by the MCP hero and "how it works" sections:
* view the docs, or run a workflow in the cloud.
* Calls-to-action for the MCP page: view the docs, jump to the on-page setup
* steps, or run a workflow in the cloud. The hero leads with install + docs;
* the "how it works" section pairs run-a-workflow with docs.
*/
export function mcpCtas(locale: Locale): { docs: McpCta; runWorkflow: McpCta } {
export function mcpCtas(locale: Locale): {
docs: McpCta
installMcp: McpCta
runWorkflow: McpCta
} {
return {
docs: {
label: t('mcp.hero.viewDocs', locale),
href: externalLinks.docsMcp,
target: '_blank'
},
installMcp: {
label: t('mcp.hero.installMcp', locale),
href: '#setup'
},
runWorkflow: {
label: t('mcp.hero.runWorkflow', locale),
href: getRoutes(locale).cloud

View File

@@ -0,0 +1,212 @@
import { describe, expect, it } from 'vitest'
import { externalLinks } from '../config/routes'
import { escapeJsonLd } from './escapeJsonLd'
import type { JsonLdGraph } from './jsonLd'
import {
absoluteUrl,
buildPageGraph,
collectGraphIds,
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
itemListNode,
jsonLdId,
organizationId,
pageContext,
productNode,
softwareApplicationNode
} from './jsonLd'
const siteUrl = 'https://comfy.org'
const site = new URL('https://comfy.org/')
function typeNames(graph: JsonLdGraph): string[] {
return graph['@graph'].map((node) => node['@type'])
}
describe('absoluteUrl', () => {
it('resolves internal paths to their trailing-slash canonical form', () => {
expect(absoluteUrl(site, '/cloud')).toBe('https://comfy.org/cloud/')
expect(absoluteUrl(site, '/about/')).toBe('https://comfy.org/about/')
expect(absoluteUrl(site, '/')).toBe('https://comfy.org/')
})
})
describe('pageContext', () => {
it('derives siteUrl, locale and canonical url from the Astro globals', () => {
expect(pageContext(site, '/about/', undefined)).toEqual({
siteUrl,
locale: 'en',
url: 'https://comfy.org/about/'
})
expect(pageContext(site, '/zh-CN/', 'zh-CN').locale).toBe('zh-CN')
})
})
describe('itemListNode', () => {
it('counts items and omits per-item names when not supplied', () => {
const node = itemListNode('https://comfy.org/careers/', 'Careers', [
{ url: 'https://jobs.example/1' },
{ url: 'https://jobs.example/2', name: 'Designer' }
])
expect(node.numberOfItems).toBe(2)
const items = node.itemListElement as Record<string, unknown>[]
expect('name' in items[0]).toBe(false)
expect(items[1].name).toBe('Designer')
})
})
describe('softwareApplicationNode', () => {
it('claims Comfy Org as author and publisher only when first-party', () => {
const node = softwareApplicationNode({
siteUrl,
id: jsonLdId(siteUrl, 'software'),
name: 'ComfyUI',
url: siteUrl,
firstParty: true,
applicationCategory: 'MultimediaApplication',
isFree: true
})
const orgRef = { '@id': organizationId(siteUrl) }
expect(node.author).toEqual(orgRef)
expect(node.publisher).toEqual(orgRef)
expect(node.offers).toEqual({
'@type': 'Offer',
price: 0,
priceCurrency: 'USD',
seller: orgRef
})
})
it('does not name Comfy Org as seller on a third-party free offer', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
name: 'Foo Pack',
url: 'https://comfy.org/cloud/supported-nodes/foo/',
applicationCategory: 'DeveloperApplication',
isFree: true
})
expect((node.offers as Record<string, unknown>).seller).toBeUndefined()
})
it('credits a known third-party author without claiming to publish it', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
name: 'Foo Pack',
url: 'https://comfy.org/cloud/supported-nodes/foo/',
applicationCategory: 'DeveloperApplication',
authorName: 'Jane Dev'
})
expect(node.author).toEqual({ '@type': 'Person', name: 'Jane Dev' })
expect(node.publisher).toBeUndefined()
})
it('claims no author or publisher for third-party software with no author', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/p/supported-models/foo/#software',
name: 'Foo Model',
url: 'https://comfy.org/p/supported-models/foo/',
applicationCategory: 'MultimediaApplication'
})
expect(node.author).toBeUndefined()
expect(node.publisher).toBeUndefined()
})
})
describe('sameAs encyclopedic references', () => {
it('links the organization to its Wikidata entity', () => {
const graph = buildPageGraph(
{ siteUrl, locale: 'en' },
{ url: `${siteUrl}/`, name: 'Home' }
)
const org = graph['@graph'].find((node) => node['@type'] === 'Organization')
expect(org?.sameAs).toContain(externalLinks.wikidataComfyOrg)
})
it('links the ComfyUI application to its Wikidata, Wikipedia and G2 entities', () => {
const node = comfyUiApplicationNode(siteUrl)
expect(node.sameAs).toEqual([
externalLinks.wikidataComfyUi,
externalLinks.wikipediaComfyUi,
externalLinks.g2ComfyUi
])
})
it('omits sameAs for third-party software', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/p/supported-models/foo/#software',
name: 'Foo Model',
url: 'https://comfy.org/p/supported-models/foo/',
applicationCategory: 'MultimediaApplication'
})
expect(node.sameAs).toBeUndefined()
})
})
describe('productNode', () => {
it('gives every offer a currency and price', () => {
const node = productNode({
siteUrl,
id: 'https://comfy.org/cloud/pricing/#product',
name: 'Comfy Cloud',
url: 'https://comfy.org/cloud/pricing/',
offers: [{ name: 'Standard', price: '20' }]
})
const offers = node.offers as Record<string, unknown>[]
expect(offers[0].price).toBe('20')
expect(offers[0].priceCurrency).toBe('USD')
expect(offers[0].seller).toEqual({ '@id': organizationId(siteUrl) })
})
})
describe('comfyUiSourceCodeNode', () => {
it('links the source code to the ComfyUI application via targetProduct', () => {
const node = comfyUiSourceCodeNode(siteUrl)
expect(node.targetProduct).toEqual({ '@id': comfyUiSoftwareId(siteUrl) })
})
})
describe('buildPageGraph', () => {
const url = 'https://comfy.org/cloud/pricing/'
const graph = buildPageGraph(
{ siteUrl, locale: 'en' },
{
url,
name: 'Pricing',
type: 'CollectionPage',
mainEntityId: jsonLdId(url, 'itemlist'),
crumbs: [{ name: 'Home', url: `${siteUrl}/` }, { name: 'Pricing' }]
},
itemListNode(url, 'Plans', [{ url: `${siteUrl}/one/` }])
)
it('always includes the site-wide organization, website and page entity', () => {
expect(typeNames(graph)).toContain('Organization')
expect(typeNames(graph)).toContain('WebSite')
expect(typeNames(graph)).toContain('CollectionPage')
})
it('produces a graph where every @id reference resolves', () => {
const { defined, references } = collectGraphIds(graph)
for (const reference of references) {
expect(defined.has(reference)).toBe(true)
}
})
})
describe('escapeJsonLd on a built graph', () => {
it('neutralizes a </script> breakout in a page name', () => {
const graph = buildPageGraph(
{ siteUrl, locale: 'en' },
{ url: `${siteUrl}/x/`, name: '</script><script>alert(1)</script>' }
)
const serialized = escapeJsonLd(graph)
expect(serialized).not.toContain('</script>')
expect(serialized).toContain('\\u003c')
})
})

View File

@@ -0,0 +1,377 @@
import { externalLinks } from '../config/routes'
import type { Locale } from '../i18n/translations'
export type JsonLdNode = Record<string, unknown> & { '@type': string }
export interface JsonLdGraph {
'@context': 'https://schema.org'
'@graph': JsonLdNode[]
}
export interface PageContext {
siteUrl: string
locale: Locale
}
export type WebPageType =
| 'WebPage'
| 'AboutPage'
| 'ContactPage'
| 'CollectionPage'
export interface Crumb {
name: string
url?: string
}
const sameAs = [
externalLinks.github,
externalLinks.x,
externalLinks.youtube,
externalLinks.discord,
externalLinks.instagram,
externalLinks.reddit,
externalLinks.linkedin,
// Wikidata entity for the organization, so the Knowledge Graph can resolve it.
externalLinks.wikidataComfyOrg
]
// Authoritative encyclopedic and review-platform references for the ComfyUI software entity.
const comfyUiSameAs = [
externalLinks.wikidataComfyUi,
externalLinks.wikipediaComfyUi,
externalLinks.g2ComfyUi
]
function siteUrlFrom(site: URL | undefined): string {
return (site?.href ?? 'https://comfy.org/').replace(/\/$/, '')
}
export function absoluteUrl(site: URL | undefined, path: string): string {
const resolved = new URL(path, site ?? 'https://comfy.org').href
return resolved.endsWith('/') ? resolved : `${resolved}/`
}
export function pageContext(
site: URL | undefined,
pathname: string,
currentLocale: string | undefined
): PageContext & { url: string } {
return {
siteUrl: siteUrlFrom(site),
locale: currentLocale === 'zh-CN' ? 'zh-CN' : 'en',
url: absoluteUrl(site, pathname)
}
}
export function jsonLdId(pageUrl: string, fragment: string): string {
return `${pageUrl}#${fragment}`
}
export function organizationId(siteUrl: string): string {
return `${siteUrl}/#organization`
}
function websiteId(siteUrl: string): string {
return `${siteUrl}/#website`
}
function buildGraph(...nodes: (JsonLdNode | null | undefined)[]): JsonLdGraph {
return {
'@context': 'https://schema.org',
'@graph': nodes.filter((node): node is JsonLdNode => Boolean(node))
}
}
function organizationNode(siteUrl: string): JsonLdNode {
return {
'@type': 'Organization',
'@id': organizationId(siteUrl),
name: 'Comfy Org',
url: siteUrl,
logo: {
'@type': 'ImageObject',
url: `${siteUrl}/web-app-manifest-512x512.png`,
width: 512,
height: 512
},
sameAs
}
}
function websiteNode(siteUrl: string): JsonLdNode {
return {
'@type': 'WebSite',
'@id': websiteId(siteUrl),
name: 'Comfy',
url: siteUrl,
publisher: { '@id': organizationId(siteUrl) }
}
}
function breadcrumbNode(pageUrl: string, crumbs: Crumb[]): JsonLdNode {
return {
'@type': 'BreadcrumbList',
'@id': jsonLdId(pageUrl, 'breadcrumb'),
itemListElement: crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1
return isLast || !crumb.url
? { '@type': 'ListItem', position: index + 1, name: crumb.name }
: {
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
item: crumb.url
}
})
}
}
export function itemListNode(
pageUrl: string,
name: string,
items: { url: string; name?: string }[]
): JsonLdNode {
return {
'@type': 'ItemList',
'@id': jsonLdId(pageUrl, 'itemlist'),
name,
numberOfItems: items.length,
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
url: item.url,
...(item.name ? { name: item.name } : {})
}))
}
}
interface WebPageInput {
siteUrl: string
locale: Locale
url: string
name: string
description?: string
imageUrl?: string
crumbs?: Crumb[]
mainEntityId?: string
}
function webPageNode(input: WebPageInput, type: WebPageType): JsonLdNode {
const hasCrumbs = Boolean(input.crumbs && input.crumbs.length > 0)
return {
'@type': type,
'@id': jsonLdId(input.url, 'webpage'),
url: input.url,
name: input.name,
description: input.description,
isPartOf: { '@id': websiteId(input.siteUrl) },
primaryImageOfPage: input.imageUrl
? { '@type': 'ImageObject', url: input.imageUrl }
: undefined,
breadcrumb: hasCrumbs
? { '@id': jsonLdId(input.url, 'breadcrumb') }
: undefined,
mainEntity: input.mainEntityId ? { '@id': input.mainEntityId } : undefined,
inLanguage: input.locale
}
}
export interface SoftwareAppInput {
siteUrl: string
id: string
name: string
url: string
applicationCategory: string
firstParty?: boolean
applicationSubCategory?: string
description?: string
operatingSystem?: string
image?: string
softwareVersion?: string
license?: string
codeRepository?: string
authorName?: string
isFree?: boolean
sameAs?: string[]
}
export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
const orgRef = { '@id': organizationId(input.siteUrl) }
const author = input.firstParty
? orgRef
: input.authorName
? { '@type': 'Person', name: input.authorName }
: undefined
return {
'@type': 'SoftwareApplication',
'@id': input.id,
name: input.name,
url: input.url,
applicationCategory: input.applicationCategory,
applicationSubCategory: input.applicationSubCategory,
description: input.description,
operatingSystem: input.operatingSystem,
image: input.image,
softwareVersion: input.softwareVersion,
license: input.license,
codeRepository: input.codeRepository,
author,
publisher: input.firstParty ? orgRef : undefined,
sameAs: input.sameAs,
offers: input.isFree
? {
'@type': 'Offer',
price: 0,
priceCurrency: 'USD',
seller: input.firstParty ? orgRef : undefined
}
: undefined
}
}
interface SourceCodeInput {
siteUrl: string
id: string
name: string
codeRepository: string
programmingLanguage?: string
targetProductId?: string
}
function softwareSourceCodeNode(input: SourceCodeInput): JsonLdNode {
return {
'@type': 'SoftwareSourceCode',
'@id': input.id,
name: input.name,
codeRepository: input.codeRepository,
programmingLanguage: input.programmingLanguage,
targetProduct: input.targetProductId
? { '@id': input.targetProductId }
: undefined,
author: { '@id': organizationId(input.siteUrl) }
}
}
export function comfyUiSoftwareId(siteUrl: string): string {
return `${siteUrl}/#software`
}
export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
return softwareApplicationNode({
siteUrl,
id: comfyUiSoftwareId(siteUrl),
name: 'ComfyUI',
url: siteUrl,
firstParty: true,
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Windows, macOS, Linux',
isFree: true,
sameAs: comfyUiSameAs
})
}
export function comfyUiSourceCodeNode(siteUrl: string): JsonLdNode {
return softwareSourceCodeNode({
siteUrl,
id: `${siteUrl}/#sourcecode`,
name: 'ComfyUI',
codeRepository: externalLinks.github,
programmingLanguage: 'Python',
targetProductId: comfyUiSoftwareId(siteUrl)
})
}
interface OfferInput {
name: string
price: string | number
url?: string
}
export interface ProductInput {
siteUrl: string
id: string
name: string
url: string
offers: OfferInput[]
}
export function productNode(input: ProductInput): JsonLdNode {
return {
'@type': 'Product',
'@id': input.id,
name: input.name,
url: input.url,
brand: { '@id': organizationId(input.siteUrl) },
offers: input.offers.map((offer) => ({
'@type': 'Offer',
name: offer.name,
price: offer.price,
priceCurrency: 'USD',
url: offer.url,
seller: { '@id': organizationId(input.siteUrl) },
priceSpecification: {
'@type': 'UnitPriceSpecification',
price: offer.price,
priceCurrency: 'USD',
unitText: 'MONTH'
}
}))
}
}
export interface PageGraphInput {
url: string
name: string
type?: WebPageType
description?: string
imageUrl?: string
crumbs?: Crumb[]
mainEntityId?: string
}
export function buildPageGraph(
ctx: PageContext,
page: PageGraphInput,
...extraNodes: (JsonLdNode | null | undefined)[]
): JsonLdGraph {
const { type = 'WebPage', ...rest } = page
const input: WebPageInput = {
...rest,
siteUrl: ctx.siteUrl,
locale: ctx.locale
}
const hasCrumbs = Boolean(page.crumbs && page.crumbs.length > 0)
return buildGraph(
organizationNode(ctx.siteUrl),
websiteNode(ctx.siteUrl),
webPageNode(input, type),
hasCrumbs ? breadcrumbNode(page.url, page.crumbs!) : undefined,
...extraNodes
)
}
export function collectGraphIds(value: unknown): {
defined: Set<string>
references: string[]
} {
const defined = new Set<string>()
const references: string[] = []
const walk = (node: unknown): void => {
if (Array.isArray(node)) {
node.forEach(walk)
return
}
if (node && typeof node === 'object') {
const record = node as Record<string, unknown>
const id = record['@id']
if (typeof id === 'string') {
if (Object.keys(record).length === 1) references.push(id)
else defined.add(id)
}
Object.values(record).forEach(walk)
}
}
walk(value)
return { defined, references }
}

View File

@@ -0,0 +1,37 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadVideo",
"pos": [50, 120],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VIDEO",
"type": "VIDEO",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadVideo"
},
"widgets_values": ["video/cloud-video-hash.mp4 [output]", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -7,6 +7,7 @@ import { TestIds } from '@e2e/fixtures/selectors'
class SidebarTab {
public readonly tabButton: Locator
public readonly selectedTabButton: Locator
public readonly badge: Locator
constructor(
public readonly page: Page,
@@ -16,6 +17,7 @@ class SidebarTab {
this.selectedTabButton = page.locator(
`.${tabId}-tab-button.side-bar-button-selected`
)
this.badge = this.tabButton.locator('.sidebar-icon-badge')
}
async open() {
@@ -25,7 +27,7 @@ class SidebarTab {
await this.tabButton.click()
}
async close() {
if (!this.tabButton.isVisible()) {
if (!(await this.tabButton.isVisible())) {
return
}
await this.tabButton.click()
@@ -54,7 +56,7 @@ export class NodeLibrarySidebarTab extends SidebarTab {
}
override async close() {
if (!this.tabButton.isVisible()) {
if (!(await this.tabButton.isVisible())) {
return
}
@@ -464,3 +466,31 @@ export class AssetsSidebarTab extends SidebarTab {
}
}
}
export class DeprecationWarningsSidebarTab extends SidebarTab {
public readonly panel: Locator
public readonly list: Locator
public readonly clearAllButton: Locator
public readonly emptyState: Locator
constructor(public override readonly page: Page) {
super(page, 'deprecation-warnings')
this.panel = page.getByTestId('deprecation-warnings-sidebar')
this.list = page.getByTestId('deprecation-warnings-list')
this.clearAllButton = this.panel.getByRole('button', { name: 'Clear all' })
this.emptyState = this.panel.getByText(
'No deprecation warnings reported in this session.'
)
}
rowFor(message: string): Locator {
return this.list.locator('li').filter({ hasText: message })
}
async emitDeprecation(message: string): Promise<void> {
await this.page.waitForFunction(() => !!window.__reportDeprecation)
await this.page.evaluate((m: string) => {
window.__reportDeprecation!(m)
}, message)
}
}

View File

@@ -11,6 +11,7 @@ import {
WORKSPACE_FEATURE_FLAG
} from '@e2e/fixtures/data/cloudWorkspace'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import { mockWorkspaceTokenMint } from '@e2e/fixtures/utils/workspaceMocks'
interface RoleChangeRequest {
url: string
@@ -92,9 +93,7 @@ export class CloudWorkspaceMockHelper {
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/api/auth/token', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await mockWorkspaceTokenMint(page, TEAM_WORKSPACE)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/workspaces', (r) =>

View File

@@ -33,6 +33,27 @@ export function member(
}
}
/**
* Stub `POST /api/auth/token` with a valid workspace token for `ws`. Without
* this the mint fails and auth cannot resolve the active workspace.
*/
export async function mockWorkspaceTokenMint(
page: Page,
ws: Pick<WorkspaceWithRole, 'id' | 'name' | 'type' | 'role'>
) {
await page.route('**/api/auth/token', (r) =>
r.fulfill(
jsonRoute({
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: { id: ws.id, name: ws.name, type: ws.type },
role: ws.role,
permissions: []
})
)
)
}
/**
* Stub the workspace resolution + members list so the cloud app boots into the
* given workspace with the given roster (drives the original-owner gate).
@@ -46,17 +67,7 @@ export async function mockWorkspace(
if (route.request().method() !== 'GET') return route.fallback()
await route.fulfill(jsonRoute({ workspaces: [ws] }))
})
await page.route('**/api/auth/token', (r) =>
r.fulfill(
jsonRoute({
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: { id: ws.id, name: ws.name, type: ws.type },
role: ws.role,
permissions: []
})
)
)
await mockWorkspaceTokenMint(page, ws)
await page.route('**/api/workspace/members**', (r) =>
r.fulfill(
jsonRoute({

View File

@@ -11,6 +11,10 @@ import type {
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import {
mockWorkspaceTokenMint,
workspace
} from '@e2e/fixtures/utils/workspaceMocks'
/**
* Billing facade consumers — FE-933 (B3) regression.
@@ -81,6 +85,7 @@ async function mockCloudBoot(
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace.

View File

@@ -0,0 +1,279 @@
import { expect } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
/**
* End-to-end coverage for the user-secrets (API keys) surface in the cloud app:
* add a provider key, see it listed, delete it — the full CRUD round-trip —
* plus the entitlement contract that a non-entitled account never sees the
* gated providers.
*
* Drives a raw `page` against fully-mocked endpoints (the `comfyPage` fixture
* would reach the OSS devtools backend during setup); `mockCloudBoot` +
* `bootCloud` boot the app signed-in, and this spec layers a stateful in-memory
* `/secrets` backend on top so the flow is deterministic and never touches a
* real server.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// `/api/features` is the remote-config source. Enabling user secrets is what
// surfaces the Secrets settings panel for a signed-in user.
const BOOT_FEATURES = {
user_secrets_enabled: true
} satisfies RemoteConfig
// TutorialCompleted suppresses the new-user template browser, whose modal
// overlay (z-1700) would otherwise intercept clicks on the settings dialog.
const BOOT_SETTINGS = { 'Comfy.TutorialCompleted': true }
// The plaintext key a user types in. It must be sent on create but NEVER echoed
// back by the API or rendered anywhere in the UI.
const RUNWAY_KEY_VALUE = 'sk-runway-do-not-echo-0xDEADBEEF'
interface SecretRecord {
id: string
name: string
provider?: string
created_at: string
updated_at: string
last_used_at?: string
}
interface CreateCapture {
name?: string
provider?: string
secret_value?: string
}
interface SecretsBackend {
/** Bodies received by POST /secrets, in order — for asserting what was sent. */
createRequests: CreateCapture[]
/** Current server-side store — for asserting delete actually removed a row. */
store: SecretRecord[]
}
/**
* Stateful mock of the ingest `/secrets` surface. A single route handler
* branches on path + method so registration order can never make a specific
* path (`/secrets/providers`, `/secrets/:id`) lose to the collection glob.
*
* `providerIds` models entitlement: an entitled account sees runway/gemini,
* a non-entitled account gets an empty list (the server omits them).
*/
async function mockSecretsBackend(
page: Page,
providerIds: string[]
): Promise<SecretsBackend> {
const backend: SecretsBackend = { createRequests: [], store: [] }
let idSeq = 0
const respondList = (route: Route) =>
route.fulfill(jsonRoute({ data: backend.store }))
await page.route('**/api/secrets**', async (route) => {
const request = route.request()
const { pathname } = new URL(request.url())
const method = request.method()
// The glob `**/api/secrets**` also matches the panel's own lazy-loaded
// source module (`/src/platform/secrets/api/secretsApi.ts`), whose path
// contains the `/api/secrets` substring. Fulfilling that dev-server module
// request with JSON breaks the dynamic import and the panel never mounts.
// Anchor to the start of the pathname so only genuine `/api/secrets…` API
// routes are handled; everything else falls through to the real Vite server.
if (!/^\/api\/secrets(\/|$)/.test(pathname)) {
return route.continue()
}
// GET /secrets/providers — the entitlement-gated provider allowlist.
if (pathname.endsWith('/secrets/providers')) {
return route.fulfill(
jsonRoute({ data: providerIds.map((id) => ({ id })) })
)
}
// /secrets/:id — item routes (only DELETE is exercised by this flow).
const itemMatch = pathname.match(/\/secrets\/([^/]+)$/)
if (itemMatch) {
const id = itemMatch[1]
if (method === 'DELETE') {
backend.store = backend.store.filter((s) => s.id !== id)
return route.fulfill({ status: 204, body: '' })
}
return respondList(route)
}
// /secrets — collection routes.
if (method === 'POST') {
const body = (request.postDataJSON() ?? {}) as CreateCapture
backend.createRequests.push(body)
idSeq += 1
const created: SecretRecord = {
id: `00000000-0000-4000-8000-${String(idSeq).padStart(12, '0')}`,
name: body.name ?? '',
provider: body.provider,
created_at: '2026-07-08T00:00:00Z',
updated_at: '2026-07-08T00:00:00Z'
}
backend.store.push(created)
// Response echoes metadata ONLY — the schema has no secret_value field.
return route.fulfill(jsonRoute(created))
}
// GET /secrets (list).
return respondList(route)
})
return backend
}
/**
* Open the settings dialog and land on the Secrets panel, waiting for both the
* provider allowlist and the secret list to resolve so subsequent assertions
* are not racing the panel's on-mount fetches.
*/
async function openSecretsPanel(page: Page) {
const settingsDialog = page.getByTestId('settings-dialog')
await page.evaluate(() => {
const app = window.app
if (!app) throw new Error('window.app is not available')
return app.extensionManager.command.execute('Comfy.ShowSettingsDialog')
})
await settingsDialog.waitFor({ state: 'visible' })
const providersResolved = page.waitForResponse((r) =>
r.url().includes('/api/secrets/providers')
)
const listResolved = page.waitForResponse(
(r) =>
/\/api\/secrets(\?|$)/.test(r.url()) && r.request().method() === 'GET'
)
await settingsDialog
.locator('nav')
.getByRole('button', { name: 'Secrets' })
.click()
await Promise.all([providersResolved, listResolved])
return settingsDialog
}
test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
test('an entitled account can add, list, and delete a provider key', async ({
page
}) => {
test.slow()
await mockCloudBoot(page, {
features: BOOT_FEATURES,
settings: BOOT_SETTINGS
})
await bootCloud(page)
const backend = await mockSecretsBackend(page, ['runway', 'gemini'])
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
const settingsDialog = await openSecretsPanel(page)
// Empty state before anything is added.
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
// --- ADD -------------------------------------------------------------
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
const formDialog = page
.getByRole('dialog')
.filter({ hasText: 'Secret Value' })
await expect(formDialog).toBeVisible()
// Pick the entitled Runway provider from the server-driven dropdown.
await formDialog.locator('#secret-provider').click()
await page.getByRole('option', { name: 'Runway' }).click()
await formDialog.locator('#secret-name').fill('My Runway Key')
await formDialog.locator('input[type="password"]').fill(RUNWAY_KEY_VALUE)
await formDialog.getByRole('button', { name: 'Save', exact: true }).click()
await expect(formDialog).toBeHidden()
// --- LIST ------------------------------------------------------------
await expect(settingsDialog.getByText('My Runway Key')).toBeVisible()
await expect(settingsDialog.getByText(/No secrets stored/)).toBeHidden()
// The create request carried the plaintext value + provider...
expect(backend.createRequests).toHaveLength(1)
expect(backend.createRequests[0]).toMatchObject({
name: 'My Runway Key',
provider: 'runway',
secret_value: RUNWAY_KEY_VALUE
})
// ...but the value must never be echoed back into the list — the API
// response carries metadata only, so nothing should render it as text.
await expect(page.getByText(RUNWAY_KEY_VALUE)).toHaveCount(0)
// --- DELETE ----------------------------------------------------------
await settingsDialog
.getByRole('button', { name: 'Delete', exact: true })
.click()
const confirmDialog = page
.getByRole('dialog')
.filter({ hasText: 'Delete Secret' })
await confirmDialog
.getByRole('button', { name: 'Delete', exact: true })
.click()
await expect(settingsDialog.getByText('My Runway Key')).toBeHidden()
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
expect(backend.store).toHaveLength(0)
})
test('a non-entitled account never sees the gated providers', async ({
page
}) => {
test.slow()
await mockCloudBoot(page, {
features: BOOT_FEATURES,
settings: BOOT_SETTINGS
})
await bootCloud(page)
// Non-entitled: the server omits runway/gemini from the allowlist.
await mockSecretsBackend(page, [])
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
const settingsDialog = await openSecretsPanel(page)
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
// The add form opens, but its provider dropdown is empty — the gated
// providers must not appear anywhere.
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
const formDialog = page
.getByRole('dialog')
.filter({ hasText: 'Secret Value' })
await expect(formDialog).toBeVisible()
await formDialog.locator('#secret-provider').click()
// Anchor on the opened listbox so the absence assertions below can't pass
// vacuously against a dropdown that never opened.
const providerListbox = page.getByRole('listbox')
await expect(providerListbox).toBeVisible()
// An empty allowlist must yield an empty dropdown. Asserting zero options
// (not just runway/gemini absent) also rejects the fetch-failure fallback,
// where `availableProviders` is null and the default providers would show.
await expect(providerListbox.getByRole('option')).toHaveCount(0)
})
})

View File

@@ -7,6 +7,10 @@ import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceAp
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import {
mockWorkspaceTokenMint,
workspace
} from '@e2e/fixtures/utils/workspaceMocks'
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
@@ -97,6 +101,7 @@ async function mockCloudBoot(page: Page) {
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -1,7 +1,13 @@
import { mergeTests } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
test.describe('Preview as Text node', () => {
test('does not include preview widget values in the API prompt', async ({
@@ -39,4 +45,34 @@ test.describe('Preview as Text node', () => {
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
})
wstest(
'restoring workflow restores state',
{ tag: '@vue-nodes' },
async ({ comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.searchBoxV2.addNode('Preview as Text')
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview as Text')
const preview = node.root.locator('textarea')
await test.step('node previews execution result', async () => {
const id = await comfyPage.vueNodes.getNodeIdByTitle('Preview as Text')
execution.executed('', id, { text: 'massive fennec ears' })
await expect(preview).toHaveValue('massive fennec ears')
})
await test.step('swap to a different workflow and back', async () => {
await comfyPage.menu.topbar.getTab(0).click()
await expect(node.root).toBeHidden()
await comfyPage.menu.topbar.getTab(1).click()
await expect(node.root).toBeVisible()
})
await expect(preview, 'previous output is restored').toHaveValue(
'massive fennec ears'
)
}
)
})

View File

@@ -1,6 +1,11 @@
import { expect, mergeTests } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import type {
Asset,
GetAllSettingsResponse,
GetSettingByIdResponse,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import {
assetRequestIncludesTag,
@@ -8,6 +13,7 @@ import {
} from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { WorkspaceStore } from '@e2e/types/globals'
import {
routeObjectInfoFromSetupApi,
setComboInputOptions
@@ -23,10 +29,11 @@ import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const ossTest = mergeTests(comfyPageFixture, jobsRouteFixture)
const outputHash =
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
const outputVideoHash = 'cloud-video-hash.mp4'
const plainVideoFileName = 'plain_video.mp4'
const graphDropPosition = { x: 500, y: 300 }
const missingMediaUploadObservationMs = 1_000
const missingMediaUploadPollMs = 100
const missingMediaObservationMs = 1_000
const missingMediaPollMs = 100
const emptyMediaLoaderNodes = [
{
nodeType: 'LoadImage',
@@ -60,6 +67,18 @@ const cloudOutputAsset: Asset & { hash?: string } = {
last_access_time: '2026-05-01T00:00:00Z'
}
const cloudOutputVideoAsset: Asset & { hash?: string } = {
id: 'test-output-video-hash-001',
name: 'ComfyUI_00001_.mp4',
hash: outputVideoHash,
size: 4_194_304,
mime_type: 'video/mp4',
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 & { hash?: string } = {
id: 'test-uploaded-video-001',
name: plainVideoFileName,
@@ -92,10 +111,21 @@ interface CloudUploadAssetState {
async function routeCloudBootstrapApis(page: Page) {
await page.route('**/api/settings**', async (route) => {
const completedSurveySetting: GetSettingByIdResponse = {
value: { usage: 'personal' }
}
const allSettings: GetAllSettingsResponse = {}
const body = route
.request()
.url()
.includes('/api/settings/onboarding_survey')
? completedSurveySetting
: allSettings
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
body: JSON.stringify(body)
})
})
await page.route('**/api/userdata**', async (route) => {
@@ -121,7 +151,10 @@ async function routeCloudBootstrapApis(page: Page) {
})
}
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
const cloudOutputTest = createCloudAssetsFixture([
cloudOutputAsset,
cloudOutputVideoAsset
]).extend({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
@@ -225,6 +258,33 @@ function getErrorOverlay(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
}
function isOutputAssetsRequest(url: string) {
return url.includes('/api/assets') && assetRequestIncludesTag(url, 'output')
}
async function waitForOutputAssetsResponse(comfyPage: ComfyPage) {
await comfyPage.page.waitForResponse(
(response) =>
response.status() === 200 && isOutputAssetsRequest(response.url())
)
}
async function getCachedMissingMediaWarningNames(
comfyPage: ComfyPage
): Promise<string[] | null> {
return await comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) return null
return (
workflow.pendingWarnings?.missingMediaCandidates?.map(
(candidate) => candidate.name
) ?? []
)
})
}
async function expectNoErrorsTab(comfyPage: ComfyPage) {
await expect(getErrorOverlay(comfyPage)).toBeHidden()
@@ -327,25 +387,31 @@ async function expectLoadVideoUploading(comfyPage: ComfyPage) {
.toBe(true)
}
async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) {
async function expectNoMissingMediaForObservationWindow(comfyPage: ComfyPage) {
await comfyPage.nextFrame()
await comfyPage.nextFrame()
let sawErrorOverlay = false
let sawCachedMissingMedia = false
const startedAt = Date.now()
await expect
.poll(
async () => {
const cachedMissingMedia =
await getCachedMissingMediaWarningNames(comfyPage)
sawCachedMissingMedia =
sawCachedMissingMedia || !!cachedMissingMedia?.length
sawErrorOverlay =
sawErrorOverlay || (await getErrorOverlay(comfyPage).isVisible())
return (
!sawErrorOverlay &&
Date.now() - startedAt >= missingMediaUploadObservationMs
!sawCachedMissingMedia &&
Date.now() - startedAt >= missingMediaObservationMs
)
},
{
timeout: missingMediaUploadObservationMs + missingMediaUploadPollMs * 5,
intervals: [missingMediaUploadPollMs]
timeout: missingMediaObservationMs + missingMediaPollMs * 5,
intervals: [missingMediaPollMs]
}
)
.toBe(true)
@@ -424,7 +490,7 @@ ossTest.describe(
})
await expectLoadVideoUploading(comfyPage)
await expectNoMissingMediaDuringUpload(comfyPage)
await expectNoMissingMediaForObservationWindow(comfyPage)
await delayedUpload.finishUpload()
await expect(getErrorOverlay(comfyPage)).toBeHidden()
@@ -482,18 +548,30 @@ cloudOutputTest.describe(
cloudOutputTest(
'resolves compact annotated output media from output assets',
async ({ cloudAssetRequests, comfyPage }) => {
async ({ comfyPage }) => {
const outputAssetsResponse = waitForOutputAssetsResponse(comfyPage)
await comfyPage.workflow.loadWorkflow(
'missing/missing_media_cloud_output_annotation'
)
await expect
.poll(() =>
cloudAssetRequests.some((url) =>
assetRequestIncludesTag(url, 'output')
)
)
.toBe(true)
await outputAssetsResponse
await expectNoMissingMediaForObservationWindow(comfyPage)
await expectNoErrorsTab(comfyPage)
}
)
cloudOutputTest(
'resolves subfoldered output video media from flat output asset hashes',
async ({ comfyPage }) => {
const outputAssetsResponse = waitForOutputAssetsResponse(comfyPage)
await comfyPage.workflow.loadWorkflow(
'missing/missing_media_cloud_output_video_subfolder'
)
await outputAssetsResponse
await expectNoMissingMediaForObservationWindow(comfyPage)
await expectNoErrorsTab(comfyPage)
}
)
@@ -529,7 +607,7 @@ cloudUploadRaceTest.describe(
})
await expectLoadVideoUploading(comfyPage)
await expectNoMissingMediaDuringUpload(comfyPage)
await expectNoMissingMediaForObservationWindow(comfyPage)
markUploadedCloudAssetAvailable()
await delayedUpload.finishUpload()

View File

@@ -0,0 +1,108 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { DeprecationWarningsSidebarTab } from '@e2e/fixtures/components/SidebarTab'
test.describe('Deprecation warnings sidebar', { tag: '@ui' }, () => {
test.describe('with DevMode disabled', () => {
test.use({ initialSettings: { 'Comfy.DevMode': false } })
test('does not render the sidebar icon', async ({ comfyPage }) => {
const tab = new DeprecationWarningsSidebarTab(comfyPage.page)
await expect(tab.tabButton).toHaveCount(0)
expect(
await comfyPage.page.evaluate(() => !!window.__reportDeprecation)
).toBe(false)
})
})
test.describe('with DevMode enabled', () => {
test.use({ initialSettings: { 'Comfy.DevMode': true } })
test('records a new deprecation, shows it in the panel, dedupes repeats', async ({
comfyPage
}) => {
const tab = new DeprecationWarningsSidebarTab(comfyPage.page)
await expect(tab.tabButton).toBeVisible()
const uniqueMessage = `e2e-${Date.now()} is deprecated.`
await tab.emitDeprecation(uniqueMessage)
await tab.open()
await expect(tab.panel).toBeVisible()
const row = tab.rowFor(uniqueMessage)
await expect(row).toBeVisible()
await tab.emitDeprecation(uniqueMessage)
await expect(row.getByTestId('deprecation-warning-badge')).toHaveText('2')
await expect(tab.rowFor(uniqueMessage)).toHaveCount(1)
})
test('opening the panel clears the unseen badge', async ({ comfyPage }) => {
const tab = new DeprecationWarningsSidebarTab(comfyPage.page)
await expect(tab.tabButton).toBeVisible()
// Mark any pre-existing warnings as seen.
await tab.open()
await expect(tab.panel).toBeVisible()
await tab.close()
await expect(tab.panel).toBeHidden()
await expect(tab.badge).toHaveCount(0)
await tab.emitDeprecation(`e2e-badge-${Date.now()} is deprecated.`)
await expect(tab.badge).toHaveText('1')
await tab.open()
await expect(tab.badge).toHaveCount(0)
// Warnings arriving while the panel is open must not resurface the badge.
const liveMessage = `e2e-badge-live-${Date.now()} is deprecated.`
await tab.emitDeprecation(liveMessage)
await expect(tab.rowFor(liveMessage)).toBeVisible()
await tab.close()
await expect(tab.panel).toBeHidden()
await expect(tab.badge).toHaveCount(0)
})
test('clear all empties the panel and shows the empty state', async ({
comfyPage
}) => {
const tab = new DeprecationWarningsSidebarTab(comfyPage.page)
await tab.open()
await expect(tab.panel).toBeVisible()
const uniqueMessage = `e2e-clearAll-${Date.now()} is deprecated.`
await tab.emitDeprecation(uniqueMessage)
await expect(tab.rowFor(uniqueMessage)).toBeVisible()
// Tool-buttons slot is hover/focus-gated; hover the panel to reveal it.
await tab.panel.hover()
await tab.clearAllButton.click()
await expect(tab.list).toHaveCount(0)
await expect(tab.emptyState).toBeVisible()
await expect(tab.clearAllButton).toHaveCount(0)
})
test('clamps the toolbar badge at "9+" when many unseen warnings pile up', async ({
comfyPage
}) => {
const tab = new DeprecationWarningsSidebarTab(comfyPage.page)
await tab.open()
await expect(tab.panel).toBeVisible()
await tab.close()
await expect(tab.panel).toBeHidden()
const stamp = Date.now()
for (let i = 0; i < 12; i++) {
await tab.emitDeprecation(`e2e-${stamp}-${i} is deprecated.`)
}
await expect(tab.badge).toHaveText('9+')
})
})
})

View File

@@ -42,6 +42,9 @@ declare global {
__capturedMessages?: CapturedMessages
__appReadiness?: AppReadiness
/** For use in tests to inject a synthetic deprecation warning. */
__reportDeprecation?: (message: string) => void
/**
* WebSocket store used by test fixtures for mocking WebSocket connections.
* @see browser_tests/fixtures/ws.ts

View File

@@ -0,0 +1,120 @@
# 11. Derived Credential Lifecycle for Cloud Auth
Date: 2026-07-09
## Status
Proposed
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
## Context
Cloud authentication derives several short-lived credentials from a single
source of truth — the Firebase identity (ID token):
- the **workspace JWT** minted by exchanging the Firebase token (`workspaceAuthStore`),
- the **session cookie** created by POSTing the Firebase token to `/auth/session`
(`useSessionCookie`),
- and consumer state gated on those credentials, such as **subscription status**
(`useSubscription`).
A recurring class of production bugs traces back to how these derived credentials
are kept fresh rather than to any single code path:
- **FE-613** — workspace token exchange is not reactive to Firebase auth state.
Its refresh relies on a `setTimeout` timer that browsers throttle in background
tabs, so a backgrounded session serves an expired workspace JWT and every cloud
call 401s until reload.
- **Workspace/personal oscillation** (PR #13511) — when a valid workspace token is
momentarily absent, `getAuthHeader`/`getAuthToken` silently downgraded to the
personal Firebase token, so requests authenticated as the wrong identity.
- **Run-button toggle loop** (Slack, related to FE-1072) — a Firebase token-refresh
burst on wake/network-swap fans out into concurrent, undeduped subscription
fetches racing an in-flight session-cookie rotation; some land pre-rotation and
return 401/empty, flapping `subscriptionStatus` and the run button.
These are not independent defects. They are symptoms of one design shape: **each
derived credential has its own ad-hoc refresh lifecycle, driven by timers or
one-shot events rather than the source identity, with no coalescing of concurrent
refreshes and with silent fallback to a different identity or a stale value on
failure.** Any credential built this way can go stale, stampede, or downgrade.
## Decision
Treat every derived credential as a pure function of the Firebase identity, and
require all of them to obey the same lifecycle invariants. New auth code must
satisfy these; existing code migrates toward them incrementally.
1. **Single source of truth.** The Firebase identity is authoritative. Workspace
JWT and session cookie are derivations of it, never independent state that can
drift from it.
2. **Valid-on-read.** A caller asking for a credential gets a currently-valid one
or a definitive failure — never a known-expired one. Validity is checked at the
point of use (expiry-aware), not assumed because a background timer _should_
have refreshed. Timers may be an optimization, never the guarantee.
3. **Single-flight.** Concurrent requests for the same credential share one
in-flight mint/refresh. A refresh burst collapses to a single network call.
4. **Fail-closed, never downgrade.** If the correct-scope credential cannot be
obtained, fail the request. Never silently substitute a different identity or
scope (e.g. personal token for a workspace request).
5. **Bounded reactive retry.** Invalidation is driven by the source identity
(`onIdTokenChanged`), not by polling or wall-clock timers alone. A `401` on a
derived credential triggers at most one re-mint and one retry, then surfaces
the error.
6. **Explicit scope.** A credential names the identity/workspace it is for.
Coalesced results are verified against the requested scope before use.
PR #13511 is the first increment: workspace-token recovery is now valid-on-read,
single-flight, fail-closed, and reconciles a revoked workspace instead of
downgrading; subscription-status and session-cookie creation are now
single-flight so a refresh burst can no longer flap them. It intentionally does
**not** yet add the `onIdTokenChanged` subscription FE-613 proposes — recovery is
lazy (on read) rather than reactive (on refresh). Invariant 5 is the remaining
gap and is tracked by FE-950 (Unified Cloud Auth) and FE-963 (reactive 401
re-mint + single retry).
Alternatives considered:
- **Layer more defensive checks per call site.** Rejected: this is what produced
the current state — correctness that depends on every caller remembering to
guard is the defect, not the fix.
- **A single reactive credential store subscribing to Firebase, replacing all
three ad-hoc lifecycles at once.** Deferred, not rejected: it is the target
end-state, but a big-bang rewrite of live auth is too risky. We migrate under
these invariants incrementally instead.
## Consequences
### Positive
- Whole categories of failure become structurally hard rather than individually
patched: stale-on-wake (invariant 2), refresh stampede (3), wrong-identity
requests (4).
- New auth code has a single checklist to satisfy, and reviewers a single rubric
to apply.
- Establishes a shared vocabulary (valid-on-read, single-flight, fail-closed) for
reasoning about auth changes.
### Negative
- Fail-closed surfaces auth failures that silent downgrade previously masked; some
transient conditions now show errors instead of degrading quietly, so
transient-vs-permanent classification must be correct.
- The invariants are not yet fully realized. Until invariant 5 lands, recovery is
lazy and a backgrounded tab still relies on the next read to heal, leaving a
visible gap against FE-613's reactive ideal.
- Existing lifecycles remain non-uniform during migration, so the mental model is
"target vs. current" until the reactive credential store exists.
## Notes
- Related: [ADR-0003](0003-crdt-based-layout-system.md) is unrelated in domain but
shares the philosophy of designing invariants that make illegal states
unrepresentable rather than guarding against them per call site.
- Tickets: FE-613, FE-950, FE-963, FE-1072. PR: #13511.

View File

@@ -20,6 +20,7 @@ An Architecture Decision Record captures an important architectural decision mad
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| [0009](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Subgraph Promoted Widgets Use Linked Inputs | Proposed | 2026-05-05 |
| [0010](0010-remove-nx-orchestration.md) | Remove Nx Orchestration | Accepted | 2026-05-19 |
| [0011](0011-derived-credential-lifecycle.md) | Derived Credential Lifecycle for Cloud Auth | Proposed | 2026-07-09 |
## Creating a New ADR

View File

@@ -4,11 +4,12 @@ This guide provides an overview of testing approaches used in the ComfyUI Fronte
## Testing Documentation
Documentation for unit tests is organized into three guides:
Documentation for unit tests is organized into four guides:
- [Component Testing](./component-testing.md) - How to test Vue components
- [Unit Testing](./unit-testing.md) - How to test utility functions, composables, and other non-component code
- [Store Testing](./store-testing.md) - How to test Pinia stores specifically
- [LiteGraph Testing](./litegraph-testing.md) - How to test LiteGraph graph, node, link, and workflow behavior
## Testing Structure

View File

@@ -0,0 +1,9 @@
# LiteGraph Testing Guide
This guide covers test patterns for LiteGraph graph, node, link, subgraph, and workflow behavior in ComfyUI Frontend.
## Shared Factories
Reuse shared factories in `src/utils/__tests__/litegraphTestUtils.ts` instead of hand-rolling LiteGraph node, canvas, graph, subgraph, or workflow builders.
Use real LiteGraph instances or shared factories when they exercise behavior directly. Avoid mocking LiteGraph classes unless the test is intentionally checking a seam outside LiteGraph itself.

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.47.6",
"version": "1.48.1",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -31,6 +31,7 @@
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
"format:check": "oxfmt --check",
"format": "oxfmt --write",
"gen:deprecations": "tsx scripts/generate-deprecations-doc.ts",
"json-schema": "tsx scripts/generate-json-schema.ts",
"knip:no-cache": "knip",
"knip": "knip --cache",

View File

@@ -414,15 +414,15 @@ describe('formatUtil', () => {
})
describe('isPreviewableMediaType', () => {
it('returns true for image/video/audio/3D', () => {
it('returns true for image/video/audio/3D/text', () => {
expect(isPreviewableMediaType('image')).toBe(true)
expect(isPreviewableMediaType('video')).toBe(true)
expect(isPreviewableMediaType('audio')).toBe(true)
expect(isPreviewableMediaType('3D')).toBe(true)
expect(isPreviewableMediaType('text')).toBe(true)
})
it('returns false for text/other', () => {
expect(isPreviewableMediaType('text')).toBe(false)
it('returns false for other', () => {
expect(isPreviewableMediaType('other')).toBe(false)
})
})

View File

@@ -677,12 +677,7 @@ export function getMediaTypeFromFilename(
}
export function isPreviewableMediaType(mediaType: MediaType): boolean {
return (
mediaType === 'image' ||
mediaType === 'video' ||
mediaType === 'audio' ||
mediaType === '3D'
)
return mediaType !== 'other'
}
export function formatTime(seconds: number): string {

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Google Gemini">
<path d="M12 1c.6 5.4 4.6 9.4 10 10-5.4.6-9.4 4.6-10 10-.6-5.4-4.6-9.4-10-10 5.4-.6 9.4-4.6 10-10z" fill="#4285F4"/>
</svg>

After

Width:  |  Height:  |  Size: 248 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Runway">
<rect width="24" height="24" rx="5" fill="#6E56CF"/>
<path d="M9.5 8.2v7.6l6.3-3.8z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import type { DeprecationEntry } from '../src/platform/dev/deprecations'
import { renderDeprecationsMdx } from './generate-deprecations-doc'
const SAMPLE: Record<string, DeprecationEntry> = {
'b.thing': {
source: 'beta',
message: 'thing is deprecated.',
suggestion: 'Use otherThing.',
since: '1.0',
removeBy: '2.0',
docsUrl: 'https://docs.example/thing'
},
'a.first': {
source: 'alpha',
message: 'first is gone.'
},
'a.second': {
source: 'alpha',
message: 'second is gone | really.'
}
}
describe('renderDeprecationsMdx', () => {
const mdx = renderDeprecationsMdx(SAMPLE)
it('emits Mintlify frontmatter', () => {
expect(mdx.startsWith('---\ntitle: "Deprecations"')).toBe(true)
})
it('includes an intro explaining the deprecation policy before the tables', () => {
expect(mdx).toContain('Whilst we try to keep the frontend API stable')
expect(mdx.indexOf('Whilst we try')).toBeLessThan(mdx.indexOf('## alpha'))
})
it('documents the dev-mode-gated Deprecation Warnings panel', () => {
expect(mdx).toContain('## Deprecation Warnings panel')
expect(mdx).toContain('dev mode')
})
it('groups entries by source, sorted alphabetically', () => {
expect(mdx.indexOf('## alpha')).toBeLessThan(mdx.indexOf('## beta'))
})
it('renders a row per entry with replacement, version and docs columns', () => {
expect(mdx).toContain(
'| thing is deprecated. | Use otherThing. | 1.0 | 2.0 | [Guide](https://docs.example/thing) |'
)
})
it('uses a dash for missing optional fields', () => {
expect(mdx).toContain('| first is gone. | - | - | - | - |')
})
it('escapes pipe characters so the table is not broken', () => {
expect(mdx).toContain('second is gone \\| really.')
})
it('omits optional columns when no entry anywhere has a value', () => {
const minimal = renderDeprecationsMdx({
'a.first': { source: 'alpha', message: 'first is gone.' },
'a.second': { source: 'alpha', message: 'second is gone.' }
})
expect(minimal).toContain('| Deprecated | Replacement |')
expect(minimal).not.toContain('Since')
expect(minimal).not.toContain('Removed in')
expect(minimal).not.toContain('Docs')
})
it('keeps a column when at least one entry in any table has a value', () => {
const mixed = renderDeprecationsMdx({
'a.first': { source: 'alpha', message: 'first is gone.' },
'b.second': { source: 'beta', message: 'second is gone.', since: '1.0' }
})
expect(mixed).toContain('| Deprecated | Replacement | Since |')
expect(mixed).not.toContain('Removed in')
})
})

View File

@@ -0,0 +1,119 @@
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import type { DeprecationEntry } from '../src/platform/dev/deprecations'
import { DEPRECATIONS } from '../src/platform/dev/deprecations'
const OUTPUT_PATH = './docs/deprecations.mdx'
const FRONTMATTER = `---
title: "Deprecations"
description: "Deprecated frontend APIs and their replacements."
---`
const INTRO = `Whilst we try to keep the frontend API stable and backwards compatible, there are times where functionality must change - to fix bugs, improve performance, or make room for new features.
When that happens we deprecate the old API rather than removing it outright: it keeps working until the removal date, but logs a warning pointing you at the replacement. This page lists the currently deprecated APIs so you can migrate your custom nodes and extensions ahead of their removal.
## Deprecation Warnings panel
To see which deprecated APIs are actually being used in your current session, enable the **"Enable dev mode options"** setting (Settings → search "dev mode"). A **Deprecation Warnings** panel then appears in the main sidebar, listing each warning reported this session - with the suggested replacement and how many times it occurred.
Alternatively, every deprecation is logged to the browser DevTools console as it occurs, prefixed with a \`[DEPRECATED]\` tag.`
interface Column {
header: string
cell: (entry: DeprecationEntry) => string | undefined
}
const REQUIRED_COLUMNS: Column[] = [
{ header: 'Deprecated', cell: (entry) => entry.message },
{ header: 'Replacement', cell: (entry) => entry.suggestion }
]
const OPTIONAL_COLUMNS: Column[] = [
{ header: 'Since', cell: (entry) => entry.since },
{ header: 'Removed in', cell: (entry) => entry.removeBy },
{
header: 'Docs',
cell: (entry) => (entry.docsUrl ? `[Guide](${entry.docsUrl})` : undefined)
}
]
function escapeCell(value: string | undefined): string {
if (!value) return '-'
return value.replace(/\|/g, '\\|').replace(/\n/g, ' ')
}
function activeColumns(entries: DeprecationEntry[]): Column[] {
return [
...REQUIRED_COLUMNS,
...OPTIONAL_COLUMNS.filter((column) =>
entries.some((entry) => column.cell(entry))
)
]
}
function groupBySource(
deprecations: Record<string, DeprecationEntry>
): Map<string, DeprecationEntry[]> {
const groups = new Map<string, DeprecationEntry[]>()
for (const entry of Object.values(deprecations)) {
const group = groups.get(entry.source) ?? []
group.push(entry)
groups.set(entry.source, group)
}
return groups
}
function renderRow(cells: string[]): string {
return `| ${cells.join(' | ')} |`
}
function renderSection(
source: string,
entries: DeprecationEntry[],
columns: Column[]
): string {
const rows = entries.map((entry) =>
renderRow(columns.map((column) => escapeCell(column.cell(entry))))
)
return [
`## ${source}`,
'',
renderRow(columns.map((column) => column.header)),
renderRow(columns.map(() => '---')),
...rows
].join('\n')
}
export function renderDeprecationsMdx(
deprecations: Record<string, DeprecationEntry>
): string {
const columns = activeColumns(Object.values(deprecations))
const sections = [...groupBySource(deprecations).entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([source, entries]) => renderSection(source, entries, columns))
return [
FRONTMATTER,
'',
'{/* Generated by scripts/generate-deprecations-doc.ts. Do not edit by hand. */}',
'',
INTRO,
'',
sections.join('\n\n'),
''
].join('\n')
}
function main(): void {
const mdx = renderDeprecationsMdx(DEPRECATIONS)
fs.writeFileSync(path.resolve(OUTPUT_PATH), mdx)
}
const invokedDirectly =
process.argv[1] &&
fileURLToPath(import.meta.url) === path.resolve(process.argv[1])
if (invokedDirectly) main()

View File

@@ -449,6 +449,12 @@ describe('shouldPreventRekaDismiss', () => {
expect(event.defaultPrevented).toBe(false)
})
it('focus-outside never dismisses when dismissOnFocusOutside is false', () => {
const event = makeEvent(document.body)
onRekaFocusOutside(event, { dismissOnFocusOutside: false })
expect(event.defaultPrevented).toBe(true)
})
it('focus-outside on a sibling Reka portal does not dismiss the parent', () => {
const portal = document.createElement('div')
portal.setAttribute('role', 'dialog')

View File

@@ -32,7 +32,9 @@
dialogStore.activeKey === item.key
)
"
@focus-outside="onRekaFocusOutside"
@focus-outside="
(e) => onRekaFocusOutside(e, item.dialogComponentProps)
"
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
>
<template v-if="item.dialogComponentProps.headless">

View File

@@ -53,7 +53,22 @@ export function onRekaPointerDownOutside(
// nested Reka or PrimeVue dialog teleported to body). Without this guard a
// non-modal Reka dialog would dismiss itself the moment a nested dialog
// receives focus.
export function onRekaFocusOutside(event: OutsideEvent) {
//
// A container dialog (e.g. Settings) that hosts nested confirm/edit dialogs can
// also lose focus to an ordinary app element — not just a portal — when a
// nested dialog closes and the element it focused was removed (deleting the
// selected row). That programmatic focus shift is not a dismiss intent, so such
// a dialog opts out of focus-outside dismissal entirely via
// `dismissOnFocusOutside: false`; it still dismisses on escape or an outside
// pointer.
export function onRekaFocusOutside(
event: OutsideEvent,
options: { dismissOnFocusOutside?: boolean } = {}
) {
if (options.dismissOnFocusOutside === false) {
event.preventDefault()
return
}
if (isInsideOverlay(event.detail.originalEvent.target)) {
event.preventDefault()
}

View File

@@ -170,6 +170,7 @@ import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { useCommandStore } from '@/stores/commandStore'
import { electronAPI } from '@/utils/envUtil'
import { formatVersionAnchor } from '@/utils/formatUtil'
import { formatRelativeTime } from '@/utils/relativeTime'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
@@ -188,16 +189,6 @@ interface MenuItem {
showExternalIcon?: boolean
}
// Constants
const TIME_UNITS = {
MINUTE: 60 * 1000,
HOUR: 60 * 60 * 1000,
DAY: 24 * 60 * 60 * 1000,
WEEK: 7 * 24 * 60 * 60 * 1000,
MONTH: 30 * 24 * 60 * 60 * 1000,
YEAR: 365 * 24 * 60 * 60 * 1000
} as const
const SUBMENU_CONFIG = {
DELAY_MS: 100,
OFFSET_PX: 8,
@@ -491,29 +482,9 @@ const calculateSubmenuPosition = (button: HTMLElement): CSSProperties => {
}
const formatReleaseDate = (dateString?: string): string => {
if (!dateString) return 'date'
const date = new Date(dateString)
const now = new Date()
const diffTime = Math.abs(now.getTime() - date.getTime())
const timeUnits = [
{ unit: TIME_UNITS.YEAR, key: 'yearsAgo' },
{ unit: TIME_UNITS.MONTH, key: 'monthsAgo' },
{ unit: TIME_UNITS.WEEK, key: 'weeksAgo' },
{ unit: TIME_UNITS.DAY, key: 'daysAgo' },
{ unit: TIME_UNITS.HOUR, key: 'hoursAgo' },
{ unit: TIME_UNITS.MINUTE, key: 'minutesAgo' }
]
for (const { unit, key } of timeUnits) {
const value = Math.floor(diffTime / unit)
if (value > 0) {
return t(`g.relativeTime.${key}`, { count: value })
}
}
return t('g.relativeTime.now')
if (!dateString) return t('helpCenter.missingDate')
const diffMs = Math.abs(Date.now() - new Date(dateString).getTime())
return formatRelativeTime(t, diffMs)
}
// Event Handlers

View File

@@ -21,7 +21,7 @@
<div ref="topToolbarRef" :class="groupClasses">
<ComfyMenuButton />
<SidebarIcon
v-for="tab in tabs"
v-for="tab in topTabs"
:key="tab.id"
:icon="tab.icon"
:icon-badge="tab.iconBadge"
@@ -37,6 +37,19 @@
</div>
<div ref="bottomToolbarRef" class="mt-auto" :class="groupClasses">
<SidebarIcon
v-for="tab in bottomTabs"
:key="tab.id"
:icon="tab.icon"
:icon-badge="tab.iconBadge"
:tooltip="tab.tooltip"
:tooltip-suffix="getTabTooltipSuffix(tab)"
:label="tab.label || tab.title"
:is-small="isSmall"
:selected="tab.id === selectedTab?.id"
:class="tab.id + '-tab-button'"
@click="onTabClick(tab)"
/>
<SidebarLogoutIcon
v-if="userStore.isMultiUserServer"
:is-small="isSmall"
@@ -121,6 +134,12 @@ const isConnected = computed(
)
const tabs = computed(() => workspaceStore.getSidebarTabs())
const topTabs = computed(() =>
tabs.value.filter((tab) => tab.placement !== 'bottom')
)
const bottomTabs = computed(() =>
tabs.value.filter((tab) => tab.placement === 'bottom')
)
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
/**

View File

@@ -0,0 +1,333 @@
import { createTestingPinia } from '@pinia/testing'
import { cleanup, render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import DeprecationWarningsTab from '@/components/sidebar/tabs/DeprecationWarningsTab.vue'
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
function renderTab() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
return {
user: userEvent.setup(),
...render(DeprecationWarningsTab, { global: { plugins: [i18n] } })
}
}
describe('DeprecationWarningsTab', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
// Drain warnings buffered during module-load imports.
useDeprecationWarningsStore().clear()
})
afterEach(() => {
cleanup()
})
it('renders the empty state when there are no warnings', () => {
renderTab()
screen.getByText('deprecationWarnings.empty')
expect(screen.queryByTestId('deprecation-warnings-list')).toBeNull()
})
it('renders each warning with its message, suggestion, and source', () => {
const store = useDeprecationWarningsStore()
store.report({
message: 'foo() is deprecated.',
suggestion: 'Use bar() instead.',
source: 'lib'
})
renderTab()
screen.getByText('foo() is deprecated.')
screen.getByText('Use bar() instead.')
screen.getByText('lib')
})
it('surfaces the extension and detail as distinct fields from the message', () => {
const store = useDeprecationWarningsStore()
store.report({
message: 'Use of defaultInput on a required input.',
source: 'nodeDef',
extension: 'custom_nodes.devtools',
detail: 'DevToolsNode.int_input'
})
renderTab()
screen.getByText('Use of defaultInput on a required input.')
screen.getByText('nodeDef')
screen.getByText('custom_nodes.devtools')
screen.getByText('DevToolsNode.int_input')
})
it('shows the extension filter only when more than one extension is present', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'only ext.', source: 's', extension: 'ext.alpha' })
const single = renderTab()
expect(
screen.queryByRole('button', {
name: 'deprecationWarnings.filterByExtension'
})
).toBeNull()
single.unmount()
store.report({
message: 'second ext.',
source: 's',
extension: 'ext.bravo'
})
renderTab()
screen.getByRole('button', {
name: 'deprecationWarnings.filterByExtension'
})
})
it('filters the list to the selected extension', async () => {
const store = useDeprecationWarningsStore()
store.report({
message: 'from alpha',
source: 'nodeDef',
extension: 'ext.alpha'
})
store.report({
message: 'from bravo',
source: 'nodeDef',
extension: 'ext.bravo'
})
const { user } = renderTab()
screen.getByText('from alpha')
screen.getByText('from bravo')
await user.click(
screen.getByRole('button', {
name: 'deprecationWarnings.filterByExtension'
})
)
await user.click(await screen.findByRole('option', { name: 'ext.alpha' }))
screen.getByText('from alpha')
expect(screen.queryByText('from bravo')).toBeNull()
})
it('filters to warnings without an extension via the Unknown option', async () => {
const store = useDeprecationWarningsStore()
store.report({
message: 'has ext',
source: 'nodeDef',
extension: 'ext.alpha'
})
store.report({ message: 'no ext', source: 'litegraph' })
const { user } = renderTab()
screen.getByText('has ext')
screen.getByText('no ext')
await user.click(
screen.getByRole('button', {
name: 'deprecationWarnings.filterByExtension'
})
)
await user.click(
await screen.findByRole('option', {
name: 'deprecationWarnings.unknownExtension'
})
)
screen.getByText('no ext')
expect(screen.queryByText('has ext')).toBeNull()
})
it('clears the extension filter when warnings are cleared', async () => {
const store = useDeprecationWarningsStore()
store.report({
message: 'from alpha',
source: 'nodeDef',
extension: 'ext.alpha'
})
store.report({
message: 'from bravo',
source: 'nodeDef',
extension: 'ext.bravo'
})
const { user } = renderTab()
await user.click(
screen.getByRole('button', {
name: 'deprecationWarnings.filterByExtension'
})
)
await user.click(await screen.findByRole('option', { name: 'ext.bravo' }))
expect(screen.queryByText('from alpha')).toBeNull()
await user.click(
screen.getByRole('button', { name: 'deprecationWarnings.clearAll' })
)
store.report({
message: 'fresh alpha',
source: 'nodeDef',
extension: 'ext.alpha'
})
await screen.findByText('fresh alpha')
expect(screen.queryByText('deprecationWarnings.noMatches')).toBeNull()
})
it('drops a stale extension filter when its warnings are removed', async () => {
const store = useDeprecationWarningsStore()
store.report({
message: 'from alpha',
source: 'nodeDef',
extension: 'ext.alpha'
})
store.report({
message: 'from bravo',
source: 'nodeDef',
extension: 'ext.bravo'
})
const { user } = renderTab()
await user.click(
screen.getByRole('button', {
name: 'deprecationWarnings.filterByExtension'
})
)
await user.click(await screen.findByRole('option', { name: 'ext.bravo' }))
expect(screen.queryByText('from alpha')).toBeNull()
for (const warning of store.warnings.filter(
(w) => w.extension === 'ext.bravo'
)) {
store.remove(warning.key)
}
await screen.findByText('from alpha')
expect(screen.queryByText('deprecationWarnings.noMatches')).toBeNull()
})
it('renders a migration-guide link only when the warning has a docsUrl', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'no link here.' })
store.report({
message: 'has a guide.',
docsUrl: 'https://docs.example/guide'
})
renderTab()
const links = screen.getAllByRole('link', {
name: 'deprecationWarnings.learnMore'
})
expect(links).toHaveLength(1)
expect(links[0]).toHaveAttribute('href', 'https://docs.example/guide')
expect(links[0]).toHaveAttribute('target', '_blank')
expect(links[0]).toHaveAttribute('rel', 'noopener noreferrer')
})
it('shows the per-warning count badge only when the warning has fired more than once', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'once' })
store.report({ message: 'twice' })
store.report({ message: 'twice' })
renderTab()
const badge = screen.getByTestId('deprecation-warning-badge')
expect(badge.textContent).toBe('2')
expect(badge.getAttribute('aria-label')).toBe(
'deprecationWarnings.occurrenceCount'
)
expect(screen.queryAllByTestId('deprecation-warning-badge')).toHaveLength(1)
})
it('marks all warnings as seen on mount', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'one' })
store.report({ message: 'two' })
expect(store.unseenCount).toBe(2)
renderTab()
expect(store.unseenCount).toBe(0)
})
it('marks warnings seen as they arrive while the panel is open', async () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'before mount' })
renderTab()
store.report({ message: 'arrived while open' })
await screen.findByText('arrived while open')
expect(store.unseenCount).toBe(0)
})
it('re-renders relative timestamps as time advances', async () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-01-01T12:00:00Z'))
const store = useDeprecationWarningsStore()
store.report({ message: 'aged-out' })
renderTab()
screen.getByText('g.relativeTime.now')
await vi.advanceTimersByTimeAsync(2 * 60_000)
screen.getByText('g.relativeTime.minutesAgo')
} finally {
vi.useRealTimers()
}
})
it('removes a single warning via its per-warning clear action', async () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'keep me' })
store.report({ message: 'remove me' })
renderTab()
const menus = screen.getAllByRole('button', {
name: 'deprecationWarnings.moreOptions'
})
// reka's Popover trigger doesn't toggle through userEvent's synthesized
// events under happy-dom, so drive the menu with native clicks.
// Warnings render newest-first, so the first menu targets 'remove me'.
menus[0].click()
;(await screen.findByText('deprecationWarnings.clear')).click()
await waitFor(() => {
expect(screen.queryByText('remove me')).toBeNull()
})
screen.getByText('keep me')
})
it('clear-all button empties the store and shows the empty state', async () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'one' })
const { user } = renderTab()
const clearAll = screen.getByRole('button', {
name: 'deprecationWarnings.clearAll'
})
await user.click(clearAll)
expect(store.warnings).toEqual([])
screen.getByText('deprecationWarnings.empty')
})
})

View File

@@ -0,0 +1,244 @@
<template>
<SidebarTabTemplate
data-testid="deprecation-warnings-sidebar"
:title="t('deprecationWarnings.title')"
>
<template #tool-buttons>
<Button
v-if="store.warnings.length > 0"
variant="muted-textonly"
size="md"
class="text-sm"
@click="clearWarnings"
>
<span>{{ t('deprecationWarnings.clearAll') }}</span>
</Button>
</template>
<template #body>
<NoResultsPlaceholder
v-if="store.warnings.length === 0"
icon="pi pi-check-circle"
:title="t('deprecationWarnings.emptyTitle')"
:message="t('deprecationWarnings.empty')"
/>
<template v-else>
<div
v-if="extensionOptions.length > 1"
class="flex justify-end px-3 pt-2"
>
<MultiSelect
v-model="selectedExtensions"
:options="extensionOptions"
:label="t('deprecationWarnings.filterByExtension')"
size="lg"
show-search-box
show-clear-button
class="w-fit"
/>
</div>
<NoResultsPlaceholder
v-if="filteredWarnings.length === 0"
icon="pi pi-filter-slash"
:title="t('deprecationWarnings.noMatchesTitle')"
:message="t('deprecationWarnings.noMatches')"
/>
<ul
v-else
class="mt-2 flex flex-col gap-2 px-3 pb-2"
data-testid="deprecation-warnings-list"
>
<li
v-for="warning in filteredWarnings"
:key="warning.key"
class="relative flex flex-col gap-2 rounded-md border border-interface-stroke p-4"
>
<div class="absolute top-2 right-2">
<Popover :show-arrow="false" :entries="warningMenuItems(warning)">
<template #button>
<Button
variant="muted-textonly"
size="md"
:aria-label="t('deprecationWarnings.moreOptions')"
>
<i class="icon-[lucide--more-horizontal] size-4" />
</Button>
</template>
</Popover>
</div>
<div class="flex items-start justify-between gap-2 pr-8">
<div
class="min-w-0 flex-1 text-sm wrap-break-word text-text-primary"
>
{{ warning.message }}
</div>
<div
class="flex shrink-0 items-center gap-2 text-sm text-text-secondary"
>
<span
:title="new Date(warning.lastSeenAt).toLocaleString(locale)"
>
{{
formatRelativeTime(t, Math.max(0, now - warning.lastSeenAt))
}}
</span>
<Badge
v-if="warning.count > 1"
v-bind="occurrenceAttrs(warning.count)"
data-testid="deprecation-warning-badge"
severity="contrast"
variant="circle"
:label="formatBadgeCount(warning.count, 9)"
class="size-auto min-w-5 px-1.5 py-0.5 text-xs font-medium"
/>
</div>
</div>
<div
v-if="metaParts(warning).length"
class="flex flex-wrap items-center gap-1.5 font-mono text-xs wrap-break-word text-text-secondary"
>
<template v-for="(part, i) in metaParts(warning)" :key="i">
<span v-if="i > 0" aria-hidden="true" class="opacity-50"
>|</span
>
<span>{{ part }}</span>
</template>
</div>
<div
v-if="warning.suggestion || warning.docsUrl"
class="my-1 flex flex-col gap-2 rounded-md bg-node-component-surface p-3 text-sm"
>
<div v-if="warning.suggestion" class="wrap-break-word">
<span class="font-medium text-text-primary">{{
t('deprecationWarnings.suggestionLabel')
}}</span>
<span class="ml-1 text-text-secondary">{{
warning.suggestion
}}</span>
</div>
<Button
v-if="warning.docsUrl"
as="a"
variant="link"
size="sm"
:href="warning.docsUrl"
target="_blank"
rel="noopener noreferrer"
class="w-fit gap-2 px-0 text-sm underline"
>
<i class="icon-[lucide--book-open] size-4" />
<span>{{ t('deprecationWarnings.learnMore') }}</span>
</Button>
</div>
</li>
</ul>
</template>
</template>
</SidebarTabTemplate>
</template>
<script setup lang="ts">
import { useIntervalFn } from '@vueuse/core'
import type { MenuItem } from 'primevue/menuitem'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Badge from '@/components/common/Badge.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import Popover from '@/components/ui/Popover.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import Button from '@/components/ui/button/Button.vue'
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
import type { SelectOption } from '@/components/ui/select/types'
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
import { formatBadgeCount } from '@/utils/numberUtil'
import { formatRelativeTime } from '@/utils/relativeTime'
const { t, locale } = useI18n()
const store = useDeprecationWarningsStore()
const UNKNOWN_EXTENSION = '__unknown__'
const selectedExtensions = ref<SelectOption[]>([])
const extensionOptions = computed<SelectOption[]>(() => {
const names = new Set<string>()
let hasUnknown = false
for (const warning of store.warnings) {
if (warning.extension) names.add(warning.extension)
else hasUnknown = true
}
const options = [...names].sort().map((name) => ({ name, value: name }))
if (hasUnknown) {
options.push({
name: t('deprecationWarnings.unknownExtension'),
value: UNKNOWN_EXTENSION
})
}
return options
})
// Prune selections whose option disappeared (e.g. its warnings were removed),
// otherwise a stale filter keeps hiding warnings once the control is hidden.
watch(extensionOptions, (options) => {
const valid = new Set(options.map((option) => option.value))
selectedExtensions.value = selectedExtensions.value.filter((option) =>
valid.has(option.value)
)
})
const filteredWarnings = computed(() => {
if (selectedExtensions.value.length === 0) return store.warnings
const selected = new Set(selectedExtensions.value.map((o) => o.value))
return store.warnings.filter((warning) =>
warning.extension != null
? selected.has(warning.extension)
: selected.has(UNKNOWN_EXTENSION)
)
})
const now = ref(Date.now())
useIntervalFn(() => {
now.value = Date.now()
}, 30_000)
function occurrenceAttrs(count: number) {
const text = t('deprecationWarnings.occurrenceCount', count)
return { 'aria-label': text, title: text }
}
function metaParts(warning: {
extension?: string
source?: string
detail?: string
}): string[] {
return [warning.extension, warning.source, warning.detail].filter(
(part): part is string => !!part
)
}
function clearWarnings() {
store.clear()
selectedExtensions.value = []
}
function warningMenuItems(warning: { key: string }): MenuItem[] {
return [
{
label: t('deprecationWarnings.clear'),
icon: 'icon-[lucide--trash-2] size-4',
command: () => store.remove(warning.key)
}
]
}
// watch, not watchEffect: markAllSeen mutates the watched source, and a
// watchEffect's recursion guard drops the subscription after a mount-time
// clear, leaving warnings that arrive while the panel is open unseen forever.
watch(
() => store.unseenCount,
(count) => {
if (count > 0) store.markAllSeen()
},
{ immediate: true }
)
</script>

View File

@@ -49,6 +49,7 @@
/>
<ResultVideo v-else-if="activeItem.isVideo" :result="activeItem" />
<ResultAudio v-else-if="activeItem.isAudio" :result="activeItem" />
<ResultText v-else-if="activeItem.isText" :result="activeItem" />
</template>
</div>
@@ -75,6 +76,7 @@ import Button from '@/components/ui/button/Button.vue'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultAudio from './ResultAudio.vue'
import ResultText from './ResultText.vue'
import ResultVideo from './ResultVideo.vue'
const emit = defineEmits<{

View File

@@ -0,0 +1,21 @@
<template>
<article
class="m-auto max-h-[80vh] w-[min(90vw,42rem)] scroll-shadows-secondary-background overflow-y-auto rounded-lg bg-secondary-background p-4 whitespace-pre-wrap"
>
<span v-if="hasError" class="text-muted-foreground">
{{ $t('g.textFailedToLoad') }}
</span>
<template v-else>{{ textContent }}</template>
</article>
</template>
<script setup lang="ts">
import { useTextFileContent } from '@/composables/useTextFileContent'
import type { ResultItemImpl } from '@/stores/queueStore'
const { result } = defineProps<{
result: ResultItemImpl
}>()
const { textContent, hasError } = useTextFileContent(() => result)
</script>

View File

@@ -93,6 +93,30 @@ describe('MultiSelect', () => {
unmount()
})
it('closes when an outside pointerdown is stopped from propagating (e.g. canvas)', async () => {
const user = userEvent.setup()
// Mimics the litegraph canvas, which stops pointerdown from bubbling to the
// document — reka's bubble-phase outside-dismiss would never fire.
const canvasLike = document.body.appendChild(document.createElement('div'))
canvasLike.addEventListener('pointerdown', (e) => e.stopPropagation())
const { unmount } = renderInParent()
const trigger = screen.getByRole('button')
await user.click(trigger)
await nextTick()
expect(trigger).toHaveAttribute('data-state', 'open')
canvasLike.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await nextTick()
expect(trigger).toHaveAttribute('data-state', 'closed')
canvasLike.remove()
unmount()
})
describe('Escape key propagation', () => {
it('stops Escape from propagating to parent when popover is open', async () => {
const user = userEvent.setup()

View File

@@ -47,6 +47,7 @@
<ComboboxPortal>
<ComboboxContent
ref="contentRef"
position="popper"
:side-offset="8"
align="start"
@@ -136,6 +137,7 @@
</template>
<script setup lang="ts">
import { useCurrentElement } from '@vueuse/core'
import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import type { FocusOutsideEvent } from 'reka-ui'
@@ -166,6 +168,7 @@ import {
stopEscapeToDocument
} from '@/components/ui/select/select.variants'
import type { SelectOption } from '@/components/ui/select/types'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@comfyorg/tailwind-utils'
@@ -221,6 +224,19 @@ const { t } = useI18n()
const isOpen = ref(false)
const selectedCount = computed(() => selectedItems.value.length)
const rootEl = useCurrentElement<HTMLElement>()
const contentRef = ref<{ $el?: HTMLElement } | null>(null)
useDismissableOverlay({
isOpen,
getOverlayEl: () => contentRef.value?.$el ?? null,
getTriggerEl: () =>
rootEl.value instanceof HTMLElement ? rootEl.value : null,
onDismiss: () => {
isOpen.value = false
}
})
function onContentKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
stopEscapeToDocument(event)

View File

@@ -0,0 +1,58 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import {
DEPRECATION_WARNINGS_TAB_ID,
useDeprecationWarningsSidebarTab
} from '@/composables/sidebarTabs/useDeprecationWarningsSidebarTab'
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
function iconBadgeOf(tab = useDeprecationWarningsSidebarTab()): string | null {
if (typeof tab.iconBadge !== 'function') {
throw new Error('expected iconBadge to be a function')
}
return tab.iconBadge()
}
describe('useDeprecationWarningsSidebarTab', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
// Drain warnings buffered during module-load imports.
useDeprecationWarningsStore().clear()
})
it('iconBadge returns null when there are no unseen warnings', () => {
expect(iconBadgeOf()).toBeNull()
})
it('iconBadge returns the unseen count when warnings are present', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'one' })
store.report({ message: 'two' })
expect(iconBadgeOf()).toBe('2')
})
it('iconBadge clamps the count at "9+"', () => {
const store = useDeprecationWarningsStore()
for (let i = 0; i < 12; i++) store.report({ message: `m${i}` })
expect(iconBadgeOf()).toBe('9+')
})
it('iconBadge returns null while the tab itself is active', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'one' })
useSidebarTabStore().activeSidebarTabId = DEPRECATION_WARNINGS_TAB_ID
expect(iconBadgeOf()).toBeNull()
})
it('iconBadge returns null after markAllSeen', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'one' })
store.markAllSeen()
expect(iconBadgeOf()).toBeNull()
})
})

View File

@@ -0,0 +1,30 @@
import { markRaw } from 'vue'
import DeprecationWarningsTab from '@/components/sidebar/tabs/DeprecationWarningsTab.vue'
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
import { formatBadgeCount } from '@/utils/numberUtil'
export const DEPRECATION_WARNINGS_TAB_ID = 'deprecation-warnings'
export function useDeprecationWarningsSidebarTab(): SidebarTabExtension {
return {
id: DEPRECATION_WARNINGS_TAB_ID,
icon: 'icon-[lucide--triangle-alert]',
title: 'deprecationWarnings.title',
tooltip: 'deprecationWarnings.title',
label: 'deprecationWarnings.label',
component: markRaw(DeprecationWarningsTab),
type: 'vue',
placement: 'bottom',
iconBadge: () => {
const sidebarTabStore = useSidebarTabStore()
if (sidebarTabStore.activeSidebarTabId === DEPRECATION_WARNINGS_TAB_ID) {
return null
}
const count = useDeprecationWarningsStore().unseenCount
return count > 0 ? formatBadgeCount(count, 9) : null
}
}
}

View File

@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useTextFileContent } from '@/composables/useTextFileContent'
function stubFetch(response: Partial<Response> | Error) {
const mock =
response instanceof Error
? vi.fn().mockRejectedValue(response)
: vi.fn().mockResolvedValue(response)
vi.stubGlobal('fetch', mock)
return mock
}
describe(useTextFileContent, () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns inline content without fetching', async () => {
const fetchMock = stubFetch(new Error('should not be called'))
const { textContent } = useTextFileContent(() => ({
content: 'inline text',
url: 'http://example.com/file.txt'
}))
await vi.waitFor(() => expect(textContent.value).toBe('inline text'))
expect(fetchMock).not.toHaveBeenCalled()
})
it('fetches text from the url when no inline content is present', async () => {
const fetchMock = stubFetch({
ok: true,
text: () => Promise.resolve('fetched text')
})
const { textContent, hasError } = useTextFileContent(() => ({
url: 'http://example.com/file.txt'
}))
await vi.waitFor(() => expect(textContent.value).toBe('fetched text'))
expect(fetchMock).toHaveBeenCalledWith('http://example.com/file.txt')
expect(hasError.value).toBe(false)
})
it('flags an error for a non-ok response', async () => {
stubFetch({ ok: false })
const { textContent, hasError } = useTextFileContent(() => ({
url: 'http://example.com/missing.txt'
}))
await vi.waitFor(() => expect(hasError.value).toBe(true))
expect(textContent.value).toBe('')
})
it('flags an error when the fetch rejects', async () => {
stubFetch(new Error('network down'))
const { hasError } = useTextFileContent(() => ({
url: 'http://example.com/file.txt'
}))
await vi.waitFor(() => expect(hasError.value).toBe(true))
})
it('resolves empty content when there is no source', async () => {
const fetchMock = stubFetch(new Error('should not be called'))
const { textContent, isLoading } = useTextFileContent(() => undefined)
await vi.waitFor(() => expect(isLoading.value).toBe(false))
expect(textContent.value).toBe('')
expect(fetchMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,40 @@
import { computedAsync } from '@vueuse/core'
import { ref, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
interface TextSource {
content?: string
url?: string
}
export function useTextFileContent(
source: MaybeRefOrGetter<TextSource | undefined>
) {
const isLoading = ref(false)
const hasError = ref(false)
const textContent = computedAsync(
async () => {
hasError.value = false
const { content, url } = toValue(source) ?? {}
if (content !== undefined) return content
if (!url) return ''
const response = await fetch(url)
if (!response.ok) {
hasError.value = true
return ''
}
return await response.text()
},
'',
{
evaluating: isLoading,
onError: () => {
hasError.value = true
}
}
)
return { textContent, isLoading, hasError }
}

View File

@@ -36,9 +36,9 @@ describe('Context Menu Extension Name in Warnings', () => {
expect(warnSpy).toHaveBeenCalled()
const warningMessage = warnSpy.mock.calls[0][0]
expect(warningMessage).toContain('[DEPRECATED]')
expect(warningMessage).toContain('getCanvasMenuOptions')
expect(warningMessage).toContain('"MyCustomExtension"')
expect(warningMessage).toContain('[DEPRECATED:litegraph]')
expect(warningMessage).toContain('detail: getCanvasMenuOptions')
expect(warningMessage).toContain('extension: MyCustomExtension')
vi.restoreAllMocks()
})
@@ -67,9 +67,9 @@ describe('Context Menu Extension Name in Warnings', () => {
expect(warnSpy).toHaveBeenCalled()
const warningMessage = warnSpy.mock.calls[0][0]
expect(warningMessage).toContain('[DEPRECATED]')
expect(warningMessage).toContain('getNodeMenuOptions')
expect(warningMessage).toContain('"AnotherExtension"')
expect(warningMessage).toContain('[DEPRECATED:litegraph]')
expect(warningMessage).toContain('detail: getNodeMenuOptions')
expect(warningMessage).toContain('extension: AnotherExtension')
vi.restoreAllMocks()
})

View File

@@ -21,6 +21,7 @@ if (!isCloud) {
import './noteNode'
import './painter'
import './previewAny'
import './saveText'
import './rerouteNode'
import './saveImageExtraOutput'
// saveMesh is loaded on-demand with load3d (see load3dLazy.ts)

View File

@@ -1,11 +1,12 @@
import _ from 'es-toolkit/compat'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app, ComfyApp } from '@/scripts/app'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
import { app, ComfyApp } from '@/scripts/app'
import { useDialogStore } from '@/stores/dialogStore'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
function openMaskEditor(node: LGraphNode): void {
if (!node) {
@@ -150,11 +151,9 @@ app.registerExtension({
],
init() {
// Set up ComfyApp static methods for plugin compatibility (deprecated)
ComfyApp.open_maskeditor = openMaskEditorFromClipspace
console.warn(
'[MaskEditor] ComfyApp.open_maskeditor is deprecated. ' +
'Plugins should migrate to using the command system or direct node context menu integration.'
)
ComfyApp.open_maskeditor = function open_maskeditor() {
warnDeprecated('maskEditor.openMaskEditor')
openMaskEditorFromClipspace()
}
}
})

View File

@@ -1,6 +1,17 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyExtension } from '@/types/comfy'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
const { addTextPreviewWidgets, updateTextPreviewWidgets } = vi.hoisted(() => ({
addTextPreviewWidgets: vi.fn(),
updateTextPreviewWidgets: vi.fn()
}))
vi.mock('@/extensions/core/textPreviewWidgets', () => ({
addTextPreviewWidgets,
updateTextPreviewWidgets
}))
const capturedExtensions: ComfyExtension[] = []
@@ -12,103 +23,51 @@ vi.mock('@/services/extensionService', () => ({
})
}))
vi.mock('@/scripts/app', () => ({ app: {} }))
type BeforeRegister = NonNullable<ComfyExtension['beforeRegisterNodeDef']>
interface MockWidget {
name: string
options: Record<string, unknown>
element: { readOnly: boolean }
callback?: (value: unknown) => void
value: unknown
hidden: boolean
label: string
serialize?: boolean
}
async function setupNode() {
const ext = capturedExtensions.find((e) => e.name === 'Comfy.PreviewAny')
expect(ext).toBeDefined()
const createdWidgets: MockWidget[] = []
const nodeType = { prototype: {} } as unknown as Parameters<BeforeRegister>[0]
const nodeData = { name: 'PreviewAny' } as Parameters<BeforeRegister>[1]
await ext!.beforeRegisterNodeDef!(
nodeType,
nodeData,
{} as Parameters<BeforeRegister>[2]
)
vi.mock('@/scripts/widgets', () => {
const create =
(kind: string) =>
(
node: { widgets?: MockWidget[] },
name: string,
_info: unknown,
_app: unknown
) => {
const widget: MockWidget = {
name,
options: {},
element: { readOnly: false },
value: kind === 'BOOLEAN' ? false : '',
hidden: false,
label: ''
}
node.widgets = node.widgets ?? []
node.widgets.push(widget)
createdWidgets.push(widget)
return { widget }
}
return {
ComfyWidgets: {
MARKDOWN: create('MARKDOWN'),
STRING: create('STRING'),
BOOLEAN: create('BOOLEAN')
}
const node = {} as LGraphNode
const proto = nodeType.prototype as {
onNodeCreated?: () => void
onExecuted?: (message: { text?: string }) => void
}
})
return { node, proto }
}
describe('PreviewAny extension', () => {
beforeEach(async () => {
capturedExtensions.length = 0
createdWidgets.length = 0
addTextPreviewWidgets.mockClear()
updateTextPreviewWidgets.mockClear()
vi.resetModules()
await import('./previewAny')
})
async function setupNode() {
const ext = capturedExtensions.find((e) => e.name === 'Comfy.PreviewAny')
expect(ext).toBeDefined()
it('adds the shared text preview widgets on node creation', async () => {
const { node, proto } = await setupNode()
const nodeType = { prototype: {} } as unknown as Parameters<
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
>[0]
const nodeData = { name: 'PreviewAny' } as Parameters<
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
>[1]
await ext!.beforeRegisterNodeDef!(
nodeType,
nodeData,
{} as Parameters<NonNullable<ComfyExtension['beforeRegisterNodeDef']>>[2]
)
const node: { widgets?: MockWidget[] } = {}
const proto = nodeType.prototype as { onNodeCreated?: () => void }
proto.onNodeCreated!.call(node)
return node
}
it('excludes preview widgets from the API prompt to prevent re-execution', async () => {
await setupNode()
expect(addTextPreviewWidgets).toHaveBeenCalledWith(node)
})
const previewMarkdown = createdWidgets.find(
(w) => w.name === 'preview_markdown'
)
const previewText = createdWidgets.find((w) => w.name === 'preview_text')
const previewMode = createdWidgets.find((w) => w.name === 'previewMode')
it('updates the preview with executed text', async () => {
const { node, proto } = await setupNode()
const message = { text: 'hello' }
expect(previewMarkdown).toBeDefined()
expect(previewText).toBeDefined()
expect(previewMode).toBeDefined()
proto.onExecuted!.call(node, message)
// widget.options.serialize === false is what executionUtil.graphToPrompt
// checks to exclude a widget from the API prompt sent to the backend.
// Without this, post-execution widget value updates (the rendered preview
// text) get serialized as inputs, change the cache signature, and cause
// the node to re-execute on the next prompt.
expect(previewMarkdown!.options.serialize).toBe(false)
expect(previewText!.options.serialize).toBe(false)
expect(previewMode!.options.serialize).toBe(false)
expect(updateTextPreviewWidgets).toHaveBeenCalledWith(node, message)
})
})

View File

@@ -4,11 +4,14 @@ https://github.com/rgthree/rgthree-comfy/blob/main/py/display_any.py
upstream requested in https://github.com/Kosinkadink/rfcs/blob/main/rfcs/0000-corenodes.md#preview-nodes
*/
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import {
addTextPreviewWidgets,
updateTextPreviewWidgets
} from '@/extensions/core/textPreviewWidgets'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import { type DOMWidget } from '@/scripts/domWidget'
import { ComfyWidgets } from '@/scripts/widgets'
import { useExtensionService } from '@/services/extensionService'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
useExtensionService().registerExtension({
name: 'Comfy.PreviewAny',
@@ -16,82 +19,24 @@ useExtensionService().registerExtension({
nodeType: typeof LGraphNode,
nodeData: ComfyNodeDef
) {
if (nodeData.name === 'PreviewAny') {
const onNodeCreated = nodeType.prototype.onNodeCreated
if (nodeData.name !== 'PreviewAny') return
nodeType.prototype.onNodeCreated = function () {
onNodeCreated ? onNodeCreated.apply(this, []) : undefined
const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, [])
addTextPreviewWidgets(this)
}
const showValueWidget = ComfyWidgets['MARKDOWN'](
this,
'preview_markdown',
['MARKDOWN', {}],
app
).widget as DOMWidget<HTMLTextAreaElement, string>
const showValueWidgetPlain = ComfyWidgets['STRING'](
this,
'preview_text',
['STRING', { multiline: true }],
app
).widget as DOMWidget<HTMLTextAreaElement, string>
const showAsPlaintextWidget = ComfyWidgets['BOOLEAN'](
this,
'previewMode',
[
'BOOLEAN',
{ label_on: 'Markdown', label_off: 'Plaintext', default: false }
],
app
)
showAsPlaintextWidget.widget.callback = (value: boolean) => {
showValueWidget.hidden = !value
showValueWidget.options.hidden = !value
showValueWidgetPlain.hidden = value
showValueWidgetPlain.options.hidden = value
}
showValueWidget.label = 'Preview'
showValueWidget.hidden = true
showValueWidget.options.hidden = true
showValueWidget.options.read_only = true
showValueWidget.options.serialize = false
showValueWidget.element.readOnly = true
showValueWidget.serialize = false
showValueWidgetPlain.label = 'Preview'
showValueWidgetPlain.hidden = false
showValueWidgetPlain.options.hidden = false
showValueWidgetPlain.options.read_only = true
showValueWidgetPlain.options.serialize = false
showValueWidgetPlain.element.readOnly = true
showValueWidgetPlain.serialize = false
// The previewMode toggle is a frontend-only display preference and
// is not declared in the backend INPUT_TYPES, so it must not be
// serialized into the API prompt (would alter the cache signature).
showAsPlaintextWidget.widget.options.serialize = false
}
const onExecuted = nodeType.prototype.onExecuted
nodeType.prototype.onExecuted = function (message) {
onExecuted === null || onExecuted === void 0
? void 0
: onExecuted.apply(this, [message])
const previewWidgets =
this.widgets?.filter((w) => w.name.startsWith('preview_')) ?? []
for (const previewWidget of previewWidgets) {
const text = message.text ?? ''
previewWidget.value = Array.isArray(text)
? (text?.join('\n\n') ?? '')
: text
}
}
const onExecuted = nodeType.prototype.onExecuted
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, [message])
updateTextPreviewWidgets(this, message)
}
},
onNodeOutputsUpdated(nodeOutputs) {
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
const node = getNodeByLocatorId(app.rootGraph, nodeLocatorId)
if (node?.type === 'PreviewAny') updateTextPreviewWidgets(node, output)
}
}
})

View File

@@ -0,0 +1,24 @@
import {
addTextPreviewWidgets,
updateTextPreviewWidgets
} from '@/extensions/core/textPreviewWidgets'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.saveText',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== 'SaveText') return
const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, [])
addTextPreviewWidgets(this)
}
const onExecuted = nodeType.prototype.onExecuted
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, [message])
updateTextPreviewWidgets(this, message)
}
}
})

View File

@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
interface MockWidget {
name: string
options: Record<string, unknown>
value?: unknown
serialize?: boolean
}
vi.mock('@/scripts/app', () => ({ app: { rootGraph: { id: 'graph-1' } } }))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
resolveNodeRootGraphId: () => 'graph-1'
}))
vi.mock(
'@/renderer/extensions/vueNodes/widgets/components/WidgetTextPreview.vue',
() => ({
default: {}
})
)
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({ getWidget: () => undefined })
}))
vi.mock('@/scripts/domWidget', () => ({
ComponentWidgetImpl: class {
name: string
options: Record<string, unknown>
type: string
serialize?: boolean
constructor(obj: {
name: string
options: Record<string, unknown>
type: string
}) {
this.name = obj.name
this.options = obj.options
this.type = obj.type
}
},
addWidget: (node: { widgets?: MockWidget[] }, widget: MockWidget) => {
node.widgets = node.widgets ?? []
node.widgets.push(widget)
}
}))
vi.mock('@/scripts/widgets', () => ({
ComfyWidgets: {
BOOLEAN: (node: { widgets?: MockWidget[] }, name: string) => {
const widget: MockWidget = { name, options: {}, value: false }
node.widgets = node.widgets ?? []
node.widgets.push(widget)
return { widget }
}
}
}))
const { addTextPreviewWidgets, updateTextPreviewWidgets } =
await import('./textPreviewWidgets')
function makeNode(): LGraphNode & { widgets: MockWidget[] } {
return { id: '1', widgets: [] } as unknown as LGraphNode & {
widgets: MockWidget[]
}
}
describe('addTextPreviewWidgets', () => {
it('adds a non-serialized preview widget and a non-serialized mode toggle', () => {
const node = makeNode()
addTextPreviewWidgets(node)
const preview = node.widgets.find((w) => w.name === 'preview_text')
const mode = node.widgets.find((w) => w.name === 'preview_mode')
expect(preview?.type).toBe('textPreview')
expect(preview?.serialize).toBe(false)
expect(preview?.options.serialize).toBe(false)
expect(mode?.options.serialize).toBe(false)
})
})
describe('updateTextPreviewWidgets', () => {
let node: LGraphNode & { widgets: MockWidget[] }
beforeEach(() => {
node = makeNode()
node.widgets.push({ name: 'preview_text', options: {}, value: '' })
})
it('joins array text into the preview widget value', () => {
updateTextPreviewWidgets(node, { text: ['a', 'b'] })
expect(node.widgets[0].value).toBe('a\n\nb')
})
it('writes a plain string message as-is', () => {
updateTextPreviewWidgets(node, { text: 'hello' })
expect(node.widgets[0].value).toBe('hello')
})
})

View File

@@ -0,0 +1,78 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
import WidgetTextPreview from '@/renderer/extensions/vueNodes/widgets/components/WidgetTextPreview.vue'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { ComfyWidgets } from '@/scripts/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
const PREVIEW_WIDGET_NAME = 'preview_text'
const MODE_WIDGET_NAME = 'preview_mode'
const inputSpecTextPreview: CustomInputSpec = {
name: PREVIEW_WIDGET_NAME,
type: 'TEXT_PREVIEW',
isPreview: true
}
export function addTextPreviewWidgets(node: LGraphNode) {
const widgetStore = useWidgetValueStore()
let fallbackValue = ''
const previewWidgetId = () =>
widgetId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
PREVIEW_WIDGET_NAME
)
const preview = new ComponentWidgetImpl<string | object>({
node,
name: PREVIEW_WIDGET_NAME,
component: WidgetTextPreview,
inputSpec: inputSpecTextPreview,
type: 'textPreview',
options: {
serialize: false,
hideInPanel: true,
getMinHeight: () => 60,
getValue: () => {
const stored = widgetStore.getWidget(previewWidgetId())?.value
return typeof stored === 'string' ? stored : fallbackValue
},
setValue: (value: string | object) => {
fallbackValue = typeof value === 'string' ? value : ''
const state = widgetStore.getWidget(previewWidgetId())
if (state) state.value = fallbackValue
}
}
})
preview.serialize = false
addWidget(node, preview)
const modeWidget = ComfyWidgets['BOOLEAN'](
node,
MODE_WIDGET_NAME,
[
'BOOLEAN',
{ label_on: 'Markdown', label_off: 'Plain text', default: false }
],
app
).widget
modeWidget.options.serialize = false
modeWidget.serialize = false
}
export function updateTextPreviewWidgets(
node: LGraphNode,
message: { text?: string | string[] }
) {
const preview = node.widgets?.find((w) => w.name === PREVIEW_WIDGET_NAME)
if (!preview) return
const text = message.text ?? ''
preview.value = Array.isArray(text) ? text.join('\n\n') : text
}

View File

@@ -0,0 +1,74 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { registerExtension } = vi.hoisted(() => ({
registerExtension: vi.fn()
}))
vi.mock('@/scripts/app', () => ({
app: { registerExtension }
}))
import { convertToInput } from '@/extensions/core/widgetInputs'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
describe('widgetInputs deprecation', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
useDeprecationWarningsStore().clear()
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
describe('convertToInput', () => {
it('returns the input slot whose widget name matches', () => {
const slot = { widget: { name: 'seed' } }
const node = createMockLGraphNode({
inputs: [slot, { widget: { name: 'other' } }]
})
expect(convertToInput(node, { name: 'seed' } as IBaseWidget)).toBe(slot)
})
it('reports a deprecation warning to the store', () => {
const node = createMockLGraphNode({ inputs: [] })
convertToInput(node, { name: 'x' } as IBaseWidget)
const store = useDeprecationWarningsStore()
expect(store.warnings).toHaveLength(1)
expect(store.warnings[0]).toMatchObject({
message: 'convertToInput is no longer necessary.',
source: 'widgetInputs'
})
})
})
describe('convertWidgetToInput prototype patch', () => {
it('reports a deprecation when the patched method is called', async () => {
const config = registerExtension.mock.calls.find(
([c]) => c?.name === 'Comfy.WidgetInputs'
)?.[0]
expect(config?.beforeRegisterNodeDef).toBeDefined()
class MockNode {}
await config.beforeRegisterNodeDef(MockNode, {})
const instance = new MockNode() as { convertWidgetToInput: () => boolean }
expect(instance.convertWidgetToInput()).toBe(false)
const store = useDeprecationWarningsStore()
expect(store.warnings[0]).toMatchObject({
message: 'convertWidgetToInput is no longer necessary.',
source: 'widgetInputs'
})
})
})
})

View File

@@ -13,6 +13,7 @@ import type {
} from '@/lib/litegraph/src/types/widgets'
import { assetService } from '@/platform/assets/services/assetService'
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
import type { ComfyNodeDef, InputSpec } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import {
@@ -426,15 +427,12 @@ function getConfig(this: LGraphNode, widgetName: string) {
* @param node The node to convert the widget to an input slot for.
* @param widget The widget to convert to an input slot.
* @returns The input slot that was converted from the widget or undefined if the widget is not found.
* @knipIgnoreUnusedButUsedByCustomNodes
*/
export function convertToInput(
node: LGraphNode,
widget: IBaseWidget
): INodeInputSlot | undefined {
console.warn(
'Please remove call to convertToInput. Widget to socket conversion is no longer necessary, as they co-exist now.'
)
warnDeprecated('widgetInputs.convertToInput')
return node.inputs.find((slot) => slot.widget?.name === widget.name)
}
@@ -517,9 +515,7 @@ app.registerExtension({
) {
// @ts-expect-error adding extra property
nodeType.prototype.convertWidgetToInput = function (this: LGraphNode) {
console.warn(
'Please remove call to convertWidgetToInput. Widget to socket conversion is no longer necessary, as they co-exist now.'
)
warnDeprecated('widgetInputs.convertWidgetToInput')
return false
}

View File

@@ -13,6 +13,8 @@ import {
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import { DEPRECATIONS } from '@/platform/dev/deprecations'
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
import type { UUID } from '@/utils/uuid'
import { zeroUuid } from '@/utils/uuid'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
@@ -552,13 +554,16 @@ describe('Subgraph Definition Garbage Collection', () => {
})
describe('beforeChange deprecated onBeforeChange shim', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
LiteGraph.onDeprecationWarning = []
LiteGraph.alwaysRepeatWarnings = true
setActivePinia(createTestingPinia({ stubActions: false }))
useDeprecationWarningsStore().clear()
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
LiteGraph.alwaysRepeatWarnings = false
warnSpy.mockRestore()
})
it('still invokes a listener assigned to onBeforeChange', () => {
@@ -572,28 +577,25 @@ describe('beforeChange deprecated onBeforeChange shim', () => {
expect(onBeforeChange).toHaveBeenCalledWith(graph, node)
})
it('warns that onBeforeChange is deprecated when used', () => {
it('reports the onBeforeChange deprecation when used', () => {
const graph = new LGraph()
const deprecationCallback = vi.fn()
LiteGraph.onDeprecationWarning = [deprecationCallback]
graph.onBeforeChange = vi.fn()
graph.beforeChange()
expect(deprecationCallback).toHaveBeenCalledWith(
expect.stringContaining('LGraph.onBeforeChange is deprecated'),
undefined
expect(useDeprecationWarningsStore().warnings).toContainEqual(
expect.objectContaining({
message: DEPRECATIONS['litegraph.onBeforeChange'].message
})
)
})
it('does not warn when no listener is assigned', () => {
it('does not report when no listener is assigned', () => {
const graph = new LGraph()
const deprecationCallback = vi.fn()
LiteGraph.onDeprecationWarning = [deprecationCallback]
graph.beforeChange()
expect(deprecationCallback).not.toHaveBeenCalled()
expect(useDeprecationWarningsStore().warnings).toHaveLength(0)
})
})

View File

@@ -58,7 +58,7 @@ import {
createBounds,
snapPoint
} from './measure'
import { warnDeprecated } from './utils/feedback'
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
import { SubgraphInput } from './subgraph/SubgraphInput'
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
import { SubgraphOutput } from './subgraph/SubgraphOutput'
@@ -1382,9 +1382,7 @@ export class LGraph
// used for undo, called before any change is made to the graph
beforeChange(info?: LGraphNode): void {
if (this.onBeforeChange) {
warnDeprecated(
'LGraph.onBeforeChange is deprecated and will be removed in a future version. Assign a listener to LGraphCanvas.onBeforeChange instead.'
)
warnDeprecated('litegraph.onBeforeChange')
this.onBeforeChange(this, info)
}
this.canvasAction((c) => c.onBeforeChange?.(this))

View File

@@ -8,6 +8,7 @@ import {
import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
import { toLinkId } from '@/types/linkId'
import { UNASSIGNED_NODE_ID, toNodeId, serializeNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
@@ -95,7 +96,6 @@ import type {
TWidgetValue
} from './types/widgets'
import { findFreeSlotOfType } from './utils/collections'
import { warnDeprecated } from './utils/feedback'
import { distributeSpace } from './utils/spaceDistribution'
import { truncateText } from './utils/textUtils'
import { BaseWidget } from './widgets/BaseWidget'
@@ -3521,9 +3521,7 @@ export class LGraphNode
* @deprecated Use {@link LGraphCanvas.pointer} instead.
*/
captureInput(v: boolean): void {
warnDeprecated(
'[DEPRECATED] captureInput will be removed in a future version. Please use LGraphCanvas.pointer (CanvasPointer) instead.'
)
warnDeprecated('litegraph.captureInput')
if (!this.graph || !this.graph.list_of_graphcanvas) return
const list = this.graph.list_of_graphcanvas

View File

@@ -28,8 +28,15 @@ import {
RenderShape,
TitleMode
} from './types/globalEnums'
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
import { createUuidv4 } from '@/utils/uuid'
/** Never read; unfrozen so legacy `.push()` callers do not throw. */
const inertDeprecationListeners: ((
message: string,
source?: object
) => void)[] = []
/**
* The Global Scope. It contains all the registered node classes.
*/
@@ -273,18 +280,31 @@ export class LiteGraphGlobal {
context_menu_scaling = false
/**
* Debugging flag. Repeats deprecation warnings every time they are reported.
* May impact performance.
* @deprecated Removed; has no effect. Deprecation warnings are now surfaced in
* the Dev Mode "Deprecation Warnings" sidebar panel rather than dispatched to
* listeners. Retained so existing `.push()` callers do not crash; the array is
* never read.
*/
alwaysRepeatWarnings: boolean = false
get onDeprecationWarning(): ((message: string, source?: object) => void)[] {
warnDeprecated('litegraph.onDeprecationWarning')
return inertDeprecationListeners
}
/**
* Array of callbacks to execute when Litegraph first reports a deprecated API being used.
* @see alwaysRepeatWarnings By default, will not repeat identical messages.
*/
onDeprecationWarning: ((message: string, source?: object) => void)[] = [
console.warn
]
set onDeprecationWarning(
_value: ((message: string, source?: object) => void)[]
) {
warnDeprecated('litegraph.onDeprecationWarning')
}
/** @deprecated Removed; has no effect. */
get alwaysRepeatWarnings(): boolean {
warnDeprecated('litegraph.alwaysRepeatWarnings')
return false
}
set alwaysRepeatWarnings(_value: boolean) {
warnDeprecated('litegraph.alwaysRepeatWarnings')
}
/**
* @deprecated Removed; has no effect.

View File

@@ -140,7 +140,6 @@ LiteGraphGlobal {
"allow_multi_output_for_events": true,
"allow_scripts": false,
"alt_drag_do_clone_nodes": false,
"alwaysRepeatWarnings": false,
"alwaysSnapToGrid": undefined,
"auto_load_slot_types": false,
"canvasNavigationMode": "legacy",
@@ -167,9 +166,6 @@ LiteGraphGlobal {
"node_box_coloured_when_on": false,
"node_images_path": "",
"node_types_by_file_extension": {},
"onDeprecationWarning": [
[Function],
],
"overlapBounding": [Function],
"pointerevents_method": "pointer",
"proxy": null,

View File

@@ -64,12 +64,12 @@ describe('contextMenuCompat', () => {
// Should have logged a warning with extension name
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('[DEPRECATED]'),
expect.stringContaining('[DEPRECATED'),
expect.any(String),
expect.any(String)
)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('"Test Extension"'),
expect.stringContaining('extension: Test Extension'),
expect.any(String),
expect.any(String)
)

View File

@@ -1,5 +1,6 @@
import type { LGraphCanvas } from './LGraphCanvas'
import type { IContextMenuValue } from './interfaces'
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
/**
* Simple compatibility layer for legacy getCanvasMenuOptions and getNodeMenuOptions monkey patches.
@@ -86,13 +87,10 @@ class LegacyMenuCompat {
if (!this.hasWarned.has(fnKey) && this.currentExtension) {
this.hasWarned.add(fnKey)
console.warn(
`%c[DEPRECATED]%c Monkey-patching ${methodName as string} is deprecated. (Extension: "${this.currentExtension}")\n` +
`Please use the new context menu API instead.\n\n` +
`See: https://docs.comfy.org/custom-nodes/js/context-menu-migration`,
'color: orange; font-weight: bold',
'color: inherit'
)
warnDeprecated('litegraph.contextMenuMonkeyPatch', {
extension: this.currentExtension ?? undefined,
detail: methodName as string
})
}
currentImpl = newImpl
}

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