Compare commits

..

39 Commits

Author SHA1 Message Date
ShihChi Huang
413d082f90 test: cover queue display formatting 2026-06-23 23:58:10 -07:00
ShihChi Huang
3400438dfb test: cover fuse search ranking 2026-06-23 23:45:11 -07:00
ShihChi Huang
3088754bb8 test: fix coverage run 2026-06-23 23:41:54 -07:00
Simon Pinfold
f076106ca5 fix: expand grouped assets when downloading multi-selection on OSS backend (#13079)
*PR Created by the Glary-Bot Agent*

---

## Summary

When using the ComfyUI (non-cloud) backend, selecting a grouped asset
(`outputCount > 1`) and clicking Download only downloaded the
cover/preview image instead of every output in the group.

The cloud backend already handles this correctly by exporting a ZIP
server-side. The OSS path called `downloadFile(asset.preview_url, ...)`
once per selected `AssetItem` and never expanded grouped assets into
their individual outputs.

## Fix

In `useMediaAssetActions.downloadAssets`, when any selected asset has
`outputCount > 1` and we're on the OSS path, resolve each grouped asset
into its individual outputs via the existing `resolveOutputAssetItems`
utility and trigger one direct download per file. Non-grouped selections
keep the original single-shot behaviour. After expansion the file list
is deduplicated by `AssetItem.id` so a user who selects both an expanded
stack parent and one of its children does not download the child twice.
The success toast now reflects the actual number of files downloaded.

- Single asset, single output → unchanged (1 download).
- Multi-select of single-output assets → unchanged (N downloads).
- Any selection containing a grouped asset → expanded via
`resolveOutputAssetItems` (same code path the cloud ZIP and
stack-expansion UI use). If resolution returns nothing, falls back to
the preview download so the user still gets something.
- Grouped parent + one of its expanded children selected → deduped, no
double download.

## Tests

Added unit tests in `useMediaAssetActions.test.ts` for the OSS path:

- Expands a grouped asset into individual downloads.
- Mixes grouped and single-output assets in one selection.
- Falls back to the original asset when `resolveOutputAssetItems`
returns empty.
- Does not call `resolveOutputAssetItems` when no grouped assets are
selected.
- Deduplicates downloads when an expanded child is also selected
alongside its parent.
- Shows an error toast when resolution rejects.

All 40 tests in the file pass; all 508 tests under `src/platform/assets`
pass. `pnpm typecheck`, `pnpm exec eslint`, `pnpm exec oxfmt --check`
all clean.

## Manual verification
Tested against a `master` ComfyUI instance with default settings.
Not tested against cloud - feature is gated to non cloud

Performed by @synap5e 
<img width="448" height="618" alt="image"
src="https://github.com/user-attachments/assets/daf32fa0-c5ec-47ca-bab3-d5ea3fb3d7cc"
/>


https://github.com/user-attachments/assets/a87ae1aa-836f-4cbc-9ef7-a35ed4f94ee7


https://github.com/user-attachments/assets/49d833bf-7b4e-4c53-b0d5-f16ff2108185

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-06-24 02:25:02 +00:00
Wei Hai
d7fa853c06 [chore] Update Comfy Registry API types from comfy-api@8d142eb (#13080)
Regenerates `packages/registry-types/src/comfyRegistryTypes.ts` from the
current comfy-api OpenAPI spec via `openapi-typescript`.

The FE registry types were last regenerated in May; this brings them up
to date. Notable addition consumed by #12924: the `createCustomer`
request body (`CreateCustomerRequest` with an optional
`turnstile_token`).

`pnpm typecheck` passes against the regenerated types.
2026-06-24 00:00:24 +00:00
CodeJuggernaut
07f881fc14 feat: float the Media Assets bulk-selection bar (#13043)
## Summary

Reskins the Media Assets bulk-selection bar into a prominent floating
pill so bulk-download actions are no longer easy to miss (Linear
FE-989).

## Changes

- **What**: The selection bar now floats over the bottom of the panel as
an inverted rounded pill — close · "{count} selected" · download ·
divider · delete — matching the Figma/prototype spacing, radius, and
shadow. Actions are icon-only with `v-tooltip` hints; the count uses
`tabular-nums` and reflects selected assets (not total outputs).
Extracted into a presentational `MediaAssetSelectionBar.vue` with a
Storybook story, a unit test, and an e2e guard that the count is
per-asset.

## Review Focus

- The pill floats via `position: absolute` (centered, `bottom-6`,
`z-40`) in the sidebar footer slot and overlays the bottom of the grid
by design.
- Count semantics changed from total outputs to selected-asset count
(`selectedAssets.length`).
- Out of scope, deferred per FE-989: marquee / select-all, pagination,
and the favorites / tags / label controls.

## Screenshots (if applicable)

<img width="1003" height="1767" alt="image"
src="https://github.com/user-attachments/assets/3a9ef884-e7f4-4d0b-a495-194ce0860db2"
/>

<img width="643" height="581" alt="image"
src="https://github.com/user-attachments/assets/1161884f-a9c2-4a2b-a20e-33ee3f189935"
/>

<img width="664" height="222" alt="image"
src="https://github.com/user-attachments/assets/b16b083c-bfd9-452d-b508-86b3cbfa9842"
/>

<img width="649" height="265" alt="image"
src="https://github.com/user-attachments/assets/a1076e34-58c9-4e7f-89c4-b21bb3281883"
/>
 
<img width="559" height="205" alt="image"
src="https://github.com/user-attachments/assets/09c24140-33ce-4629-b681-233c59916043"
/>

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-23 22:53:46 +00:00
Dante
e14b5c6f3f feat: wire /api/billing/events into usage history behind teamWorkspacesEnabled (#12955)
## Summary

Wire the previously-dead `workspaceApi.getBillingEvents` (`GET
/api/billing/events`) into the existing usage/activity table behind
`teamWorkspacesEnabled`, so billing history can converge onto the
unified cloud feed (FE-969, "wire `GET /api/billing/events`" half).

## Changes

- **What**: `UsageLogsTable` sources events from
`workspaceApi.getBillingEvents` when `teamWorkspacesEnabled` is on, else
the legacy `customerEventsService` (`/customers/events`) — no change for
flag-off (legacy/personal). The two feeds share an identical response
envelope (`{total, events, page, limit, totalPages}`) and event object
(`{event_type, event_id, params, createdAt}`), so the table view is
reused unchanged (no design change). `topupTracker` now also recognizes
`topup_completed` so credit top-up telemetry works on the unified feed.
- **Breaking**: none (flag-gated; legacy path retained).

## Review Focus

This is a **draft** — it intentionally does NOT retire
`customerEventsService` yet. Two BE confirmations gate the rest:

**1. Does the store actually accumulate events?** For a personal
workspace today, `GET /api/billing/events` appears to hold only
`gpu_usage` (written directly by inference per `workspace_id`).
Webhook-sourced events (topup/subscription/invoice/credit) are dropped
unless the workspace is provisioned in the cloud billing system —
`GetWorkspaceBy{Stripe,Metronome}CustomerID` → "Not our customer,
ignoring". That provisioning is the BE-1047 cutover. Please confirm
which `event_type`s land in the store per env (e.g. pr-4359), personal
vs team.

**2. Are the incompatible fields compatible (or can they be made so)?**
The feeds use different vocabularies / `params` shapes:
- legacy: `credit_added` / `account_created` / `api_usage_*`, params
`{amount, api_name, model}`
- unified: `topup_completed` / `gpu_usage` / `api_node_usage` /
`invoice_*` / `subscription_activated` / `seat_*`, params e.g.
`gpu_usage {gpu_seconds, gpu_type, ...}`

The table degrades gracefully (badge falls back to the raw `event_type`;
the generic params tooltip still renders), but per-type labels/severity
and the details column need the confirmed `params` shapes before full
convergence + `customerEventsService` retirement.

Notes:
- Surfacing: on cloud with `subscription_required`, the Credits panel
hosting `UsageLogsTable` is hidden when `teamWorkspacesEnabled` is on
(team users see the workspace Plan & Credits panel, which has no
activity table). So this is plumbing; an in-app team history surface is
a separate design item.
- Full retirement of `customerEventsService` should land with the
BE-1047 cutover.

Local gates: unit tests (topupTracker + UsageLogsTable) pass, typecheck
/ oxlint / oxfmt clean.

---------

Co-authored-by: dante01yoon <dante01yoon@naver.com>
2026-06-23 21:35:59 +00:00
CodeJuggernaut
065650b3bf fix: open Vue context menu when right-clicking a group (#12971)
## Summary

Right-clicking a frame (group) now opens the new Vue context menu
instead of
the legacy litegraph menu, matching the three-dot menu and node
right-click.

## Changes

- **What**: In Nodes 2.0 mode, a group right-click is routed to the
existing
  `showNodeOptions` flow (the same menu the three-dot button and node
right-click use) instead of litegraph's `processContextMenu`. The group
is
selected (unless already in the selection) so the menu targets it.
Nodes, the
  canvas background, reroutes, and legacy rendering are unchanged.

## Review Focus

- App-layer wrap of `LGraphCanvas.prototype.processContextMenu`,
mirroring the
  existing `useContextMenuTranslation` pattern: no new methods on
  `LGraphCanvas` (ADR 0008).
- Gated on `LiteGraph.vueNodesMode`; legacy rendering keeps the old
menu,
  consistent with legacy node right-click.
- Reroute guard: right-clicking a reroute inside a group still gets the
legacy
  "Delete Reroute" menu, not the group menu.

Fixes FE-1090

## Screenshots (if applicable)

<img width="719" height="788" alt="image"
src="https://github.com/user-attachments/assets/8d514c6d-b7d0-4ec1-841e-677793daf3c7"
/>

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-23 21:32:36 +00:00
Comfy Org PR Bot
a58f927871 1.47.3 (#12976)
Patch version increment to 1.47.3

**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-06-23 21:12:26 +00:00
Wei Hai
c304f206a6 fix(ci): strip served bundle scripts from e2e coverage before genhtml (#13077)
## Problem

The **CI: E2E Coverage** `merge` job fails in the *Generate HTML
coverage report* step ([example
run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/28051468752)):

```
genhtml: ERROR: localhost-8188/assets/nodeDefs-BNhq_6cm.js is not readable or doesn't exist.
##[error]Process completed with exit code 1.
```

V8/Playwright coverage records scripts the test server serves at
`localhost-8188/assets/*.js` (built bundles), which are not source files
on disk. `genhtml` aborts on the missing source even with
`--ignore-errors source`, so the whole job fails.

## Change

Add a *Strip non-source entries from coverage* step that runs `lcov
--remove '*localhost-8188*'` on the merged lcov. It is placed **after**
the data-loss validation (so the merged-vs-shard integrity check stays
consistent) and **before** the Codecov upload and genhtml — which also
makes the Codecov report more accurate, since those served bundles
aren't repo source.

## Validation

- YAML parses cleanly; pre-commit lint/format/typecheck pass.
- The removed paths are non-source served assets only; real `src/**`
entries (absolute paths under the repo workspace) are untouched by the
`*localhost-8188*` pattern.
- `--ignore-errors unused` guards against runs where no such entries
exist.
2026-06-23 20:27:07 +00:00
Christian Byrne
75ea646090 chore: disable CodeRabbit docstring coverage pre-merge check (#13027)
## Summary

The **Docstring Coverage** pre-merge check is currently blocking merges.
It is not defined in our `.coderabbit.yaml` — it is enabled via
CodeRabbit **organization-level settings** (this repo has "use
organization settings" toggled on, but the repo config file overrides
specific keys).

This PR explicitly opts the repo out of that check by setting
`reviews.pre_merge_checks.docstrings.mode: 'off'`. A repo-level
`.coderabbit.yaml` takes precedence over org settings, so this disables
the check **only for this repo** — other org repos that want it keep it.

```yaml
reviews:
  pre_merge_checks:
    docstrings:
      mode: 'off'
```

## Why

- Docstring coverage is a poor fit for this TS/Vue frontend codebase and
was added at the org level, not chosen here.
- It's a blocking gate with no repo-level config, so it can't currently
be tuned from the dashboard without affecting other repos.

## References

- [Built-in pre-merge
checks](https://docs.coderabbit.ai/pr-reviews/pre-merge-checks)
- [Configuration
reference](https://docs.coderabbit.ai/reference/configuration)
- Prior art:
[crossplane/.coderabbit.yaml](https://github.com/crossplane/crossplane/blob/main/.coderabbit.yaml)
disables docstrings the same way
2026-06-23 13:35:43 -07:00
Benjamin Lu
a670944a05 Fix share auth attribution gap (#13064)
## Summary

Logged-out users who open a share link and then sign up/in were not
attributed to the share. The `share_id` capture lived in
`useSharedWorkflowUrlLoader`, which only runs after `GraphView` mounts —
i.e. after the cloud auth guard has already redirected the logged-out
user to login. The capture never happened, so `trackAuth` fired without
a `share_id`.

This moves the capture into the cloud auth guard (`router.beforeEach`),
so it runs on the initial navigation before any login redirect. The
`share_id` is preserved across the auth round-trip and consumed on auth
completion as before.

## Changes

- Capture logged-out share attribution in the router guard instead of
the share loader, via a new `preserveLoggedOutShareAuthAttribution` util
- Extract `isValidShareId` into the shared util and reuse it in the
loader (removes the duplicated regex)
- Gate capture on `isCloud` (matching the cloud-only consumption); drop
the now-dead capture branch from the loader
- Make the accepted share-id shape explicit: ASCII alphanumeric start,
ASCII alphanumeric/`_.-` after that, max 128 chars

## Notes

- Capture no-ops when `share` is absent, so param-less redirects do not
clear attribution
- If another valid share link is visited before auth completes, the
latest valid share replaces the previous attribution
- `SHARE` and `SHARE_AUTH` stay separate intentionally: `SHARE`
preserves the workflow-loading query, while `SHARE_AUTH` is consumed
once by auth telemetry attribution
- No behavior change for logged-in users or for share-dialog open/cancel

## Testing

- New unit tests for `isValidShareId` and
`preserveLoggedOutShareAuthAttribution`
(valid/invalid/array/logged-in/boundary cases)
- Auth store tests cover `share_id` propagation + consumption across
email signup/login, Google, and GitHub
- Updated loader and telemetry tests for the relocated capture and
`share_id` passthrough
- Cloud E2E regression covers logged-out `/?share=abc` redirecting to
login after capturing share auth attribution
2026-06-23 19:43:44 +00:00
Wei Hai
282b8cf819 fix(auth): show a clear message when sign-up is rejected by the backend (#13070)
## Problem

When the auth backend rejects a sign-up, the Firebase client SDK
surfaces the rejection wrapped in a generic error code (e.g.
`auth/internal-error`). `reportError` maps only by `error.code` →
`auth.errors.${code}`, so this case falls through to the generic
*"Something went wrong while signing you in. Please try again."* toast —
giving the user no actionable guidance.

## Change

- `useAuthActions.ts` — add a branch in `reportError` that matches a
stable `signup_blocked` token in `error.message` (matched
**case-insensitively** on the message, not `error.code`, which the SDK
wraps inconsistently across versions) and shows a dedicated message.
- `locales/en/main.json` — new `auth.errors.signupBlocked` string. Other
locales inherit it via `fallbackLocale: 'en'`.
- `useAuthActions.test.ts` — unit tests asserting the token path wins
over the generic fallback, and that the match is case-insensitive.

## Validation

- `vitest run src/composables/auth` — 12/12 pass (incl. token +
case-insensitive tests)
- `vue-tsc --noEmit` — clean

## Why no E2E (Playwright) test

This branch is reachable only when the **auth backend actively rejects a
Google-Workspace-SSO sign-up** (a server-side blocking decision) and the
Firebase SDK returns an error whose message carries the `signup_blocked`
token. Reproducing that in the browser E2E harness would require driving
a real federated-SSO sign-up against a backend configured to reject the
specific account — state the FE test environment cannot set up
deterministically. The logic under test is pure error-mapping with no
UI/navigation surface beyond the existing toast, so it is fully covered
by the unit tests above. The end-to-end path will instead be confirmed
via a one-time staging repro (see below).

## Draft — pending confirmation

Kept as **draft** until we confirm, via a staging repro, the exact error
shape the client receives for this rejection (the message-token match is
the robust hedge regardless of `error.code`, but a live repro makes it
deterministic). The backend that emits the `signup_blocked` token is a
separate change.
2026-06-23 19:27:44 +00:00
Matt Miller
70bc8dc6e6 fix(api): isolate event-listener errors from global telemetry (#12740)
## ELI-5

Custom nodes can hook into app events (like "this node finished
running"). If
one of those hooks crashes, the browser shouts the crash into our global
error
channel — and our monitoring logs it as an app error, on every single
execution.
One sloppy third-party node can bury the real errors in thousands of
copies of
its own bug. This wraps each hook so if it crashes, we log a quiet
warning for
the node author instead of screaming it into the error dashboard.

## What

`ComfyApi` extends `EventTarget`. When a listener throws, native
`dispatchEvent`
reports the exception to the **global** error handler (`window.onerror`
/
`reportError`) and continues. Our error monitoring (Datadog RUM)
collects those
as unhandled errors — so a single misbehaving custom-node listener (e.g.
`comfyui-enricos-nodes` reading `.type` on an undefined message inside
an
`executed` handler) produces thousands of non-actionable error events.

This wraps listeners registered via `addEventListener` /
`addCustomEventListener` in a try/catch:

- The wrapper is stored in a `WeakMap<original, wrapped>`, so
`removeEventListener`
/ `removeCustomEventListener` still match the installed wrapper. (GC'd
weakly.)
- Caught errors are logged at **`console.warn`**, not `console.error` —
RUM
collects `console.error` by default, which would just relabel the same
noise.

Native `EventTarget` already isolates listeners from one another, so
this does
**not** change dispatch/continuation behaviour — it only changes where a
thrown
error goes (a warn in the console, visible to node authors, instead of
an
unhandled error in global telemetry).

## Test plan

- [x] New `api.eventListeners.test.ts`: a throwing listener doesn't
abort
  dispatch to other listeners; errors log at `warn` not `error`;
  `removeEventListener` still removes a guarded listener. (3 tests)
- [x] Existing `api.featureFlags.test.ts` (15) and
`changeTracker.test.ts` (16)
  still pass — 34 total green.
- [x] oxfmt + oxlint clean.

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

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-23 16:55:23 +00:00
Matt Miller
ac56ecf009 refactor: unify image editor upload contract (FE-750) (#12318)
## Summary

Collapse the OSS vs Cloud branching in the mask editor and painter
uploads so both runtimes use the same contract — POST to `/upload/image`
with `type: input` and no `subfolder`, then reference the result by
filename only.

## Related

- Companion spec change (Comfy-Org/ComfyUI):
https://github.com/Comfy-Org/ComfyUI/pull/13968 deprecates
`/api/upload/mask` and documents `/api/upload/image` as the unified
upload contract. No runtime behavior changes on the server.

## Changes

- **What**:
- `useMaskEditorSaver.ts`: replace the separate `uploadMask` +
`uploadImage` helpers with a single `uploadLayer`. All four layers
(masked, paint, painted, paintedMasked) now go through `/upload/image`.
Dropped the `original_ref` form field and the `clipspace` subfolder.
`uploadLayer` throws on a non-ok status, on a non-JSON response body,
and on a 200 response missing `data.name` (no more silent fallback to
the pre-upload ref).
- `usePainter.ts`: removed the runtime branch on upload type/subfolder
and on the returned widget value. Always uploads as `type: input` with
no subfolder; widget value is `${filename} [input]`. Added a `data.name`
guard alongside the existing non-ok and JSON-parse guards.
- `usePainter.test.ts`: assert the upload FormData carries `type=input`
with no `subfolder`, plus new coverage for the missing-name and
JSON-parse-failure error branches.
- `browser_tests/tests/maskEditor.spec.ts`: drop the now-dead
`**/upload/mask` Playwright route interceptors and tighten the
save-success assertion to confirm exactly four `/upload/image` calls
(one per layer).
- **Breaking**: yes for downstream consumers — the mask editor no longer
calls `/upload/mask`, and saved widget values for both editors no longer
contain a subfolder prefix. Existing nodes will continue to load their
referenced inputs because the filename is preserved; new saves emit the
unified shape.

## Review Focus

- Confirm the mask editor's four-layer upload (masked, paint, painted,
paintedMasked) is still correct without `original_ref` — the layers are
now independent uploads rather than alpha-composited server-side. The
four blobs are composited client-side in `prepareOutputData` before
upload, so the previous `original_ref` chain was vestigial — but worth a
second look.
- OSS-side painter widget back-compat: previously stored as
`painter/foo.png [temp]`, new saves emit `foo.png [input]`. The old
format remains valid in saved workflows; only the *new save* shape
changes. Loading a pre-existing OSS workflow with a `painter/foo.png
[temp]` widget value still resolves via the existing parser path.

## Test plan

Unit:
- [x] `usePainter.test.ts` — 30 tests pass, including the three new ones
covering FormData payload shape, missing `data.name`, and bad JSON.

E2E:
- [x] `browser_tests/tests/maskEditor.spec.ts` — `save uploads all
layers and closes dialog` and `save failure keeps dialog open` updated
to the unified contract.

Smoke-tested manually against a Cloud staging instance:
- [x] **Painter node**: paint strokes → save → reload workflow → widget
value resolves and the canvas re-renders cleanly. Upload responds with
`subfolder: ""`, `type: "input"`.
- [x] **Mask editor**: open editor with a base image, paint + mask,
save. All four layers (`clipspace-mask-*`, `clipspace-paint-*`,
`clipspace-painted-*`, `clipspace-painted-masked-*`) POST successfully
to `/upload/image` and return 200 with the asset-aware response shape
(`{ name, subfolder: "", type: "input", asset: { id, hash, tags } }`).
The resulting `LoadImage` node references the painted-masked filename by
basename only and re-renders.

Not validated here: OSS ComfyUI core-side smoke. The contract is
symmetric (the OSS `/upload/image` endpoint accepts the same fields the
FE now sends), but a smoke against OSS HEAD is recommended before merge.

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-23 16:54:59 +00:00
pythongosssss
e97cca9e4a feat: show node preview ghost when adding models from dialog & sidebar (#12765)
## Summary

Adds consistent ghost node behavior when clicking "use" on models from
either the model dialog or treeview - matching the Node Library & Node
Search.

## Changes

- **What**: 
- Split `createModelNodeFromAsset` into `resolveModelNodeFromAsset` and
`startModelNodeDragFromAsset` to allow sharing logic between the two
drag sources
- Move NodeDragPreview file & use from being node library tab specific
to app level in GraphCanvas so all drag sources share it
- drag listeners now attach on `startDrag` and detach on cancel instead
of living for the tab's lifetime
- Updated `LGraphNodePreview` to accept widgetValues and prepend combo
options with passed value so it shows in the preview as the default

## Review Focus
- refactored positioning to use RAF with useMouse and transform to fix
laggy-follow behavior present in Firefox

## Screenshots (if applicable)

Model dialog + Node library

https://github.com/user-attachments/assets/b227ac43-c6ea-4cf6-86ed-6cfb196fd80e

Model library sidebar

https://github.com/user-attachments/assets/bb546aee-5099-4df9-abe5-68bccd8fa2eb
2026-06-23 10:41:14 +00:00
Dante
49a7b7b558 feat(billing): UnifiedPricingTable — one table, personal/team plan toggle (B4 / FE-934) (#12666)
## What
**B4 (FE-934): `UnifiedPricingTable`** — one pricing table for the
**Jun-5 model** (typeless workspace; personal/team is a **plan**, shown
as a Gamma-style **plan toggle** on one workspace), per **DES-197**. A
new, flag-gated component that will replace the two legacy tables at
cutover (strangler).


### video 

https://github.com/user-attachments/assets/82b704a4-101e-4609-8ff5-06b7cf7f9cd7



> **Stacked on #12644 (FE-935 `CreditSlider`).** Base is the slider
branch — retarget to `main` once #12644 merges. The slider commits show
in the diff until then.

## Changes
- **`UnifiedPricingTable.vue`** — plan toggle (personal/team, flag-gated
on `teamWorkspacesEnabled`); personal tier cards (facade `plans` +
`TIER_PRICING` fallback, billing-cycle toggle); team column hosting
`<CreditSlider>`; Enterprise card. Reuses `useBillingContext`; emits
`subscribe`/`resubscribe` (personal) + `subscribeTeam` (team).
- **`SubscriptionRequiredDialogContentUnified.vue`** — host; wires
personal checkout through `useSubscriptionCheckout` (full flow incl.
preview/transition + **new `'success'` step**); team checkout stubbed.
- **Confirm/success screens (DES 3084-15873)** — aligned the reused
`SubscriptionAddPaymentPreviewWorkspace` /
`SubscriptionTransitionPreviewWorkspace` to DES-197 (drop `/member`,
`comfy--credits` icon, plan-specific CTAs **"Subscribe to {plan}" /
"Switch to {plan}"**); added **`SubscriptionSuccessWorkspace.vue`**
("You're all set") as the `'success'` checkout step.
- **`showPricingTable`** — renders the unified host when
`teamWorkspacesEnabled`, legacy `PricingTable.vue` for flag-off. (The
old workspace-variant fork is removed; `PricingTableWorkspace.vue`
becomes unused on flag-on → deleted at cutover.)
- **i18n** — `subscription.planScope` / `teamPlan` / `enterprise` +
`subscription.preview.{subscribeToPlan,switchToPlan}` +
`subscription.success.*` keys.

## Strategy (new component + cutover — per FE-934)
Build new, retire old at cutover (a later single PR deletes
`PricingTable.vue` + `PricingTableWorkspace.vue` + the legacy dialog
host + the dispatch fork; `TIER_PRICING` kept only as the flag-off/OSS
fallback). Avoids half-migrated conditional cruft in the live tables.

## Screenshots
Pricing captured live on the authenticated cloud-prod session
(`local.comfy.org`, flag on). Confirm/success captured in Storybook
(prop-driven) — a **personal** workspace can't reach these live until
**B1/FE-966** flips its billing path off the legacy `/customers/*`
adapter (whose `plans` is always empty); the screens themselves are
unchanged regardless of source.

**Pricing table**

| Personal | Team |
|---|---|
| <img width="420" alt="pricing-personal"
src="https://github.com/user-attachments/assets/2be3b8bc-ac54-41db-8c21-5c950d3e7338"
/> | <img width="420" alt="pricing-team"
src="https://github.com/user-attachments/assets/c4078eb4-ee7d-42f6-bcc3-375686ab7f1e"
/> |

**Confirm your payment / plan change**

| New subscription | Plan change (Pro → Creator) |
|---|---|
| <img width="380" alt="confirm-new-subscription"
src="https://github.com/user-attachments/assets/e371c744-dc64-43e8-b977-73f9f99f85bc"
/> | <img width="380" alt="confirm-plan-change"
src="https://github.com/user-attachments/assets/b1ee5ab3-c572-4c40-9b70-f078d66b78f4"
/> |

**You're all set (success)**

<img width="380" alt="success-all-set"
src="https://github.com/user-attachments/assets/8ec9e90f-8ba4-4cb0-81a7-6b4316e0c19e"
/>

## ⚠️ BE-blocked (deferred)
- **Team checkout**: the slider stop → plan-slug / subscribe-request
shape is undefined (doc **Open Q#2** / "Team-slider contract",
**BE-1254**). `subscribeTeam` is stubbed (toast "coming soon") until BE
provides it.
- **Live discount data**: stops come from the hardcoded **DES-197
fallback** (`teamPlanCreditStops.ts`) until `GET /api/billing/plans`
carries them.
- **Confirm "credits you'll get right away" line** + **async-success
routing** (Stripe-tab/`pending_payment` → in-dialog success) deferred —
see ticket notes; credits-cents unit is the open BE-1254 question.

## Verification
- `vue-tsc --noEmit`: clean (pre-commit).
- `oxlint`/`eslint`/`stylelint`/`oxfmt`: pass (pre-commit).
- `vitest` `useSubscriptionDialog.test.ts` (11) +
`useSubscriptionCheckout.test.ts` (17) + new
`SubscriptionSuccessWorkspace.test.ts` (2): pass.
- Personal checkout reuses the existing `useSubscriptionCheckout` flow;
subscribed → new `'success'` step.

## Not in scope
- Pixel-finalizing vs DES-197 (visual reference = Alex's
**reference-only** #12042); personal-card detail refinement.
- Settings / Misc-UX (FE-768/770); team-checkout wiring (BE contract).

Design: **DES-197** / **3084-15873**. Survey: *FE Billing API
Divergence* (B4 / P1 / P2 / P4).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-23 10:11:13 +00:00
jaeone94
8d82944441 fix: limit workflow models to metadata enrichment (#12990)
## Summary

This PR intentionally narrows workflow-embedded model metadata handling
so root-level `models[]` and node-level embedded model metadata can
enrich existing missing-model candidates, but can no longer create new
candidates by themselves.

## Why this PR exists

ADR 0009, **Subgraph promoted widgets use linked inputs**, changes
promoted value ownership for subgraphs. That design was implemented by
[#12197](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12197),
**Subgraph Link Only Promotion (ADR 0009)**.

Under ADR 0009, a promoted widget is represented as a standard linked
`SubgraphInput` on the host `SubgraphNode`. The host boundary owns the
promoted value identity through the host node locator plus
`SubgraphInput.name`. The interior source widget remains the provider of
schema, type, options, tooltip, defaults, diagnostics, and migration
metadata, but it is not the persistence owner of the promoted value.

This PR is a preparatory cleanup discovered while working on the missing
model detection follow-up required by that ADR 0009 /
[#12197](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12197)
behavior. The follow-up needs missing model detection to respect the new
subgraph promoted-widget ownership model. While reviewing that path, we
found that the existing embedded model metadata fallback in
`enrichWithEmbeddedMetadata` was doing more than metadata enrichment.

The important finding was that this fallback was not just attaching
metadata to candidates that had already been detected from live node
widgets. It could also synthesize brand-new `MissingModelCandidate`
entries from workflow JSON metadata, including root-level `models[]`
entries, when no live candidate existed.

That behavior is inaccurate for the missing model system for two
reasons.

First, the normal missing model lifecycle is anchored to a real
node/widget binding. A candidate found from a COMBO or asset widget has
a concrete `nodeId + widgetName` reference. That reference is what lets
the UI surface the error, cache it as a pending warning, and later clear
or resolve it when the underlying node/widget value is fixed. A
root-level `models[]` entry does not reliably provide that anchor. If
metadata-only fallback creates a candidate without a real live widget
reference, the resulting error can be detected but cannot reliably
travel through the existing clearing path. In practice, that can become
an effectively unremovable missing model warning unless the user
downloads exactly the same model referenced by the stale metadata.

Second, a missing model error is meant to mean that a model-selecting
widget on an active node references a value that is not available.
Workflow JSON metadata by itself is not the same source of truth. If a
model only appears in root workflow metadata, or appears in node
metadata that is not represented by an active COMBO or asset widget
candidate, that is a different kind of state from the existing missing
model error model. Treating that metadata as a candidate creates a
second, less reliable detector that is not aligned with the scan/clear
lifecycle.

This is especially important before the ADR 0009 missing-model
follow-up. With linked-input promoted widgets, the host promoted value
is the value that matters. The interior source widget may still carry
stale or default metadata, and it must not become a second source of
truth for missing model errors. A detection path that can create
candidates directly from workflow metadata would make it harder to
reason about which value actually produced the warning.

For those reasons, this PR removes metadata-only candidate synthesis and
keeps embedded metadata in the role it can perform safely: metadata
enrichment. If the live widget/asset scan produces a candidate, embedded
metadata may fill in `directory`, `url`, `hash`, and `hashType`. If no
live candidate exists, the metadata is not enough to create a missing
model warning.

This PR is intended to land before the child PR that updates runtime
missing model detection for ADR 0009 linked-input promoted widgets.

## Changes

- **What**: Restrict `enrichWithEmbeddedMetadata` to enriching existing
candidates instead of creating fallback candidates from unmatched root
`models[]` or embedded model metadata.
- **What**: Remove the now-unused installed-model check callback and
asset-support callback from `enrichWithEmbeddedMetadata`.
- **What**: Remove the now-unnecessary `modelStore.loadModelFolders()`
path from the missing model pipeline, since embedded metadata no longer
performs installed-model fallback detection.
- **What**: Remove dead source-tracking metadata
(`EmbeddedModelWithSource`, source node/widget fields, and widget-name
lookup) that only existed to support metadata-only synthesis.
- **What**: Update missing model tests so they assert the new contract:
metadata enriches live candidates, but does not create candidates
without a live scan result.
- **What**: Delete obsolete fixtures that only covered the removed
metadata-only synthesis path.
- **Breaking**: None expected. This is an intentional narrowing of an
inaccurate fallback detector, not a public API change.
- **Dependencies**: None.

## Review Focus

Please focus on whether the candidate lifecycle now has a single source
of truth: live COMBO/asset widget scanning creates candidates, while
workflow metadata only enriches those candidates.

The intended behavioral change is that a model present only in
workflow-level metadata, with no active node widget candidate
referencing it, no longer appears as a missing model. This avoids
surfacing warnings that cannot be cleared through the normal `nodeId +
widgetName` path.

The expected retained behavior is that active widget-referenced missing
models are still detected by `scanAllModelCandidates`, and metadata from
root `models[]` or node `properties.models` still supplies
download-related fields for those live candidates.

## Screenshots (if applicable)

Not applicable. This is a detection/pipeline behavior change covered by
unit tests.

## Validation

- `pnpm test:unit src/platform/missingModel/missingModelScan.test.ts
src/platform/missingModel/missingModelPipeline.test.ts`
- `pnpm exec eslint src/platform/missingModel/missingModelScan.ts
src/platform/missingModel/missingModelScan.test.ts
src/platform/missingModel/missingModelPipeline.ts
src/platform/missingModel/missingModelPipeline.test.ts
src/platform/missingModel/types.ts`
- `pnpm exec oxfmt --check src/platform/missingModel/missingModelScan.ts
src/platform/missingModel/missingModelScan.test.ts
src/platform/missingModel/missingModelPipeline.ts
src/platform/missingModel/missingModelPipeline.test.ts
src/platform/missingModel/types.ts`
- `pnpm typecheck`
- pre-push hook: `knip --cache`
2026-06-23 07:44:43 +00:00
Dante
a0f4feb111 feat(billing): downgrade-to-personal member-removal confirm flow (FE-977) (#12789)
## Summary

Owner-initiated team→personal downgrade flow: a confirm dialog ("Change
to {plan} plan?", "All other members of this workspace will be
immediately removed", type-"I understand" gate) that removes non-owner
members and then initiates the tier change. No dialog when there are no
other members.

## Changes

- **What**: `DowngradeRemoveMembersDialogContent.vue` (confirm + type-"I
understand" gate, destructive Change plan); `useDowngradeToPersonal.ts`
(validates via `previewSubscribe`, removes every non-creator member via
`workspaceApi.removeMember`, then `useBillingContext().subscribe` with
`needs_payment_method`/`pending_payment` handling à la
`useSubscriptionCheckout`);
`dialogService.showDowngradeToPersonalDialog` (refreshes members,
skip-dialog when no other members; non-dismissable while open).
`subscription.downgrade.*` i18n.
- **Breaking**: none.

## Review Focus

- **Creator protection**: the cloud model is single-owner with no
distinct creator field — a member is protected if `role === 'owner'` OR
they are the current user. To be reconciled with FE-770's
earliest-`joinDate` creator inference when both land (single source: the
`isCreator` predicate). BE-1337 will expose an explicit original-owner
determination.
- **Integration seam**: there is no on-main team→personal trigger
(FE-934 pricing-table plan change is unmerged); the flow is exposed via
`dialogService.showDowngradeToPersonalDialog` for FE-934 to call on
`transition_type: 'downgrade'`.
- **Failure-path hardening** (follow-up commits): `previewSubscribe`
gate runs before any member is removed (a BE-disallowed transition
removes nobody and surfaces the BE reason); `subscribe` outcomes
`needs_payment_method` (payment tab + billing-op polling; a
popup-blocked tab throws so the dialog stays open and a retry
re-subscribes) and `pending_payment` (polling) are handled instead of
discarded; the member list is refetched before the no-members fast path
(a stale empty store can no longer skip the confirm gate);
fast-path/refresh failures toast instead of escaping as unhandled
rejections; the dialog is not dismissable via ESC/overlay-click
(`closable: false` — `dialogStore.updateCloseOnEscapeStates` derives
`closeOnEscape` from `closable`, so `closeOnEscape: false` alone is
overridden).
- **Accepted non-atomicity (until BE-1337)**: removals and the tier
change are separate FE-orchestrated calls — if the user confirms, then
abandons/fails the payment-method step, members are already removed
while the plan stays team (poller surfaces an error toast after 120s).
The dedicated BE downgrade endpoint makes removal+transition atomic.
- Tests: type-gate exact-match; no-dialog when no other members;
preview→remove→subscribe ordering; disallowed preview removes nobody;
payment-method/pending/popup-blocked outcomes; member-refresh gate
(composable + dialogService level); non-dismissable dialog props;
fast-path toast; creator excluded; cancel no-ops; error keeps dialog
open (23 green). typecheck / oxlint / eslint / stylelint / oxfmt / knip
clean.

## Screenshots

Captured live on the PR branch against mocked workspace/billing APIs
(4-member team workspace; `DELETE /api/workspace/members/:id` and `POST
/api/billing/subscribe` intercepted). Flow verified end-to-end: all 3
non-owner members removed, then `subscribe('standard-monthly')` issued;
owner retained.

| Confirm gate (initial) | Phrase typed → enabled | Removing members +
subscribing |
| --- | --- | --- |
| <img width="400" alt="initial"
src="https://github.com/user-attachments/assets/bd5c2d41-edc5-48c2-a44e-b95f9b6bcbd7"
/> | <img width="400" alt="typed enabled"
src="https://github.com/user-attachments/assets/69d5ef0f-56f4-44ce-94a3-f4c05594f403"
/> | <img width="400" alt="loading"
src="https://github.com/user-attachments/assets/073ff7b3-49ca-4fda-9ab8-0fca869f7347"
/> |

In context over the pricing table (the FE-934 trigger seam):

<img width="800" alt="dialog over pricing table"
src="https://github.com/user-attachments/assets/6589f823-f511-42eb-ae2e-5d7d523bb2ee"
/>

Disallowed transition (`previewSubscribe` gate): BE reason surfaced,
dialog stays open, no member removed:

<img width="800" alt="preview disallowed error toast"
src="https://github.com/user-attachments/assets/58759c77-d824-4bfe-80d4-3847e2145456"
/>

Fixes FE-977
2026-06-23 02:24:17 +00:00
Dante
d4be483c03 fix(billing): widen user popover so the credits row keeps both buttons inside (#13052)
## Issue

In the top-right user popover, a **cancelled-but-still-active** personal
subscription renders **both** "Add credits" and "Resubscribe" in the
credits row (the user can still top up *and* re-subscribe during the
grace period). With a wide (7-digit) credit balance, balance + help icon
+ both buttons exceeded the fixed `w-80` (320px) popover and the
trailing "Resubscribe" button spilled past the right edge.

Surfaced during FE-991 (Billing Rework V1) testing. Pre-existing on
`main` — reproducible for any personal owner whose subscription is
cancelled but not yet expired.

## Fix

Make the popover width **fluid** instead of fixed: `w-fit` clamped to
`min-w-80 max-w-96`. It stays **320px** in the common single-action case
(unchanged) and only grows — to **~370px** — when the credits row
actually needs the room for a second button.

## Before / After

**Before (`w-80`)** — "Resubscribe" clipped past the popover edge:

<img width="320" alt="before"
src="https://github.com/user-attachments/assets/439baae8-9e04-4cdf-b43f-098fb5e3853f"
/>

**After — single action (stays 320px):**

<img width="320" alt="after-single"
src="https://github.com/user-attachments/assets/e96f784e-6afd-4286-80c3-1cf0ecec7aa8"
/>

**After — both actions (grows to ~370px, fits):**

<img width="370" alt="after-both"
src="https://github.com/user-attachments/assets/578c1528-24ad-4717-a2b5-33e1af78f048"
/>

## Test

Adds a `@cloud` e2e that opens the popover in the cancelled-but-active
state (mocked `/customers/cloud-subscription-status` with `end_date` + a
7-digit balance) and asserts the "Resubscribe" button's right edge stays
within the popover bounds — same bounding-box pattern as
`workspaceSwitcher.spec.ts`. Validated red→green locally (fails on fixed
`w-80`, passes with the fluid width); single-action width measured at
320px, both-action at ~370px.

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-23 01:55:46 +00:00
Dante
8d0b21e9e8 fix(billing): keep successful team subscribe when post-write refresh fails (#12951)
## What

Mirrors the personal/legacy adapter fix (`useLegacyBilling.subscribe` in
#12945, FE-967) on the **team** workspace adapter.

`useWorkspaceBilling.subscribe` performed the write
(`workspaceApi.subscribe`) and then refreshed status + balance with
`Promise.all([fetchStatus(), fetchBalance()])`. A post-write **refresh**
failure rejected the whole call, so the caller saw "subscribe failed"
and could prompt a retry of an **already-active** subscription.

## Fix

The refresh is now non-fatal: `Promise.allSettled` runs the refresh and,
on a rejected refresh, surfaces a soft signal via the existing `error`
ref (`Subscription succeeded, but billing state refresh failed`) and
returns the successful `SubscribeResponse`. Write semantics and the
success / needs-payment / pending branches are unchanged; a failed
**write** still rejects as before.

## Test

Added a regression test: `subscribe` resolves but the post-write refresh
rejects -> still returns the response (no false failure) and records the
soft error.

## Verification

- `pnpm typecheck` clean
- `pnpm test:unit
src/platform/workspace/composables/useWorkspaceBilling.test.ts` -> 41
passed
- eslint / oxlint / oxfmt clean on changed files
2026-06-23 01:04:49 +00:00
Matt Miller
69858538d0 Adopt jobs-namespace cancel endpoints in the jobs panel (#12863)
## ELI-5

When you cancel a job in the jobs panel, the app used to pick a
different cancel button under the hood depending on which backend it was
talking to and whether the job was already running or just waiting in
line. That meant three code paths for one user action. This swaps all of
that for a single "cancel this job" request that works the same way no
matter the job's state — plus a single "cancel all running jobs" request
for the bulk case.

## What

- Single cancel now calls `POST /api/jobs/{job_id}/cancel`.
- Batch / "cancel all running" now calls `POST /api/jobs/cancel` with
body `{ "job_ids": [...] }`.
- Removed the runtime + job-state branching that previously routed
cancellation through `/api/queue { delete }` or `/api/interrupt`.
- Added two thin client methods (`api.cancelJob`, `api.cancelJobs`) that
target these endpoints and throw on failure so existing error handling
fires.

The "clear queue" (clear-all-pending) action is intentionally
**unchanged** and still uses the existing `/api/queue` path — there is
no jobs-namespace replacement for it, and it is out of scope here.

## Why

The cancel flow had three branches (running vs pending, and one backend
vs another) for a single user intent. The jobs-namespace endpoints are
state-agnostic and idempotent (already-terminal jobs are a successful
no-op), so one call covers every case. Collapsing the branches removes
runtime-specific conditionals from the panel and makes the cancel
behavior identical everywhere.

## ⚠️ Dependency — do not merge before runtime parity

This change relies on the runtime that serves the API exposing **both**
of these endpoints:

- `POST /api/jobs/{job_id}/cancel`
- `POST /api/jobs/cancel`

Exposing these on every runtime this UI runs against is **in flight and
not yet complete**. Until that parity lands, some runtimes will not have
these endpoints, and cancellation would fail there.

**This PR should sit ready and only be merged once that runtime parity
exists.** Do not enable auto-merge. A code comment next to each cancel
site (and on the new client methods) restates this dependency.

## Testing

- `npx vue-tsc --noEmit` — clean (0 errors).
- `npx vitest run src/scripts/api.cancel.test.ts
src/composables/queue/useJobMenu.test.ts
src/components/queue/QueueProgressOverlay.test.ts` — 48 passed.
- `npx eslint` on the touched non-ignored files — clean.

New/updated unit tests cover the single cancel call, the batch cancel
call (including the empty-list no-op), and the error path (request
failure propagates and skips the queue refresh).

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-22 20:21:24 +00:00
Matt Miller
001b132b0c feat(assets): wire infinite scroll to the flat-output provider in the widget select dropdown (#12780)
## Summary

Wires the previously-dormant `loadMore` path of the flat-output assets
provider into the widget select dropdown, so cloud users with more than
one page of outputs can actually reach them. Stacked on #12774 (FE-985);
retargets to `main` when that merges.

## Changes

- **What**: `VirtualGrid`'s existing `approach-end` event now forwards
through `FormDropdownMenu` → `FormDropdown` → `WidgetSelectDropdown`,
which calls `outputMediaAssets.loadMore()` guarded by
`hasMore`/`isLoadingMore` and debounced 300ms — the same idiom as
`AssetsSidebarTab`. A spinner row (`loadingMore` prop) renders below the
grid while a page is in flight. On cloud this drives the FE-985 cursor
walk; on OSS it drives the FE-962 jobs-history cursor walk via
`useAssetsApi`.

## Review Focus

- Verified end-to-end against the dev server with a mocked 100-asset
backend: scroll → `after=cur-40` → `after=cur-80` → stops at
`has_more:false` (100/100, no duplicate fetches).
- **Known platform limitation, not introduced here**: VueUse ≥14's
`throttleFilter` with `leading=false` drops events spaced wider than the
throttle window, so `useScroll`'s `throttle: 64` in `VirtualGrid` never
reports discrete mouse-wheel scrolls — only high-frequency
(trackpad-style) scrolling triggers `approach-end`. This equally affects
the assets sidebar and manager dialog today; bug filed separately with
root cause. Fixing it makes this wiring work for wheel users with no
further changes.
- Underfill edge: `approach-end` cannot fire when loaded items don't
overflow the viewport (shared VirtualGrid trait with the sidebar); with
the 200-item page size this only matters for heavily-filtered media
types.

- Fixes FE-988

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

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-22 20:20:55 +00:00
jaeone94
56b05c0fd5 chore: shrink-wrap asset browser dialog chrome (#13060)
## Summary

- Shrink-wraps the asset browser dialog chrome around its self-sized
BaseModalLayout content.
- Applies the same Reka dialog props to model-widget selection and
direct asset browsing entry points.

## Cause

The Reka dialog cutover moved showLayoutDialog callers onto the shared
Reka DialogContent wrapper. AssetBrowserModal already renders a large
BaseModalLayout with its own modal sizing, but the outer wrapper still
used the default md dialog width. That left the large modal content
anchored from a narrow centered wrapper, pushing the right edge beyond
the viewport.

This change keeps BaseModalLayout as the owner of the asset browser
dimensions. The Reka wrapper now only shrink-wraps the content and
removes its own border, background, and shadow.

## Validation

- pnpm exec oxfmt --write
src/platform/assets/composables/useAssetBrowserDialog.ts
- pnpm exec eslint
src/platform/assets/composables/useAssetBrowserDialog.ts
- pnpm typecheck
- pre-commit hook completed oxfmt, oxlint, eslint, and typecheck

## Screenshot
Before 
<img width="1920" height="1028" alt="스크린샷 2026-06-22 오후 9 30 15"
src="https://github.com/user-attachments/assets/1534a0a8-a239-419e-b05f-9c5e43cedeb1"
/>

After
<img width="1918" height="1024" alt="스크린샷 2026-06-22 오후 9 29 39"
src="https://github.com/user-attachments/assets/14ad751e-54c9-4f9e-87f5-805f6ca456d1"
/>
2026-06-22 15:52:33 +00:00
pythongosssss
403353ac77 feat: add tab status indicator (running/done/errored) (#10177)
## Summary

Adds indicator to show outcome of last job per tab, cleared next time
the workflow is activated.

## Changes

- **What**: 
- add workflow status tracking to execution store, handling various
events
- add icon to tab based on store
- handle race condition where job finishes instantly (e.g. invalid
workflow or already executed)

## Screenshots (if applicable)



https://github.com/user-attachments/assets/8b1d8d8e-57d4-4ac2-9cc3-0d218d6eb0f7

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10177-feat-add-tab-status-indicator-running-done-errored-3266d73d365081a89f5dfd58487bb065)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-22 09:22:14 +00:00
Christian Byrne
c4db198875 fix(test): de-flake Canvas ctrl+shift+vertical-drag zoom e2e (#13024)
## Observed flake

The e2e test `Canvas Interaction > Can zoom in/out with
ctrl+shift+vertical-drag` (`browser_tests/tests/interaction.spec.ts`)
intermittently failed in CI with:

```
Error: mouse.move: Test timeout of 15000ms exceeded
```

It failed all 3 retries on a single shard while 209 other tests on that
shard passed, and passed on a later re-run — i.e. genuinely flaky, not a
hard break.

## Root cause

The test pressed `Control` and `Shift` *down* once, then ran three
`canvasOps.dragAndDrop` gestures (each performs a `mouse.move(target, {
steps: 100 })`) with three `toHaveScreenshot` assertions interleaved,
and only released the modifiers at the very end — with no `try/finally`.

Two problems:
1. Holding the modifiers across all three drags means every one of the
~300 step-wise `mousemove` events drives litegraph's ctrl+shift zoom
handler (scale recompute + canvas redraw). Combined with the screenshot
captures in between, the main thread can saturate, and a single
`mouse.move` step can stall past the 15s test timeout. That is exactly
the failing call in the signature.
2. Without `try/finally`, a mid-test failure leaves `Control`/`Shift`
stuck down.

## Fix

Switch to the existing `canvasOps.ctrlShiftDrag(from, to)` helper, which
presses and releases `Control`+`Shift` around each individual gesture.
This is the robust pattern already used in `canvasSettings.spec.ts`. The
modifiers are never held across the heavy multi-drag + screenshot
sequence, and are always released.

Test intent, drag coordinates, and all three screenshot assertions are
unchanged.

## Validation

- Verified by reasoning: `ctrlShiftDrag` wraps the same `dragAndDrop`
with `keyboard.down/up` of the same modifiers, called with identical
`Position` args, so behavior and types are preserved.
- Could not run the browser e2e locally (requires a running ComfyUI
backend + Playwright browsers; `node_modules` not installed in this
environment). Relying on CI for the full e2e run.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-20 03:32:41 +00:00
Christian Byrne
040e490f02 fix: update share dialog acknowledgement copy (#13044)
*PR Created by the Glary-Bot Agent*

---

Update the English copy on the share dialog acknowledgement checkbox to
better reflect the link-based visibility model.

**Before:** "I understand these media items will be published and made
public"
**After:** "I understand anyone with the link can view these files"

Only the `en` locale string was changed
(`shareWorkflow.acknowledgeCheckbox` in `src/locales/en/main.json`), per
the request. Other locale files are intentionally left to the standard
translation sync process.

The component (`ShareAssetWarningBox.vue`) reads this string via
`$t('shareWorkflow.acknowledgeCheckbox')`, so no component change is
needed.

## Verification
- `ShareAssetWarningBox.test.ts` — 12 passed (tests use their own mock
string and are unaffected).
- `ShareWorkflowDialogContent.test.ts` — 19 passed.
- Lint-staged hooks (oxfmt, oxlint, eslint, typecheck) ran on commit and
passed.
- Visual verification: rendered the new copy in a representative
checkbox layout via Playwright and confirmed the user-facing text
matches (screenshot attached).

## Screenshots

![Share dialog acknowledgement checkbox showing new copy: 'I understand
anyone with the link can view these
files'](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/2c2cd89e57ce203d296953b0c02b0d05e0d065a22629e974bc336f890fa85d2e/pr-images/1781924023274-711d37d8-f31f-4c42-bd00-bc37ae83b3ed.png)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-06-20 03:31:40 +00:00
Dante
90c523b4a3 feat(dialog): flip remaining callers + default renderer to Reka (Phase 6a cutover, stacked on #12848. 6a-2) (#12593)
## Summary

The **renderer cutover** for Phase 6: every remaining dialog caller is
flipped to Reka, and `createDialog` now defaults `renderer: 'reka'` so
the PrimeVue `Dialog` branch is no longer reached by default (it
survives only as an explicit `renderer: 'primevue'` escape hatch,
deleted in Phase 6b).

> **Stacked on #12848** (mask editor + 3D viewer dialogs + dialog
infra). Per @jtydhr88's review, the heavy, screenshot-bearing surface
(3D + mask editor) was split into #12848 so it reviews and tests on its
own. **Merge #12848 first**, then this PR's base auto-retargets to
`main`.

Parent:
[FE-571](https://linear.app/comfyorg/issue/FE-571/dialog-system-migration-primevue-reka-ui-parent)
This phase:
[FE-578](https://linear.app/comfyorg/issue/FE-578/phase-6-remove-primevue-dialogconfirmdialog-imports-clean-up-css)

## Changes

- **drop dead `ConfirmationService` registration** — zero
`useConfirm`/`<ConfirmDialog>` consumers remain in `src/`; desktop-ui
keeps its own.
- **flip `showConfirmDialog`** — all six confirm callers render Reka
chrome; width goes from PrimeVue auto-hug to fixed `size:'md'`, matching
`dialogService.confirm()`.
- **flip remaining `dialogService` + composable callers** — signin,
update-password, top-up, workspace family, cancel-subscription, publish,
cloud-notification, edit-keybinding / node-conflict / import-failed,
upload-model, queue-clear-history, delete-assets, share /
open-shared-workflow, subscription pricing. Self-styled panels get a
shared transparent `w-fit` chrome replicating PrimeVue's auto-sized
root.
- **default `createDialog` to `renderer:'reka'`** — cuts over
`showExtensionDialog` (third-party dialogs) and anything unflagged. The
single-commit revert point.
- **retarget class-based e2e selectors** — `BaseDialog` `.p-dialog` →
`getByRole('dialog')`, `BuilderSaveAsHelper` close-X → `getByLabel`,
`shareWorkflowDialog` role-based, dead `confirm-dialog` testid removed.
- honor `[autofocus]` inside Reka dialogs; size the template browser
dialog so the filter bar fits; drop redundant Tailwind width constraints
on the remaining callers.

## Review focus

1. **`modal:false` on the pricing dialogs** — same trade-off as
Settings/Manager (visual overlay without focus trap) because
`PricingTable(.Workspace)` hosts a body-teleported PrimeVue `Popover`.
2. **`w-fit` shrink-wrapped chrome** for self-styled panels — replicates
PrimeVue's shrink-to-fit root.
3. **Confirm width change** (auto-hug → fixed 576px `md`) — intentional
consistency with `dialogService.confirm()`.

## Public API impact

`createDialog` now defaults to Reka. Third-party extension dialogs
render through Reka by default — a fixed `size:'md'` frame with a modal
focus trap instead of PrimeVue auto-width; `renderer:'primevue'` remains
an explicit escape hatch until Phase 6b. Worth a release note for
extension authors.

## Out of scope (Phase 6b)

PrimeVue branch deletion (`GlobalDialog.vue` legacy branch,
`PrimeDialog` import, `.p-dialog` CSS/bridge tokens, `dialogStore`
`pt`/`position`/`unstyled` typing) — lands after this soaks one cloud
deploy cycle.

## 📸 Screenshots — manual verification

Captured via Chrome DevTools (CDP) from this branch running locally in
**cloud mode** (proxied to the `cloud.comfy.org` backend, free Personal
Workspace). Every dialog below now renders through the **Reka** path —
the PrimeVue `Dialog` branch is no longer reached. (Mask editor + 3D
viewers live in the stacked base #12848.)

**Confirm dialog** (`showConfirmDialog`) — Reka chrome at a fixed
`size:'md'`, replacing PrimeVue's auto-hug width — *review focus #3*
<img width="880" alt="confirm-dialog"
src="https://github.com/user-attachments/assets/5d9953c1-4d0c-4ff9-adc7-88dd370c6a24"
/>

**Settings** — renders through Reka
<img width="880" alt="settings"
src="https://github.com/user-attachments/assets/44e3fd3f-8d9b-4322-8fbe-8ce8d94ed15d"
/>

**Edit Keybinding**, stacked on Settings — small-layout `w-fit` chrome;
closing it leaves Settings open (stacked-dismiss holds)
<img width="880" alt="edit-keybinding-nested"
src="https://github.com/user-attachments/assets/d0875c00-7b9c-439d-b24d-ba6770009d08"
/>

**Subscription pricing** (`PricingTable`) — opened with `modal:false`
because it hosts a body-teleported PrimeVue `Popover` — *review focus
#1*
<img width="880" alt="subscription-pricing"
src="https://github.com/user-attachments/assets/3be20397-8a69-4b00-b803-73eff4e0e313"
/>

**Share** and **Publish** (open-shared-workflow + publish) — shared
transparent shrink-wrapped (`w-fit`) chrome — *review focus #2*
<img width="880" alt="share-dialog"
src="https://github.com/user-attachments/assets/16f1c1b5-e35e-4664-a957-2f7f61ad96bd"
/>
<img width="880" alt="publish-dialog"
src="https://github.com/user-attachments/assets/935ff453-5247-430f-9c21-2f500d4bc6e2"
/>

**Workspace** (workspace-family callers)
<img width="880" alt="workspace-settings"
src="https://github.com/user-attachments/assets/8031a352-f6fc-41e4-9567-e26e0c35ecd9"
/>

**Template selector** (`showExtensionDialog` /
`useWorkflowTemplateSelectorDialog`)
<img width="880" alt="templates-dialog"
src="https://github.com/user-attachments/assets/9975ebbe-75ae-4ad9-a90a-248db4850e1a"
/>

**Account / workspace menu** (cloud)
<img width="880" alt="account-menu"
src="https://github.com/user-attachments/assets/5bc0cade-9bd9-49de-8bb4-779d65e211b0"
/>
2026-06-20 00:12:01 +00:00
Dante
1f759a758c feat(workspace): creator-only canManageSubscriptionLifecycle permission (FE-770) (#12829)
## What

Adds a creator-only `canManageSubscriptionLifecycle` permission (cancel
/ reactivate / downgrade) to `useWorkspaceUI`:

- Any workspace **owner** keeps `canManageSubscription` (manage payment
/ top-up / change-commit).
- Only the **original owner (creator)** also gets
`canManageSubscriptionLifecycle`.
- **Personal** workspaces (single-member) always get it; **members**
never do.

Driven by an `is_creator` flag on the workspace from `/api/workspaces`,
plumbed through the shared type and the auth/session Zod schema.

## Safe to merge ahead of BE / no regression

- **Pure infra — nothing reads the permission yet.** Consumers land
separately: #12786 (cancel/reactivate), #12789 (downgrade). No code on
`main` references `canManageSubscriptionLifecycle` or `is_creator`.
- **Additive & fails closed.** `is_creator` is optional; when absent the
permission is `false`. Existing permission values are unchanged, so
existing consumers (`SubscribeToRun`,
`SubscriptionPanelContentWorkspace`, `CurrentUserPopoverWorkspace`, …)
behave identically.

## BE contract (confirmed with Hunter, 2026-06-17)

- `is_creator` = current-user-relative boolean on `/api/workspaces`; the
creator is tracked explicitly (not by creation date).
- Intentionally **temporary** — removed once member-removal
auto-provisions a personal workspace.
- The FE-770 members-panel creator-**row** lock additionally needs the
creator **id** (`created_by_user_id`) — separate, still-open ask.

## Follow-up (separate PR, once BE ships)

`is_creator` + `WorkspaceWithRoleSchema` are hand-rolled only because
the cloud ingest OpenAPI doesn't expose the field yet.
`@comfyorg/ingest-types` already generates `WorkspaceWithRole` +
`zWorkspaceWithRole`. Once BE adds `is_creator` to
`services/ingest/openapi.yaml`, regenerate and swap the hand-rolled
interface + schema for the generated ones (TODOs marked in
`workspaceApi.ts` / `workspaceAuthStore.ts`).
2026-06-19 23:41:36 +00:00
Matt Miller
44557fd138 fix(asset-card): read image dimensions from typed metadata field (#12328)
## ELI-5

The asset library shows each image's resolution (e.g. "1920×1080") on
its card. It used to get that by measuring the picture the browser
actually drew — fine locally, but on cloud the card draws a small
**downscaled thumbnail**, so it measured the thumbnail and showed the
wrong size (like "512×288") instead of the real one.

Now the card prefers the **true dimensions the server records** for each
image. When the server hasn't recorded them yet (an old image not
backfilled, or something unmeasurable like an SVG), it only falls back
to measuring the drawn image when that image is the original — never a
downscaled thumbnail. If all it can measure is a thumbnail, it shows
**no size rather than a wrong one**: a blank beats a confidently wrong
number.

## Summary

Asset cards render image dimensions from the asset response's typed
`metadata` field (`asset.metadata.width` / `asset.metadata.height`) when
available, falling back to the locally-measured `naturalWidth` /
`naturalHeight` of the rendered `<img>` — but only when the rendered
image was the original, not a downscaled thumbnail.

## Why

`MediaAssetCard.vue` previously read dimensions from the rendered
`<img>`'s `naturalWidth` / `naturalHeight` — correct only when the
runtime serves the original file. Runtimes that serve a downscaled
preview return preview-sized dimensions, so the label could surface the
preview's size rather than the source asset's. Reading the typed
`metadata` field when present fixes that. And when metadata is absent,
the natural-size fallback is now **suppressed if a downscaled thumbnail
was rendered** (`asset.thumbnail_url` present), so the card shows no
dimensions rather than a misleading thumbnail size.

## What changes

- `assetMetadataUtils.ts`: new pure
`resolveDisplayImageDimensions(asset, renderedNaturalSize)` — prefers
server `metadata.{width,height}`, falls back to the rendered natural
size only when no thumbnail was rendered, otherwise returns `undefined`
(no label). Adds an exported `ImageDimensions` type.
- `MediaAssetCard.vue`: `displayImageDimensions` delegates to the
helper; `metaInfo` reads from it and does not branch on runtime
identity.
- `assetMetadataUtils.test.ts`: unit tests for
`resolveDisplayImageDimensions` across every branch (metadata present,
metadata-wins-over-thumbnail, no-thumbnail fallback, thumbnail-guard
suppression, invalid shape, both-absent, undefined asset).

## Compatibility

- **metadata has numeric `width`/`height`** → card displays the typed
(true) values.
- **metadata absent, no thumbnail rendered (original served)** →
unchanged: displays the locally-measured dimensions.
- **metadata absent, downscaled thumbnail rendered** → displays **no**
dimensions. This is the one intentional behavior change: previously the
card showed the thumbnail's size (a wrong value); now it shows a blank
until the real dimensions are available (e.g. after the cloud dimension
backfill), and stays blank for permanently-unmeasurable assets
(SVG/corrupt). Blank instead of wrong.

## Test plan

- [x] `pnpm test:unit` — `resolveDisplayImageDimensions` covered across
all branches (70 tests pass)
- [x] `oxfmt --check` + `oxlint --type-aware` clean on touched files
- [ ] `pnpm typecheck` (vue-tsc) — via CI
- [ ] Manual: asset library — image cards show real resolution on cloud;
un-backfilled / SVG cards show no size label (not a thumbnail size);
non-image cards (video / audio / 3D) still render their size label

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12328-refactor-asset-card-read-image-dimensions-from-typed-metadata-field-3656d73d3650811bb0cff00b6bc2d2e8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Matt Miller <mattmillerai@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-06-19 23:30:16 +00:00
Christian Byrne
90210292d7 fix: add explicit return types for composables to fix TS declaration errors (#13034)
## Summary
- Fix TS2742 and TS4060 errors during .d.ts generation in the types
build
- Add explicit interface definitions and return type annotations for
composables

## Changes
- `confirmDialog.ts`: explicit `DialogInstance` return type
- `useImageCrop.ts`: export `ResizeHandle` interface
- `useAbsolutePosition.ts`: `UseAbsolutePositionReturn` interface
- `useDomClipping.ts`: `UseDomClippingReturn` interface
- `useNodePreviewAndDrag.ts`: `UseNodePreviewAndDragReturn` interface
- `i18n.ts`: explicit type annotations for exported functions

## Test plan
- [ ] Types build succeeds: `pnpm build:types`
- [ ] No new lint errors: `pnpm lint`
- [ ] Type check passes: `pnpm typecheck`

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

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-19 16:34:22 -07:00
pythongosssss
c8b5589768 feat: remove subscript prompt dialog on load (#13012)
## Summary

The first thing a user sees when they log in is the paywall prompt which
is a poor UX. This removes the dialog that pops up as the run buttons
are already gated to prompt to subscribe.

## Changes

- **What**: 
- Remove extension & invocation
2026-06-19 21:31:17 +00:00
Christian Byrne
b4b95980da fix: publish core/* releases immediately to create tags (#12995)
## Summary
- Fix release pipeline where core/* releases hang indefinitely waiting
for tags

## Problem
Draft releases don't create git tags, but `publish-pypi` workflow waits
for the tag to exist. For `core/*` branches, releases were always
created as drafts, causing the pipeline to wait forever.

## Solution
Only use draft releases for prereleases (alpha/beta/rc). Publish all
stable releases (main and core/*) immediately so tags are created.

## Test plan
- [x] Verify workflow change logic is correct
- [ ] Test on next core/1.45 release

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-19 14:28:49 -07:00
Deep Mehta
8c0af36c4a fix: re-disable ultralytics asset-picker registration (#13020)
## Summary

Re-comment the `ultralytics/bbox` and `ultralytics/segm` entries in
`MODEL_NODE_MAPPINGS` so `UltralyticsDetectorProvider` falls back to the
static combo from `/api/object_info`, restoring the working post-#12075
behavior. Companion to
[Comfy-Org/cloud#4395](https://github.com/Comfy-Org/cloud/pull/4395)
(ONNX dummy-placeholder fix).

## Why

PR #12151 (2026-05-12) re-added these entries before BE-689 (the
cloud-side asset-ingestion fix) landed, reintroducing both halves of the
regression that #12075 originally cited:

1. **Tag lookup mismatch.** Cloud stores model tags as combined values
like `ultralytics/bbox`, but the asset query splits them into `(models,
ultralytics)` with exact-match filtering, so the picker returns no
results.
2. **Submitted value mismatch.** Picker returns basenames like
`face_yolov8m.pt`, but the ingest service validates against the
subdirectory-prefixed form (`bbox/face_yolov8m.pt`) that the static
combo path produces. The combo rejects the basename with `Value not in
list` (the exact error users hit in BE-1116).

Until BE-689 lands both halves of the cloud fix, the asset-picker
registration cannot ship without breaking the node.

## Changes

- **What**: comment out the two ultralytics entries in
`src/platform/assets/mappings/modelNodeMappings.ts` with an inline note
pointing at BE-689 and PR #12075 / #12151 for context.
- **Breaking**: none. The node returns to the same static-combo behavior
it had between #12075 and #12151.
- **Dependencies**: none.

## Review Focus

- The existing assertion in `src/stores/modelToNodeStore.test.ts:278`
already expects `ultralytics`, `ultralytics/bbox`, and
`ultralytics/segm` to NOT resolve to a default provider — that test was
added with #12075 and was left in place when #12151 re-added the
mappings. After this change the mappings file is consistent with the
test again. 55/55 in `modelToNodeStore.test.ts` pass locally.
- This is a targeted revert of the ultralytics half of #12151. The other
entries that PR added (`background_removal`, `frame_interpolation`,
`film`) are unaffected.

## Companion PRs

- Comfy-Org/cloud#4395 — fixes the `ONNXDetectorProvider` half of the
FaceDetailer-broken report (BE-1116) by repairing the dummy-placeholder
mechanism so the static combo carries the right model list. ONNX uses
the static-combo path (no `MODEL_NODE_MAPPINGS` entry), so the
cloud-side fix is sufficient there.

<!-- Fixes BE-689 -->
2026-06-19 20:59:56 +00:00
jaeone94
78a8d6f8fc test: stabilize node help locale e2e (#12998)
## Summary

Stabilizes the locale-specific Node Help E2E by setting the locale
through the existing Playwright settings fixture before app bootstrap
instead of racing a workflow reload pulse.

## Changes

- **What**: Removed the brittle in-page locale mutation helper and uses
`test.use({ initialSettings: { 'Comfy.Locale': 'ja' } })` for the
locale-specific documentation case.
- **What**: Keeps the Japanese and English doc routes local to the test,
then verifies the Japanese help content after loading the default
workflow.
- **Dependencies**: None.

## Review Focus

Please focus on whether the E2E now waits on the correct setup boundary.
The previous helper watched `ChangeTracker.isLoadingGraph` after
changing `Comfy.Locale`; once unrelated workflow-load work became
faster, that loading pulse could complete before the helper observed it.
Pre-boot `initialSettings` avoids that timing dependency and uses
existing test infrastructure.

Verification:

- `pnpm exec oxfmt --check browser_tests/tests/nodeHelp.spec.ts
browser_tests/fixtures/helpers/WorkflowHelper.ts`
- `pnpm exec eslint browser_tests/tests/nodeHelp.spec.ts
browser_tests/fixtures/helpers/WorkflowHelper.ts`
- `pnpm typecheck:browser`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5176 pnpm
exec playwright test browser_tests/tests/nodeHelp.spec.ts --grep "Should
handle locale-specific documentation" --project=chromium
--repeat-each=10`

## Screenshots (if applicable)

N/A
2026-06-19 19:50:04 +00:00
jaeone94
cc41e3e1ac test: stabilize cloud template filtering e2e (#12999)
## Summary

Stabilizes the cloud template filtering E2E by making the test own both
startup asset API responses and the complete template universe it
asserts against.

## Changes

- **What**: Uses the existing `createCloudAssetsFixture([])` fixture so
startup `/api/assets` calls do not create an unrelated error toast that
can intercept the Clear Filters click.
- **What**: Extends `TemplateHelper.mockIndex()` to also mock
`/api/workflow_templates` as an empty custom-template map, so tests that
configure a core template index do not accidentally include custom
templates from the local backend.
- **Dependencies**: None.

## Review Focus

Please focus on fixture ownership. This spec asserts exact template
counts, so `templateApi.mockIndex()` should isolate the core template
index and the custom workflow-template endpoint together. The asset
fixture change is intentionally scoped to this cloud spec and reuses
existing infrastructure instead of dismissing arbitrary toasts or
forcing clicks.

Verification:

- `pnpm exec oxfmt --check
browser_tests/tests/templateFilteringCount.spec.ts
browser_tests/fixtures/helpers/TemplateHelper.ts
browser_tests/fixtures/helpers/WorkflowHelper.ts`
- `pnpm exec eslint browser_tests/tests/templateFilteringCount.spec.ts
browser_tests/fixtures/helpers/TemplateHelper.ts
browser_tests/fixtures/helpers/WorkflowHelper.ts`
- `pnpm typecheck:browser`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:8188
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm exec playwright test
browser_tests/tests/templateFilteringCount.spec.ts --grep "clear filters
button resets" --project=cloud --repeat-each=5`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:8188
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm exec playwright test
browser_tests/tests/templateFilteringCount.spec.ts --project=cloud`

## Screenshots (if applicable)

N/A
2026-06-19 17:34:23 +00:00
Dante
f994673dd1 fix(test): import AppMode from utils/appMode in WorkflowHelper (#13003)
## Summary

`pnpm typecheck:browser` (`vue-tsc --project
browser_tests/tsconfig.json`) currently fails on `main`, turning
`lint-and-format` red for every PR built on it:

```
browser_tests/fixtures/helpers/WorkflowHelper.ts(5,15): error TS2459:
Module '"@/composables/useAppMode"' declares 'AppMode' locally, but it is not exported.
```

## Cause

#12925 (`bc885f383c`, "Decouple run telemetry context from providers")
moved the `AppMode` type from `@/composables/useAppMode` to
`@/utils/appMode`. `useAppMode` now only `import type`s `AppMode` (it no
longer re-exports it), so this fixture's `import type { AppMode } from
'@/composables/useAppMode'` became invalid. The other `useAppMode`
import sites are unaffected — they import the `useAppMode` composable
(still exported), not the type.

## Fix

Import `AppMode` from its actual source, `@/utils/appMode`. One line, no
behavior change.

Verified `vue-tsc --project browser_tests/tsconfig.json` no longer
reports the `WorkflowHelper.ts` error.
2026-06-19 15:17:57 +00:00
jaeone94
444dc3fccd test: stabilize mask editor screenshot e2e (#13011)
<img width="1155" height="648" alt="스크린샷 2026-06-19 오후 11 24 27"
src="https://github.com/user-attachments/assets/01ed2607-662f-4735-b0c2-2f1a2c8a8811"
/>

## Summary

Stabilizes the Mask Editor screenshot E2E by hiding the transient brush
cursor before capturing the dialog.

## Changes

- **What**: Moves the pointer from the mask editor pointer zone to the
Brush Settings panel before the screenshot and asserts that the brush
cursor is hidden.
- **Dependencies**: None.

## Review Focus

Please check that the test still exercises the dialog UI while excluding
only cursor-position noise from the screenshot. The PR also includes the
existing browser-test `AppMode` type import fix needed for
`typecheck:browser` on branches that touch `browser_tests/**`.


Validation:
- `pnpm exec oxfmt --check browser_tests/tests/maskEditor.spec.ts
browser_tests/fixtures/helpers/WorkflowHelper.ts`
- `pnpm exec eslint browser_tests/tests/maskEditor.spec.ts
browser_tests/fixtures/helpers/WorkflowHelper.ts`
- `pnpm typecheck:browser`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm
exec playwright test browser_tests/tests/maskEditor.spec.ts --grep
"opens mask editor from image preview button" --project=chromium
--repeat-each=10`

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-06-19 14:57:31 +00:00
jaeone94
ed028a88be Fix LiteGraph hidden widget metadata handling (FE-1014) (#12916)
## Summary

Fixes FE-1014 by making legacy LiteGraph honor backend-provided hidden
widget metadata.

This PR adds a regression test for the Painter node and updates
LiteGraph widget construction so that a backend input spec with `hidden:
true` is mirrored onto the top-level `widget.hidden` property that the
legacy canvas renderer actually reads.

## Problem

Backend node definitions can mark inputs as hidden, for example with
`extra_dict={"hidden": True}`. That metadata already flows into the
frontend widget options as `widget.options.hidden`, which is why Vue
nodes correctly hide those fields.

Legacy LiteGraph, however, does not use `widget.options.hidden` for
canvas visibility. Its rendering, layout, and hit-testing paths check
top-level `widget.hidden` instead. As a result, a field could be hidden
in Vue nodes while still appearing as an editable control in the legacy
LiteGraph canvas.

For affected nodes, this exposes fields that are intended to be
implementation details, schema/version values, or other non-user-facing
inputs.

## Root Cause

The frontend widget construction path copied backend display metadata
into `widget.options`, including:

- `advanced`
- `hidden`

But it did not mirror backend `hidden` metadata into `widget.hidden`.

That created a renderer split:

- Vue nodes and the right panel use `widget.options.hidden`.
- Legacy LiteGraph uses top-level `widget.hidden`.

So backend-hidden widgets were hidden in Vue mode but still visible and
clickable in legacy LiteGraph mode.

## Implementation

The production change is intentionally small and scoped to
backend-provided hidden metadata:

- Continue assigning `inputSpec.hidden` to `widget.options.hidden` as
before.
- When `inputSpec.hidden` is explicitly defined, also assign it to
top-level `widget.hidden`.

This keeps Vue behavior unchanged while making the legacy LiteGraph
renderer receive the same backend hidden signal through the field it
already uses for visibility.

The fix deliberately does not mirror `advanced` into top-level
`widget.advanced`. While investigating this area, I found that many
backend inputs define `advanced`, and changing legacy advanced-widget
behavior would be a much broader behavioral change than FE-1014
requires. This PR only addresses hidden metadata.

## Test Coverage

This PR adds and tightens Painter regression coverage because Painter
currently provides a concrete backend-hidden widget case:

- In Vue mode, the test verifies hidden Painter widgets are not rendered
to the user.
- In legacy LiteGraph mode, the test disables Vue nodes, loads the
Painter workflow, clicks the rows where backend-hidden number widgets
used to be exposed, and verifies the legacy graph editor dialog does not
open.

The legacy test specifically covers the backend-hidden number widgets
`width` and `height`. It uses user-observable behavior rather than
asserting internal widget flags directly.

A follow-up discussion is ongoing about the broader contract between
`widget.options.hidden` and top-level `widget.hidden`, especially for
frontend-extension-only hiding such as Painter `bg_color`. This PR
intentionally keeps that broader renderer-contract question out of scope
and focuses on backend `hidden` metadata from FE-1014.

## Validation

Validated locally with targeted Playwright coverage:

```bash
PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm exec playwright test browser_tests/tests/painter.spec.ts --project=chromium -g "Does not render hidden standard widgets|Does not open editors for backend-hidden number widget rows"
```

Result:

```text
2 passed
```

Also validated with linting:

```bash
pnpm eslint src/services/litegraphService.ts browser_tests/tests/painter.spec.ts
pnpm eslint browser_tests/tests/painter.spec.ts
```

The commit hooks also passed:

- `oxfmt`
- `oxlint`
- `eslint`
- `pnpm typecheck`
- `pnpm typecheck:browser`

## Notes

The new legacy test was confirmed red before the production fix and
green after the production fix, so it is not a vacuous assertion. The
final cleanup commit only tightens test naming and coordinate handling
while preserving the same regression intent.
2026-06-19 14:05:49 +00:00
238 changed files with 20681 additions and 3887 deletions

View File

@@ -15,6 +15,11 @@ reviews:
- github-actions[bot]
pre_merge_checks:
override_requested_reviewers_only: true
# Explicitly disable the built-in docstring coverage check, which is
# enabled via organization-level settings. This repo opts out at the
# repo level without affecting other org repos.
docstrings:
mode: 'off'
custom_checks:
- name: End-to-end regression coverage for fixes
mode: error

View File

@@ -85,6 +85,16 @@ jobs:
fi
done
- name: Strip non-source entries from coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
# Drop served bundle scripts (localhost-8188/assets/*.js) that V8 records but have no source file on disk, which would abort genhtml.
lcov --remove coverage/playwright/coverage.lcov \
'*localhost-8188*' \
-o coverage/playwright/coverage.lcov \
--ignore-errors unused
wc -l coverage/playwright/coverage.lcov
- name: Upload merged coverage data
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: actions/upload-artifact@v6

View File

@@ -92,9 +92,7 @@ jobs:
make_latest: >-
${{ github.event.pull_request.base.ref == 'main' &&
needs.build.outputs.is_prerelease == 'false' }}
draft: >-
${{ github.event.pull_request.base.ref != 'main' ||
needs.build.outputs.is_prerelease == 'true' }}
draft: ${{ needs.build.outputs.is_prerelease == 'true' }}
prerelease: >-
${{ needs.build.outputs.is_prerelease == 'true' }}
generate_release_notes: true

View File

@@ -78,6 +78,11 @@ const config: StorybookConfig = {
find: '@/composables/queue/useJobActions',
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
},
{
find: '@/composables/billing/useBillingContext',
replacement:
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
},
{
find: '@/utils/formatUtil',
replacement:

View File

@@ -5,7 +5,6 @@ import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
@@ -42,7 +41,6 @@ setup((app) => {
}
}
})
app.use(ConfirmationService)
app.use(ToastService)
})

View File

@@ -1,236 +0,0 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { externalLinks } from '../src/config/routes'
import { drops } from '../src/data/drops'
import type { Locale } from '../src/i18n/translations'
import { t } from '../src/i18n/translations'
import { test } from './fixtures/blockExternalMedia'
const PATH_EN = '/drops'
const PATH_ZH = '/zh-CN/drops'
const CLOUD_URL = 'https://cloud.comfy.org'
const LOCALES: ReadonlyArray<readonly [string, Locale]> = [
[PATH_EN, 'en'],
[PATH_ZH, 'zh-CN']
]
function heroSection(page: Page, locale: Locale) {
return page.locator('section').filter({
has: page.getByRole('heading', {
level: 1,
name: t('drops.hero.title', locale)
})
})
}
function ctaSection(page: Page, locale: Locale) {
return page.locator('section').filter({
has: page.getByRole('heading', {
level: 2,
name: t('drops.cta.heading', locale)
})
})
}
function dropsSection(page: Page, locale: Locale) {
return page.locator('section').filter({
has: page.getByRole('heading', {
level: 2,
name: t('drops.section.title', locale)
})
})
}
test.describe('Drops landing — desktop @smoke', () => {
test('renders the configured title at /drops', async ({ page }) => {
await page.goto(PATH_EN)
await expect(page).toHaveTitle(t('drops.page.title', 'en'))
})
test('renders the localized title at /zh-CN/drops', async ({ page }) => {
await page.goto(PATH_ZH)
await expect(page).toHaveTitle(t('drops.page.title', 'zh-CN'))
})
test('is indexable at both locales', async ({ page }) => {
await page.goto(PATH_EN)
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
await page.goto(PATH_ZH)
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
})
test('hero h1 renders the localized title in both locales', async ({
page
}) => {
await page.goto(PATH_EN)
await expect(
page.getByRole('heading', {
level: 1,
name: t('drops.hero.title', 'en')
})
).toBeVisible()
await page.goto(PATH_ZH)
await expect(
page.getByRole('heading', {
level: 1,
name: t('drops.hero.title', 'zh-CN')
})
).toBeVisible()
})
test('hero primary CTA links to /download per locale', async ({ page }) => {
for (const [path, locale, expectedHref] of [
[PATH_EN, 'en', '/download'],
[PATH_ZH, 'zh-CN', '/zh-CN/download']
] as const) {
await page.goto(path)
const primary = heroSection(page, locale).getByRole('link', {
name: t('drops.hero.primary', locale)
})
await expect(primary).toBeVisible()
await expect(primary).toHaveAttribute('href', expectedHref)
}
})
test('hero secondary CTA opens external Cloud in a new tab on both locales', async ({
page
}) => {
for (const [path, locale] of LOCALES) {
await page.goto(path)
const secondary = heroSection(page, locale).getByRole('link', {
name: t('drops.hero.secondary', locale)
})
await expect(secondary).toBeVisible()
await expect(secondary).toHaveAttribute('href', CLOUD_URL)
await expect(secondary).toHaveAttribute('target', '_blank')
await expect(secondary).toHaveAttribute('rel', 'noopener noreferrer')
}
})
test('subscribe banner shows text and a sign-up link in both locales', async ({
page
}) => {
for (const [path, locale] of LOCALES) {
await page.goto(path)
await expect(page.getByText(t('drops.banner.text', locale))).toBeVisible()
const signUp = page.getByRole('link', {
name: t('drops.banner.cta', locale)
})
await expect(signUp).toBeVisible()
await expect(signUp).toHaveAttribute('href', externalLinks.youtube)
await expect(signUp).toHaveAttribute('target', '_blank')
await expect(signUp).toHaveAttribute('rel', 'noopener noreferrer')
}
})
test('closing CTA shows heading and both action buttons in both locales', async ({
page
}) => {
for (const [path, locale] of LOCALES) {
await page.goto(path)
const section = ctaSection(page, locale)
await expect(
section.getByRole('heading', {
level: 2,
name: t('drops.cta.heading', locale)
})
).toBeVisible()
const primary = section.getByRole('link', {
name: t('drops.cta.primary', locale)
})
await expect(primary).toBeVisible()
await expect(primary).toHaveAttribute('href', externalLinks.cloud)
await expect(primary).toHaveAttribute('target', '_blank')
await expect(primary).toHaveAttribute('rel', 'noopener noreferrer')
const secondary = section.getByRole('link', {
name: t('drops.cta.secondary', locale)
})
await expect(secondary).toBeVisible()
await expect(secondary).toHaveAttribute('href', externalLinks.workflows)
await expect(secondary).toHaveAttribute('target', '_blank')
await expect(secondary).toHaveAttribute('rel', 'noopener noreferrer')
}
})
test('drops section renders one card per data entry with the correct localized href in both locales', async ({
page
}) => {
for (const [path, locale] of LOCALES) {
await page.goto(path)
const section = dropsSection(page, locale)
await expect(
section.getByRole('heading', {
level: 2,
name: t('drops.section.title', locale)
})
).toBeVisible()
const cards = section.locator('[data-slot="card"]')
await expect(cards).toHaveCount(drops.length)
for (const [i, drop] of drops.entries()) {
const card = cards.nth(i)
await expect(card).toContainText(drop.title[locale])
const explore = card.getByRole('link', {
name: drop.cta.label[locale]
})
await expect(explore).toBeVisible()
await expect(explore).toHaveAttribute('href', drop.cta.href[locale])
}
}
})
test('desktop: first 4 drop cards are wider than cards 5+', async ({
page
}) => {
await page.goto(PATH_EN)
const cards = dropsSection(page, 'en').locator('[data-slot="card"]')
await expect(cards).toHaveCount(drops.length)
const firstWidth = (await cards.nth(0).boundingBox())?.width ?? 0
const fifthWidth = (await cards.nth(4).boundingBox())?.width ?? 0
expect(firstWidth).toBeGreaterThan(fifthWidth)
})
})
test.describe('Drops landing — mobile @mobile', () => {
test('drops grid stacks in a single column at mobile width', async ({
page
}) => {
await page.goto(PATH_EN)
const cards = dropsSection(page, 'en').locator('[data-slot="card"]')
await expect(cards).toHaveCount(drops.length)
const viewportWidth = page.viewportSize()!.width
const firstBox = await cards.nth(0).boundingBox()
const secondBox = await cards.nth(1).boundingBox()
expect(firstBox, 'first card bounding box').not.toBeNull()
expect(secondBox, 'second card bounding box').not.toBeNull()
expect(firstBox!.width).toBeGreaterThanOrEqual(viewportWidth * 0.7)
expect(secondBox!.y).toBeGreaterThanOrEqual(firstBox!.y + firstBox!.height)
})
test('closing CTA heading stays within viewport width', async ({ page }) => {
await page.goto(PATH_EN)
const heading = page.getByRole('heading', {
level: 2,
name: t('drops.cta.heading', 'en')
})
await heading.scrollIntoViewIfNeeded()
await expect(heading).toBeVisible()
const box = await heading.boundingBox()
expect(box, 'CTA heading bounding box').not.toBeNull()
expect(box!.x + box!.width).toBeLessThanOrEqual(
page.viewportSize()!.width + 1
)
})
})

View File

@@ -1,14 +1,10 @@
<script setup lang="ts">
import type { AnchorHTMLAttributes } from 'vue'
import Button from '../ui/button/Button.vue'
import { resolveRel } from '../../utils/cta'
import BrandButton from '../common/BrandButton.vue'
type Cta = {
label: string
href: string
target?: AnchorHTMLAttributes['target']
rel?: AnchorHTMLAttributes['rel']
target?: '_blank' | '_self' | '_parent' | '_top'
}
type TermsLink = {
@@ -16,11 +12,10 @@ type TermsLink = {
href: string
}
const { heading, primaryCta, secondaryCta, termsLink } = defineProps<{
defineProps<{
heading: string
primaryCta: Cta
secondaryCta?: Cta
termsLink?: TermsLink
termsLink: TermsLink
}>()
</script>
@@ -29,37 +24,23 @@ const { heading, primaryCta, secondaryCta, termsLink } = defineProps<{
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
>
<h2
class="max-w-3xl text-4xl/snug font-light tracking-tight text-pretty text-primary-comfy-canvas lg:text-6xl/snug"
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{ heading }}
</h2>
<div class="mt-10 flex flex-col gap-4 sm:flex-row lg:mt-12">
<Button
as="a"
:href="primaryCta.href"
:target="primaryCta.target"
:rel="resolveRel(primaryCta)"
variant="default"
size="lg"
>
{{ primaryCta.label }}
</Button>
<Button
v-if="secondaryCta"
as="a"
:href="secondaryCta.href"
:target="secondaryCta.target"
:rel="resolveRel(secondaryCta)"
variant="outline"
size="lg"
>
{{ secondaryCta.label }}
</Button>
</div>
<BrandButton
:href="primaryCta.href"
:target="primaryCta.target"
:rel="primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined"
variant="outline"
size="lg"
class="mt-10 px-20 py-4 text-base uppercase lg:mt-12"
>
{{ primaryCta.label }}
</BrandButton>
<a
v-if="termsLink"
:href="termsLink.href"
class="mt-8 text-sm text-primary-comfy-canvas/70 underline underline-offset-4 transition-colors hover:text-primary-comfy-canvas"
>

View File

@@ -30,9 +30,9 @@ function toggle(index: number) {
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-80 md:py-0"
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 md:top-28 md:w-80 md:py-0"
>
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
<h2 class="text-4xl font-light text-primary-comfy-canvas md:text-5xl">
{{ heading }}
</h2>
</div>
@@ -42,7 +42,7 @@ function toggle(index: number) {
<div
v-for="(faq, index) in faqs"
:key="faq.id"
class="border-primary-comfy-canvas/20 border-b"
class="border-b border-primary-comfy-canvas/20"
>
<button
:id="`faq-trigger-${faq.id}`"
@@ -83,7 +83,7 @@ function toggle(index: number) {
:aria-labelledby="`faq-trigger-${faq.id}`"
class="pb-6"
>
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
{{ faq.answer }}
</p>
</section>

View File

@@ -1,164 +0,0 @@
<script setup lang="ts">
import type { AnchorHTMLAttributes } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useNow } from '@vueuse/core'
import Button from '../ui/button/Button.vue'
import { resolveRel } from '../../utils/cta'
type Cta = {
label: string
href: string
target?: AnchorHTMLAttributes['target']
rel?: AnchorHTMLAttributes['rel']
}
type Visual =
| {
type: 'image'
src: string
alt: string
width?: number
height?: number
}
| {
type: 'video'
src: string
alt: string
poster?: string
width?: number
height?: number
}
const {
visual,
eyebrow,
title,
subtitle,
primaryCta,
secondaryCta,
youtubeVideoId,
startDateTime,
endDateTime
} = defineProps<{
visual?: Visual
eyebrow?: string
title: string
subtitle?: string
primaryCta: Cta
secondaryCta?: Cta
youtubeVideoId: string
startDateTime: string
endDateTime: string
}>()
const embedUrl = computed(
() =>
`https://www.youtube-nocookie.com/embed/${youtubeVideoId}?autoplay=1&mute=1&rel=0`
)
// Keep SSR/initial paint deterministic on the logo and only flip to the embed
// after client hydration — avoids a build-time `now` leaking into the markup.
const mounted = ref(false)
onMounted(() => {
mounted.value = true
})
const now = useNow({ interval: 30_000 })
const startMs = computed(() => new Date(startDateTime).getTime())
const endMs = computed(() => new Date(endDateTime).getTime())
const isLive = computed(
() =>
mounted.value &&
now.value.getTime() >= startMs.value &&
now.value.getTime() < endMs.value
)
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
>
<div
v-if="isLive"
class="mb-10 aspect-video w-full overflow-hidden rounded-2xl lg:mb-12"
>
<iframe
:src="embedUrl"
:title="title"
class="size-full"
loading="lazy"
allow="autoplay; encrypted-media; picture-in-picture"
allowfullscreen
/>
</div>
<img
v-else-if="visual?.type === 'image'"
:src="visual.src"
:alt="visual.alt"
:width="visual.width"
:height="visual.height"
fetchpriority="high"
decoding="async"
class="mb-10 h-auto w-full max-w-md lg:mb-12 lg:max-w-lg"
/>
<video
v-else-if="visual?.type === 'video'"
:src="visual.src"
:poster="visual.poster"
:aria-label="visual.alt"
:width="visual.width"
:height="visual.height"
autoplay
loop
muted
playsinline
preload="metadata"
class="mb-10 h-auto w-full max-w-md lg:mb-12 lg:max-w-2xl"
/>
<p
v-if="eyebrow"
class="mb-4 text-sm font-medium tracking-wide text-primary-comfy-canvas/70 uppercase"
>
{{ eyebrow }}
</p>
<h1
class="max-w-3xl text-4xl/snug font-light tracking-tight text-pretty text-primary-comfy-canvas lg:text-6xl/snug"
>
{{ title }}
</h1>
<p
v-if="subtitle"
class="mt-6 max-w-2xl text-base text-primary-comfy-canvas/70 lg:text-lg"
>
{{ subtitle }}
</p>
<div class="mt-10 flex flex-col gap-4 sm:flex-row lg:mt-12">
<Button
as="a"
:href="primaryCta.href"
:target="primaryCta.target"
:rel="resolveRel(primaryCta)"
size="lg"
>
{{ primaryCta.label }}
</Button>
<Button
v-if="secondaryCta"
as="a"
:href="secondaryCta.href"
:target="secondaryCta.target"
:rel="resolveRel(secondaryCta)"
variant="outline"
size="lg"
>
{{ secondaryCta.label }}
</Button>
</div>
</section>
</template>

View File

@@ -25,7 +25,7 @@ const {
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
<div class="flex flex-col items-center text-center">
<h2
class="text-primary-comfy-canvas max-w-5xl text-3xl font-light tracking-tight lg:text-5xl"
class="max-w-5xl text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
{{ t(headingKey, locale) }}
</h2>

View File

@@ -1,76 +0,0 @@
<script setup lang="ts">
import type { Drop } from '../../data/drops'
import type { Locale } from '../../i18n/translations'
import Badge from '../ui/badge/Badge.vue'
import ButtonPill from '../ui/button-pill/ButtonPill.vue'
import Card from '../ui/card/Card.vue'
import CardContent from '../ui/card/CardContent.vue'
import CardDescription from '../ui/card/CardDescription.vue'
import CardFooter from '../ui/card/CardFooter.vue'
import CardHeader from '../ui/card/CardHeader.vue'
import CardTitle from '../ui/card/CardTitle.vue'
const { drop, locale } = defineProps<{
drop: Drop
locale: Locale
}>()
</script>
<template>
<Card class="group/pill-trigger relative h-full overflow-hidden">
<a
:href="drop.cta.href[locale]"
:aria-label="`${drop.title[locale]} ${drop.cta.label[locale]}`"
class="rounded-4.5xl focus-visible:ring-primary-comfy-yellow absolute inset-0 z-10 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
/>
<div class="flex flex-col-reverse">
<CardHeader class="gap-2 px-6">
<Badge variant="ghost">
{{ drop.category[locale] }}
</Badge>
<CardTitle class="pt-4">
{{ drop.title[locale] }}
</CardTitle>
<CardDescription>
{{ drop.description[locale] }}
</CardDescription>
</CardHeader>
<CardContent class="relative p-2">
<div class="aspect-video w-full overflow-hidden rounded-4xl">
<img
v-if="drop.media.type === 'image'"
:src="drop.media.src"
:alt="drop.media.alt[locale]"
loading="lazy"
decoding="async"
class="size-full object-cover object-center transition-transform duration-500 ease-out group-hover/pill-trigger:scale-105"
/>
<video
v-else
:src="drop.media.src"
:poster="drop.media.poster"
:aria-label="drop.media.alt[locale]"
autoplay
loop
muted
playsinline
preload="metadata"
class="size-full object-cover object-center transition-transform duration-500 ease-out group-hover/pill-trigger:scale-105"
/>
</div>
<Badge v-if="drop.badge" variant="accent" class="absolute top-6 left-8">
{{ drop.badge[locale] }}
</Badge>
</CardContent>
</div>
<CardFooter class="mt-auto px-6 pb-6">
<ButtonPill as="span" variant="ghost" icon-position="left">
{{ drop.cta.label[locale] }}
</ButtonPill>
</CardFooter>
</Card>
</template>

View File

@@ -40,12 +40,12 @@ const {
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
<div class="flex flex-col gap-8">
<h2
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{ t(headingKey, locale) }}
</h2>
<p
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
class="max-w-sm text-sm/relaxed text-primary-comfy-canvas lg:text-base"
>
{{ t(descriptionKey, locale) }}
</p>
@@ -66,10 +66,10 @@ const {
v-for="(event, i) in events"
:key="i"
:href="event.href"
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
class="group flex items-center gap-4 border-b border-primary-comfy-canvas/15 py-6 lg:gap-8"
>
<span
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
class="shrink-0 text-sm font-medium text-primary-comfy-canvas"
>
{{ event.label[locale] }}
</span>

View File

@@ -109,7 +109,7 @@ const contactColumn: { title: string; links: FooterLink[] } = {
<template>
<footer
ref="footerRef"
class="bg-primary-comfy-ink text-primary-comfy-canvas px-6 py-8 lg:px-20"
class="bg-primary-comfy-ink px-6 py-8 text-primary-comfy-canvas lg:px-20"
>
<div
class="border-primary-warm-gray grid gap-12 border-t pt-16 lg:grid-cols-2 lg:gap-4"

View File

@@ -53,7 +53,7 @@ defineEmits<{ click: [] }>()
<div class="flex w-full items-end justify-between p-4">
<div class="gap-2">
<p class="text-sm font-bold text-white">{{ item.title }}</p>
<p class="text-primary-comfy-canvas text-xs">
<p class="text-xs text-primary-comfy-canvas">
<GalleryItemAttribution :item :locale />
</p>
</div>
@@ -82,7 +82,7 @@ defineEmits<{ click: [] }>()
<!-- Mobile metadata -->
<div v-if="mobile" class="mt-2 gap-2">
<p class="text-sm font-bold text-white">{{ item.title }}</p>
<p class="text-primary-comfy-canvas text-xs">
<p class="text-xs text-primary-comfy-canvas">
<GalleryItemAttribution :item :locale />
</p>
</div>

View File

@@ -11,7 +11,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-24 pb-12 text-center"
>
<h1
class="text-primary-comfy-canvas max-w-4xl text-3xl leading-[110%] font-light tracking-tight lg:text-5xl"
class="max-w-4xl text-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
{{ t('learning.heroTitle.before', locale) }}
<span class="text-primary-comfy-yellow">ComfyUI</span

View File

@@ -25,7 +25,7 @@ const {
data-slot="badge"
:data-variant="variant"
:data-size="size"
:class="cn(badgeVariants({ size, variant }), className)"
:class="cn(badgeVariants({ variant, size }), className)"
>
<slot name="prepend">
<component :is="prependIcon" v-if="prependIcon" />

View File

@@ -4,16 +4,15 @@ import { cva } from 'cva'
export const badgeVariants = cva({
base: 'text-primary-warm-gray font-formula leading-none focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
variants: {
size: {
md: 'px-4 py-1 text-xs',
xs: 'px-2 py-0.5 text-[9px]'
},
variant: {
default: 'bg-transparency-ink-t80',
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
ghost: 'text-primary-comfy-yellow px-0 font-semibold uppercase',
accent:
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
},
size: {
md: 'px-4 py-1 text-xs',
xs: 'px-2 py-0.5 text-[9px]'
}
},
defaultVariants: {

View File

@@ -8,8 +8,7 @@ export const buttonVariants = cva(
{
variants: {
size: {
sm: 'h-8 px-4 py-2 text-xs md:text-sm',
default: 'h-10 px-6 py-2.5 text-xs md:text-sm',
default: 'h-10 px-6 py-2.5',
lg: 'h-14 px-8 py-4 text-base'
},
variant: {
@@ -17,7 +16,7 @@ export const buttonVariants = cva(
'bg-primary-comfy-yellow hover:bg-primary-comfy-yellow/90 text-primary-comfy-ink uppercase',
outline:
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border uppercase hover:text-primary-comfy-ink',
link: "text-primary-comfy-yellow relative h-auto justify-start px-0 py-1 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 hover:opacity-90 hover:after:scale-x-100 [&_svg:not([class*='size-'])]:size-6",
link: "text-primary-comfy-yellow h-auto justify-start px-0 py-1 text-base uppercase hover:opacity-90 [&_svg:not([class*='size-'])]:size-6",
nav: 'text-primary-warm-white hover:text-primary-comfy-yellow h-auto justify-between px-0 py-1 text-start text-2xl font-medium',
navMuted:
'hover:text-primary-comfy-yellow h-auto w-full justify-between px-0 py-1 text-start text-2xl font-medium text-primary-comfy-canvas uppercase'

View File

@@ -1,22 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-transparency-white-t4 text-primary-warm-white rounded-4.5xl flex flex-col gap-6 shadow-sm',
className
)
"
>
<slot />
</div>
</template>

View File

@@ -1,14 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div data-slot="card-content" :class="cn(className)">
<slot />
</div>
</template>

View File

@@ -1,19 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-description"
:class="
cn('line-clamp-3 text-base text-primary-comfy-canvas/70', className)
"
>
<slot />
</div>
</template>

View File

@@ -1,14 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div data-slot="card-footer" :class="cn('flex items-center', className)">
<slot />
</div>
</template>

View File

@@ -1,14 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div data-slot="card-header" :class="cn('flex flex-col gap-1.5', className)">
<slot />
</div>
</template>

View File

@@ -1,22 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-title"
:class="
cn(
'text-xl leading-none font-medium text-primary-comfy-canvas md:text-2xl',
className
)
"
>
<slot />
</div>
</template>

View File

@@ -8,7 +8,6 @@ const baseRoutes = {
cloudEnterprise: '/cloud/enterprise',
api: '/api',
gallery: '/gallery',
drops: '/drops',
about: '/about',
careers: '/careers',
customers: '/customers',

View File

@@ -1,249 +0,0 @@
// Image URLs are placeholders at media.comfy.org/website/drops/<id>.png —
// asset uploads and native zh-CN review are pending follow-ups (see
// apps/website/.scratch/drops-page/PRD.md).
import { externalLinks } from '../config/routes'
import type { LocalizedText } from '../i18n/translations'
type DropMedia =
| { type: 'image'; src: string; alt: LocalizedText }
| { type: 'video'; src: string; alt: LocalizedText; poster?: string }
export type Drop = {
id: string
badge?: LocalizedText
category: LocalizedText
media: DropMedia
title: LocalizedText
description: LocalizedText
cta: { label: LocalizedText; href: LocalizedText }
}
const EXPLORE: LocalizedText = { en: 'EXPLORE', 'zh-CN': '探索' }
const PLATFORM: LocalizedText = { en: 'Platform', 'zh-CN': '平台' }
const CLOUD: LocalizedText = { en: 'Cloud', 'zh-CN': '云端' }
const COMMUNITY: LocalizedText = { en: 'Community', 'zh-CN': '社区' }
const DEVELOPER: LocalizedText = { en: 'Developer', 'zh-CN': '开发者' }
const MODELS_AND_NODES: LocalizedText = {
en: 'Models & Nodes',
'zh-CN': '模型与节点'
}
const NEW_BADGE: LocalizedText = { en: 'NEW', 'zh-CN': '新' }
const FEATURED_BADGE: LocalizedText = { en: 'FEATURED', 'zh-CN': '精选' }
function imageFor(fileName: string, alt: LocalizedText): DropMedia {
return {
type: 'image',
src: `https://media.comfy.org/website/drops/${fileName}`,
alt
}
}
function videoFor(
fileName: string,
alt: LocalizedText,
poster?: string
): DropMedia {
return {
type: 'video',
src: `https://media.comfy.org/website/drops/${fileName}`,
alt,
...(poster && {
poster: `https://media.comfy.org/website/drops/${poster}`
})
}
}
export const drops: readonly Drop[] = [
{
id: 'desktop-client',
badge: NEW_BADGE,
category: PLATFORM,
media: imageFor('Drops_2x2card_Desktop.jpg', {
en: 'New Desktop Client',
'zh-CN': '新桌面客户端'
}),
title: { en: 'New Desktop Client', 'zh-CN': '新桌面客户端' },
description: {
en: 'A faster, redesigned desktop app for ComfyUI — one-click install and managed updates.',
'zh-CN': '更快、重新设计的 ComfyUI 桌面应用程序 — 一键安装与受管更新。'
},
cta: {
label: EXPLORE,
href: { en: '/download', 'zh-CN': '/zh-CN/download' }
}
},
{
id: 'app-mode',
badge: NEW_BADGE,
category: PLATFORM,
media: videoFor('Drops_2x2card_APP.mp4', {
en: 'App Mode',
'zh-CN': 'App 模式'
}),
title: { en: 'App Mode', 'zh-CN': 'App 模式' },
description: {
en: 'A simplified view of your workflows. Flip back to the node graph anytime to go deeper.',
'zh-CN': '工作流的简化视图。随时切换回节点图视图以深入了解。'
},
// TODO: no destination page yet — link out when App Mode lands.
cta: {
label: EXPLORE,
href: {
en: 'https://docs.comfy.org/interface/app-mode',
'zh-CN': 'https://docs.comfy.org/zh/interface/app-mode'
}
}
},
{
id: 'comfy-api',
badge: NEW_BADGE,
category: DEVELOPER,
media: imageFor('Drops_2x2card_API.jpg', {
en: 'Comfy API',
'zh-CN': 'Comfy API'
}),
title: { en: 'Comfy API', 'zh-CN': 'Comfy API' },
description: {
en: 'Turn any workflow into a production endpoint. Automate generation and scale to thousands of outputs.',
'zh-CN': '将任意工作流变成生产端点。自动化生成并扩展到数千个输出。'
},
cta: {
label: EXPLORE,
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' },
// TODO: production MCP copy + destination page pending.
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,
media: imageFor('Drops_3x3card_Comm Workflows.jpg', {
en: 'Community Workflows',
'zh-CN': '社区工作流'
}),
title: {
en: 'Community Workflows',
'zh-CN': '社区工作流'
},
description: {
en: 'Browse and remix thousands of community-shared workflows. Start from a proven template.',
'zh-CN': '浏览和混搭数千个社区共享的工作流。从经过验证的模板开始。'
},
cta: {
label: EXPLORE,
href: { en: externalLinks.workflows, 'zh-CN': externalLinks.workflows }
}
},
{
id: 'supported-models',
category: MODELS_AND_NODES,
media: imageFor('Drops_Supported models.jpg', {
en: 'Supported Models',
'zh-CN': '支持的模型'
}),
title: { en: 'Supported Models', 'zh-CN': '支持的模型' },
description: {
en: 'Run the latest open and partner models — every checkpoint, LoRA, and ControlNet, ready to use in your graph.',
'zh-CN':
'运行最新的开源和合作伙伴模型 — 每个 checkpoint、LoRA 和 ControlNet 都可直接在工作流中使用。'
},
cta: {
label: EXPLORE,
href: { en: '/p/supported-models', 'zh-CN': '/zh-CN/p/supported-models' }
}
},
{
id: 'supported-nodes',
category: MODELS_AND_NODES,
media: videoFor('Drops_3x3card_supported nodes.mp4', {
en: 'Supported Nodes',
'zh-CN': '支持的节点'
}),
title: { en: 'Supported Nodes', 'zh-CN': '支持的节点' },
description: {
en: 'Thousands of community and partner nodes, curated and verified to run on Comfy Cloud.',
'zh-CN':
'数千个社区与合作伙伴节点,经过精选与验证,可在 Comfy Cloud 上运行。'
},
cta: {
label: EXPLORE,
href: {
en: '/cloud/supported-nodes',
'zh-CN': '/zh-CN/cloud/supported-nodes'
}
}
},
{
id: 'comfy-enterprise',
category: CLOUD,
media: imageFor('Drops_3x3card_enterprise.png', {
en: 'Comfy Enterprise',
'zh-CN': 'Comfy 企业版'
}),
title: { en: 'Comfy Enterprise', 'zh-CN': 'Comfy 企业版' },
description: {
en: 'Enterprise-grade infrastructure for the creative engine inside your organization.',
'zh-CN': '为您组织内创意引擎提供的企业级基础设施。'
},
cta: {
label: EXPLORE,
href: { en: '/cloud/enterprise', 'zh-CN': '/zh-CN/cloud/enterprise' }
}
},
{
id: 'learning-hub',
category: COMMUNITY,
media: imageFor('Drops_3x3_Learninghub.jpg', {
en: 'Learning Hub',
'zh-CN': '学习中心'
}),
title: { en: 'Learning Hub', 'zh-CN': '学习中心' },
description: {
en: 'Walkthroughs and ready-to-run workflows to take you from first render to production pipeline.',
'zh-CN': '配套教程与开箱即用的工作流,带您从第一次渲染走向生产管线。'
},
cta: {
label: { en: 'START LEARNING', 'zh-CN': '开始学习' },
href: { en: '/learning', 'zh-CN': '/zh-CN/learning' }
}
},
{
id: 'share-comfy',
badge: NEW_BADGE,
category: COMMUNITY,
media: videoFor('Drops_3x3card_Affilliate.mp4', {
en: 'Comfy Affiliate',
'zh-CN': 'Comfy Affiliate'
}),
title: {
en: 'Comfy Affiliate',
'zh-CN': 'Comfy Affiliate'
},
description: {
en: 'Share Comfy with your audience and earn for every creator you bring on board.',
'zh-CN': '与您的受众分享 Comfy为您带来的每一位创作者获得回报。'
},
// /affiliates is locale-invariant: same URL in both locales.
cta: {
label: { en: 'LEARN MORE', 'zh-CN': '了解更多' },
href: { en: '/affiliates', 'zh-CN': '/affiliates' }
}
}
]

View File

@@ -4928,70 +4928,6 @@ const translations = {
'affiliate.cta.termsLabel': {
en: 'Read the affiliate program terms',
'zh-CN': '阅读联盟计划条款'
},
// Drops page (/drops) — head metadata
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'drops.page.title': {
en: 'Drops — Everything new in ComfyUI',
'zh-CN': 'Drops — ComfyUI 最新动态'
},
'drops.page.description': {
en: 'Explore everything new in Comfy — releases, features, models, and resources across platform, cloud, community, and developer tools.',
'zh-CN':
'探索 Comfy 的最新动态 — 涵盖平台、云端、社区和开发者工具的发布、功能、模型和资源。'
},
// Drops page (/drops) — hero section
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'drops.hero.title': {
en: 'Everything new in ComfyUI',
'zh-CN': 'ComfyUI 全新内容'
},
'drops.hero.primary': {
en: 'Download Desktop',
'zh-CN': '下载桌面版'
},
'drops.hero.secondary': {
en: 'Launch Cloud',
'zh-CN': '启动云端'
},
'drops.hero.visualAlt': {
en: 'Comfy',
'zh-CN': 'Comfy'
},
// Drops page (/drops) — subscribe banner
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'drops.banner.text': {
en: 'Join the live stream. Get answers in real time.',
'zh-CN': '加入直播,实时获得解答。'
},
'drops.banner.cta': {
en: 'Sign up now',
'zh-CN': '立即注册'
},
// Drops page (/drops) — closing CTA
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'drops.cta.heading': {
en: 'Everything Comfy ships. All in one place.',
'zh-CN': 'Comfy 的全部内容,一处尽享。'
},
'drops.cta.primary': {
en: 'Open Comfy Cloud',
'zh-CN': '打开 Comfy Cloud'
},
'drops.cta.secondary': {
en: 'Try Workflow',
'zh-CN': '试用工作流'
},
// Drops page (/drops) — drops grid
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'drops.section.title': {
en: 'Latest Drops',
'zh-CN': '最新发布'
}
} as const satisfies Record<string, Record<Locale, string>>

View File

@@ -1,20 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import CtaSection from '../templates/drops/CtaSection.vue'
import DropsSection from '../templates/drops/DropsSection.vue'
import HeroSection from '../templates/drops/HeroSection.vue'
import SubscribeBanner from '../templates/drops/SubscribeBanner.vue'
import { t } from '../i18n/translations'
const locale = 'en' as const
---
<BaseLayout
title={t('drops.page.title', locale)}
description={t('drops.page.description', locale)}
>
<SubscribeBanner locale={locale} />
<HeroSection locale={locale} client:load />
<DropsSection locale={locale} />
<CtaSection locale={locale} />
</BaseLayout>

View File

@@ -1,20 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import CtaSection from '../../templates/drops/CtaSection.vue'
import DropsSection from '../../templates/drops/DropsSection.vue'
import HeroSection from '../../templates/drops/HeroSection.vue'
import SubscribeBanner from '../../templates/drops/SubscribeBanner.vue'
import { t } from '../../i18n/translations'
const locale = 'zh-CN' as const
---
<BaseLayout
title={t('drops.page.title', locale)}
description={t('drops.page.description', locale)}
>
<SubscribeBanner locale={locale} />
<HeroSection locale={locale} client:load />
<DropsSection locale={locale} />
<CtaSection locale={locale} />
</BaseLayout>

View File

@@ -1,25 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import CtaCenter01 from '../../components/blocks/CtaCenter01.vue'
import { externalLinks } from '../../config/routes'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<CtaCenter01
:heading="t('drops.cta.heading', locale)"
:primary-cta="{
label: t('drops.cta.primary', locale),
href: externalLinks.cloud,
target: '_blank'
}"
:secondary-cta="{
label: t('drops.cta.secondary', locale),
href: externalLinks.workflows,
target: '_blank'
}"
/>
</template>

View File

@@ -1,29 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import DropCard from '../../components/common/DropCard.vue'
import { drops } from '../../data/drops'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<h2
class="text-primary-warm-white text-3xl font-light tracking-tight lg:text-5xl"
>
{{ t('drops.section.title', locale) }}
</h2>
<div class="mt-10 grid grid-cols-1 gap-6 md:grid-cols-6 lg:mt-12">
<div
v-for="(drop, index) in drops"
:key="drop.id"
:class="index < 4 ? 'md:col-span-3' : 'md:col-span-2'"
>
<DropCard :drop :locale />
</div>
</div>
</section>
</template>

View File

@@ -1,41 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import HeroLivestream01 from '../../components/blocks/HeroLivestream01.vue'
import { externalLinks, getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const routes = getRoutes(locale)
// TODO(drops-livestream): replace with the production stream ID + window.
const youtubeVideoId = 'nlLZfNIqF8M'
const startDateTime = '2026-06-29T15:00:00Z'
const endDateTime = '2026-06-29T17:15:00Z'
</script>
<template>
<HeroLivestream01
:visual="{
type: 'video',
src: 'https://media.comfy.org/website/drops/Drops_hero_rotatinglogo.webm',
alt: t('drops.hero.visualAlt', locale),
width: 1760,
height: 528
}"
:title="t('drops.hero.title', locale)"
:primary-cta="{
label: t('drops.hero.primary', locale),
href: routes.download
}"
:secondary-cta="{
label: t('drops.hero.secondary', locale),
href: externalLinks.cloud,
target: '_blank'
}"
:youtube-video-id="youtubeVideoId"
:start-date-time="startDateTime"
:end-date-time="endDateTime"
/>
</template>

View File

@@ -1,30 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Button from '@/components/ui/button/Button.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const signUpHref = 'https://luma.com/l7c5z4gp'
</script>
<template>
<div class="px-4">
<div
class="bg-primary-comfy-plum max-w-8xl rounded-5xl text-primary-warm-white mx-auto flex w-full flex-col items-center justify-center gap-2 px-6 py-5 text-center text-sm sm:flex-row sm:gap-4"
>
<p class="ppformula-text-center">{{ t('drops.banner.text', locale) }}</p>
<Button
:href="signUpHref"
as="a"
variant="link"
size="sm"
target="_blank"
rel="noopener noreferrer"
>
{{ t('drops.banner.cta', locale) }}
</Button>
</div>
</div>
</template>

View File

@@ -1,10 +0,0 @@
import type { AnchorHTMLAttributes } from 'vue'
export function resolveRel(cta: {
rel?: AnchorHTMLAttributes['rel']
target?: AnchorHTMLAttributes['target']
}): AnchorHTMLAttributes['rel'] {
return (
cta.rel ?? (cta.target === '_blank' ? 'noopener noreferrer' : undefined)
)
}

View File

@@ -8,7 +8,7 @@ export class BaseDialog {
public readonly page: Page,
testId?: string
) {
this.root = testId ? page.getByTestId(testId) : page.locator('.p-dialog')
this.root = testId ? page.getByTestId(testId) : page.getByRole('dialog')
this.closeButton = this.root.getByRole('button', { name: 'Close' })
}

View File

@@ -352,20 +352,11 @@ export class AssetsSidebarTab extends SidebarTab {
this.listViewItems = page.locator(
'.sidebar-content-container [role="button"][tabindex="0"]'
)
this.selectionFooter = page
.locator('.sidebar-content-container')
.locator('..')
.locator('[class*="h-18"]')
this.selectionCountButton = page.getByText(/Assets Selected: \d+/)
this.deselectAllButton = page.getByText('Deselect all')
this.deleteSelectedButton = page
.getByTestId('assets-delete-selected')
.or(page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
.first()
this.downloadSelectedButton = page
.getByTestId('assets-download-selected')
.or(page.locator('button:has(.icon-\\[lucide--download\\])').last())
.first()
this.selectionFooter = page.getByTestId('assets-selection-bar')
this.selectionCountButton = page.getByText(/\d+ selected/)
this.deselectAllButton = page.getByTestId('assets-deselect-selected')
this.deleteSelectedButton = page.getByTestId('assets-delete-selected')
this.downloadSelectedButton = page.getByTestId('assets-download-selected')
this.backToAssetsButton = page.getByText('Back to all assets')
this.skeletonLoaders = page.locator(
'.sidebar-content-container .animate-pulse'

View File

@@ -36,9 +36,11 @@ export class BuilderSaveAsHelper {
this.closeButton = this.successDialog
.getByRole('button', { name: 'Close', exact: true })
.filter({ hasText: 'Close' })
this.dismissButton = this.successDialog.locator(
'button.p-dialog-close-button'
)
// The icon-only X carries an aria-label, while the footer Close button
// is named by its text — getByLabel only matches the former.
this.dismissButton = this.successDialog.getByLabel('Close', {
exact: true
})
this.exitBuilderButton = this.successDialog.getByRole('button', {
name: 'Exit builder'
})

View File

@@ -231,6 +231,22 @@ export class ExecutionHelper {
)
}
/** Send `execution_interrupted` WS event (user-initiated stop). */
executionInterrupted(jobId: string, nodeId: string): void {
this.requireWs().send(
JSON.stringify({
type: 'execution_interrupted',
data: {
prompt_id: jobId,
timestamp: Date.now(),
node_id: nodeId,
node_type: 'Unknown',
executed: []
}
})
)
}
/** Send `progress` WS event. */
progress(jobId: string, nodeId: string, value: number, max: number): void {
this.requireWs().send(

View File

@@ -69,6 +69,24 @@ export class TemplateHelper {
}
async mockIndex(): Promise<void> {
const customTemplatesHandler = async (route: Route) => {
const customTemplates: Record<string, string[]> = {}
await route.fulfill({
status: 200,
body: JSON.stringify(customTemplates),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
}
const customTemplatesPattern = '**/api/workflow_templates'
this.routeHandlers.push({
pattern: customTemplatesPattern,
handler: customTemplatesHandler
})
await this.page.route(customTemplatesPattern, customTemplatesHandler)
const indexHandler = async (route: Route) => {
const payload = this.index ?? mockTemplateIndex(this.templates)
await route.fulfill({

View File

@@ -2,7 +2,7 @@ import { readFileSync } from 'fs'
import { test } from '@playwright/test'
import type { AppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/utils/appMode'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON

View File

@@ -38,7 +38,6 @@ export const TestIds = {
settings: 'settings-dialog',
settingsContainer: 'settings-container',
settingsTabAbout: 'settings-tab-about',
confirm: 'confirm-dialog',
errorOverlay: 'error-overlay',
errorOverlaySeeErrors: 'error-overlay-see-errors',
errorOverlayDismiss: 'error-overlay-dismiss',

View File

@@ -0,0 +1,61 @@
import { expect } from '@playwright/test'
import type { Asset } from '@comfyorg/ingest-types'
import { createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture'
import { STABLE_CHECKPOINT } from '@e2e/fixtures/data/assetFixtures'
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT]
const test = createCloudAssetsFixture(CLOUD_ASSETS)
test.describe('Browse Model Assets - Use button', { tag: '@cloud' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
await comfyPage.nodeOps.clearGraph()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
})
test('Use button ghost-places a loader populated with the model', async ({
comfyPage
}) => {
await comfyPage.command.executeCommand('Comfy.BrowseModelAssets')
const modal = comfyPage.page.locator(
'[data-component-id="AssetBrowserModal"]'
)
await expect(modal).toBeVisible()
const card = comfyPage.page.locator(
`[data-component-id="AssetCard"][data-asset-id="${STABLE_CHECKPOINT.id}"]`
)
await expect(card).toBeVisible()
await card.getByRole('button', { name: 'Use' }).click()
// Dialog closes and the ghost is armed; the node is not placed until the
// user clicks the canvas.
await expect(modal).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
.toBe(0)
const canvasBox = (await comfyPage.canvas.boundingBox())!
await comfyPage.canvas.click({
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
})
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(1)
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
'CheckpointLoaderSimple'
)
expect(loader).toBeDefined()
const widget = await loader.getWidgetByName('ckpt_name')
expect(await widget.getValue()).toBe(STABLE_CHECKPOINT.name)
})
})

View File

@@ -1,6 +1,9 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
const SHARE_AUTH_STORAGE_KEY = 'Comfy.PreservedQuery.share_auth'
/**
* Cloud distribution E2E tests.
*
@@ -14,15 +17,31 @@ test.describe('Cloud distribution UI', { tag: '@cloud' }, () => {
test('cloud build redirects unauthenticated users to login', async ({
page
}) => {
await page.goto('http://localhost:8188')
await page.goto(APP_URL)
// Cloud build has an auth guard that redirects to /cloud/login.
// This route only exists in the cloud distribution — it's tree-shaken
// in the OSS build. Its presence confirms the cloud build is active.
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
})
test('preserves share auth attribution before redirecting logged-out users', async ({
page
}) => {
await page.goto(new URL('/?share=abc', APP_URL).toString())
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
await expect
.poll(() =>
page.evaluate(
(key) => sessionStorage.getItem(key),
SHARE_AUTH_STORAGE_KEY
)
)
.toBe(JSON.stringify({ share: 'abc' }))
})
test('cloud login page renders sign-in options', async ({ page }) => {
await page.goto('http://localhost:8188')
await page.goto(APP_URL)
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
// Verify cloud-specific login UI is rendered
await expect(page.getByRole('button', { name: /google/i })).toBeVisible()

View File

@@ -0,0 +1,138 @@
import { expect } from '@playwright/test'
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
import type { operations } from '@/types/comfyRegistryTypes'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
type CustomerBalanceResponse = NonNullable<
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
>
const PERSONAL_WORKSPACE_NAME = 'Personal Workspace'
const FUTURE_DATE = '2099-01-01T00:00:00Z'
const mockRemoteConfig: RemoteConfig = { team_workspaces_enabled: true }
const mockListWorkspacesResponse: { workspaces: WorkspaceWithRole[] } = {
workspaces: [
{
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
role: 'owner'
}
]
}
const mockTokenResponse: WorkspaceTokenResponse = {
token: 'mock-workspace-token',
expires_at: FUTURE_DATE,
workspace: {
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal'
},
role: 'owner',
permissions: []
}
// Cancelled but still active: `end_date` set (cancelled) while `is_active` is
// true. A personal owner in this state sees BOTH "Add credits" and "Resubscribe"
// in the credits row.
const mockSubscriptionStatus: CloudSubscriptionStatusResponse = {
is_active: true,
subscription_id: 'sub_e2e',
renewal_date: FUTURE_DATE,
end_date: FUTURE_DATE
}
// ~6.3M credits — a 7-digit balance is what pushes the second action button out
// of the popover before the fix.
const mockBalance: CustomerBalanceResponse = {
amount_micros: 3_000_000,
effective_balance_micros: 3_000_000,
currency: 'usd'
}
const test = comfyPageFixture.extend({
page: async ({ page }, use) => {
await page.route('**/api/features', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockRemoteConfig)
})
)
await page.route('**/api/workspaces', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockListWorkspacesResponse)
})
})
await page.route('**/api/auth/token', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockTokenResponse)
})
)
await page.route('**/customers/cloud-subscription-status', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockSubscriptionStatus)
})
)
await page.route('**/customers/balance', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockBalance)
})
)
await use(page)
}
})
test.describe('Current user popover credits row', { tag: '@cloud' }, () => {
test('keeps both action buttons inside the popover when cancelled but active', async ({
comfyPage
}) => {
const page = comfyPage.page
await comfyPage.toast.closeToasts()
await page.getByRole('button', { name: 'Current user' }).click()
const popover = page.locator('.current-user-popover')
await expect(popover).toBeVisible()
const addCredits = page.getByTestId('add-credits-button')
const resubscribe = page.getByRole('button', { name: 'Resubscribe' })
await expect(addCredits).toBeVisible()
await expect(resubscribe).toBeVisible()
const popoverBox = await popover.boundingBox()
const resubscribeBox = await resubscribe.boundingBox()
expect(popoverBox).not.toBeNull()
expect(resubscribeBox).not.toBeNull()
const popoverRight = popoverBox!.x + popoverBox!.width
const resubscribeRight = resubscribeBox!.x + resubscribeBox!.width
expect(resubscribeRight).toBeLessThanOrEqual(popoverRight)
})
})

View File

@@ -99,15 +99,15 @@ async function mockShareableAssets(
}
/**
* Dismiss stale PrimeVue dialog masks left by cloud-mode's onboarding flow
* or auth-triggered modals by pressing Escape until they clear.
* Dismiss stale dialogs left by cloud-mode's onboarding flow or
* auth-triggered modals by pressing Escape until they clear.
*/
async function dismissOverlays(page: Page): Promise<void> {
const mask = page.locator('.p-dialog-mask')
const dialogs = page.getByRole('dialog')
for (let attempt = 0; attempt < 3; attempt++) {
if ((await mask.count()) === 0) break
if ((await dialogs.count()) === 0) break
await page.keyboard.press('Escape')
await mask
await dialogs
.first()
.waitFor({ state: 'hidden', timeout: 2000 })
.catch(() => {})

View File

@@ -612,18 +612,23 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
test('Can zoom in/out with ctrl+shift+vertical-drag', async ({
comfyPage
}) => {
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.down('Shift')
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 100 }, { x: 10, y: 40 })
// Use ctrlShiftDrag so the Control+Shift modifiers are pressed and released
// around each individual gesture. Holding the modifiers down across all
// three drags plus the intervening screenshot assertions could saturate the
// main thread and stall a single mouse.move step past the test timeout, and
// a mid-test failure would leave the modifiers stuck down. Releasing per
// gesture matches the robust pattern used in canvasSettings.spec.ts.
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 100 }, { x: 10, y: 40 })
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 40 }, { x: 10, y: 160 })
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 40 }, { x: 10, y: 160 })
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out-ctrl-shift.png')
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 280 }, { x: 10, y: 220 })
await comfyPage.canvasOps.ctrlShiftDrag(
{ x: 10, y: 280 },
{ x: 10, y: 220 }
)
await expect(comfyPage.canvas).toHaveScreenshot(
'zoomed-default-ctrl-shift.png'
)
await comfyPage.page.keyboard.up('Control')
await comfyPage.page.keyboard.up('Shift')
})
test('Can zoom in/out after decreasing canvas zoom speed setting', async ({

View File

@@ -32,6 +32,10 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await expect(dialog.getByText('Save')).toBeVisible()
await expect(dialog.getByText('Cancel')).toBeVisible()
await dialog.getByTestId('pointer-zone').hover()
await dialog.getByText('Brush Settings').hover()
await expect(dialog.getByTestId('brush-cursor')).toHaveCSS('opacity', '0')
await comfyPage.expectScreenshot(dialog, 'mask-editor-dialog-open.png')
}
)
@@ -250,21 +254,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
}) => {
const dialog = await maskEditor.openDialog()
let maskUploadCount = 0
let imageUploadCount = 0
await comfyPage.page.route('**/upload/mask', (route) => {
maskUploadCount++
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
name: `test-mask-${maskUploadCount}.png`,
subfolder: 'clipspace',
type: 'input'
})
})
})
await comfyPage.page.route('**/upload/image', (route) => {
imageUploadCount++
return route.fulfill({
@@ -284,20 +275,17 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await expect(dialog).toBeHidden()
// The save pipeline uploads multiple layers (mask + image variants)
// The save pipeline uploads four layers (masked, paint, painted, paintedMasked)
// through the unified /upload/image endpoint.
expect(
maskUploadCount + imageUploadCount,
'save should trigger upload calls'
).toBeGreaterThan(0)
imageUploadCount,
'save should upload all four layers via /upload/image'
).toBe(4)
})
test('save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
const dialog = await maskEditor.openDialog()
// Fail all upload routes
await comfyPage.page.route('**/upload/mask', (route) =>
route.fulfill({ status: 500 })
)
await comfyPage.page.route('**/upload/image', (route) =>
route.fulfill({ status: 500 })
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 324 KiB

View File

@@ -34,19 +34,17 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
let observedContentType = ''
let observedBodyLength = 0
await comfyPage.page.route('**/upload/mask', async (route) => {
await comfyPage.page.route('**/upload/image', async (route) => {
const request = route.request()
observedContentType = (await request.headerValue('content-type')) ?? ''
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
if (!observedContentType) {
observedContentType = (await request.headerValue('content-type')) ?? ''
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
}
await route.fulfill(
fulfillJson(successResponse('clipspace-mask-123.png'))
)
})
await comfyPage.page.route('**/upload/image', (route) =>
route.fulfill(fulfillJson(successResponse('clipspace-painted-123.png')))
)
await dialog.getByRole('button', { name: 'Save' }).click()
await expect(dialog).toBeHidden()
expect(observedContentType).toContain('multipart/form-data')
@@ -69,24 +67,11 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
await expect(dialog).toBeVisible()
})
test('Save failure on partial upload keeps dialog open', async ({
comfyPage,
maskEditor
}) => {
test('Save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeAndExpectPixels(dialog)
// The saver uploads sequentially: mask layer first, then image layers.
// Let the mask upload succeed and the image upload fail to exercise both
// endpoints and verify the dialog stays open after a partial failure.
let maskUploadHit = false
let imageUploadHit = false
await comfyPage.page.route('**/upload/mask', (route) => {
maskUploadHit = true
return route.fulfill(
fulfillJson(successResponse('clipspace-mask-999.png'))
)
})
await comfyPage.page.route('**/upload/image', (route) => {
imageUploadHit = true
return route.fulfill({ status: 500 })
@@ -95,7 +80,6 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
const saveButton = dialog.getByRole('button', { name: 'Save' })
await saveButton.click()
await expect.poll(() => maskUploadHit).toBe(true)
await expect.poll(() => imageUploadHit).toBe(true)
await expect(dialog).toBeVisible()
await expect(saveButton).toBeVisible()

View File

@@ -4,7 +4,6 @@ import {
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import type { WorkspaceStore } from '@e2e/types/globals'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
// TODO: there might be a better solution for this
@@ -35,56 +34,6 @@ async function openSelectionToolboxHelp(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId('properties-panel')
}
async function setLocaleAndWaitForWorkflowReload(
comfyPage: ComfyPage,
locale: string
) {
await comfyPage.page.evaluate(async (targetLocale) => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) {
throw new Error('No active workflow while waiting for locale reload')
}
const changeTracker = workflow.changeTracker.constructor as unknown as {
isLoadingGraph: boolean
}
let sawLoading = false
const waitForReload = new Promise<void>((resolve, reject) => {
const timeoutAt = performance.now() + 5000
const tick = () => {
if (changeTracker.isLoadingGraph) {
sawLoading = true
}
if (sawLoading && !changeTracker.isLoadingGraph) {
resolve()
return
}
if (performance.now() > timeoutAt) {
reject(
new Error(
`Timed out waiting for workflow reload after setting locale to ${targetLocale}`
)
)
return
}
requestAnimationFrame(tick)
}
tick()
})
await window.app!.extensionManager.setting.set('Comfy.Locale', targetLocale)
await waitForReload
}, locale)
}
test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
@@ -398,34 +347,33 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
await expect(helpPage.locator('img[alt="Safe Image"]')).toBeVisible()
})
test('Should handle locale-specific documentation', async ({
comfyPage
}) => {
// Mock different responses for different locales
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSamplerード
test.describe('Locale-specific documentation', () => {
test.use({ initialSettings: { 'Comfy.Locale': 'ja' } })
test('Should handle locale-specific documentation', async ({
comfyPage
}) => {
// Mock different responses for different locales
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSamplerード
これは日本語のドキュメントです。
`
})
})
})
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSampler Node
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSampler Node
This is English documentation.
`
})
})
})
// Set locale to Japanese
await setLocaleAndWaitForWorkflowReload(comfyPage, 'ja')
try {
await comfyPage.workflow.loadWorkflow('default')
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
@@ -434,9 +382,7 @@ This is English documentation.
const helpPage = await openSelectionToolboxHelp(comfyPage)
await expect(helpPage).toContainText('KSamplerード')
await expect(helpPage).toContainText('これは日本語のドキュメントです')
} finally {
await setLocaleAndWaitForWorkflowReload(comfyPage, 'en')
}
})
})
test('Should handle network errors gracefully', async ({ comfyPage }) => {

View File

@@ -10,13 +10,16 @@ import {
} from '@e2e/fixtures/utils/painter'
import type { TestGraphAccess } from '@e2e/types/globals'
const HIDDEN_PAINTER_WIDGET_NAMES = ['width', 'height', 'bg_color'] as const
const HIDDEN_PAINTER_NUMBER_WIDGET_NAMES = ['width', 'height'] as const
test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
})
test.describe('Widget rendering', { tag: ['@widget'] }, () => {
test.describe('Widget rendering', () => {
test('Node enforces minimum size', async ({ comfyPage }) => {
const size = await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
@@ -28,17 +31,15 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
expect(size![1]).toBeGreaterThanOrEqual(550)
})
test('Width, height, and bg_color standard widgets are hidden', async ({
test('Does not render hidden standard widgets in Vue mode', async ({
comfyPage
}) => {
const hiddenFlags = await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
const node = graph?._nodes_by_id?.['1']
return (node?.widgets ?? [])
.filter((w) => ['width', 'height', 'bg_color'].includes(w.name))
.map((w) => w.options.hidden ?? false)
})
expect(hiddenFlags).toEqual([true, true, true])
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
for (const widgetName of HIDDEN_PAINTER_WIDGET_NAMES) {
await expect(node.getByLabel(widgetName, { exact: true })).toBeHidden()
}
})
})
@@ -788,6 +789,49 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
})
})
test.describe(
'Painter legacy LiteGraph rendering',
{ tag: ['@widget', '@canvas'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
})
test('Does not open editors for backend-hidden number widget rows in legacy LiteGraph', async ({
comfyPage
}) => {
const painterNodes = await comfyPage.nodeOps.getNodeRefsByType('Painter')
expect(painterNodes).toHaveLength(1)
const painterNode = painterNodes[0]!
const maskWidget = await painterNode.getWidgetByName('mask')
const maskWidgetClientPosition = await maskWidget.getPosition()
const widgetRowClientHeight = await comfyPage.page.evaluate(
() =>
(window.LiteGraph!.NODE_WIDGET_HEIGHT + 4) *
window.app!.canvas.ds.scale
)
const legacyPrompt = comfyPage.page.locator('.graphdialog')
await expect(legacyPrompt).toBeHidden()
for (const [
index,
widgetName
] of HIDDEN_PAINTER_NUMBER_WIDGET_NAMES.entries()) {
await test.step(`Click ${widgetName} row`, async () => {
await comfyPage.page.mouse.click(
maskWidgetClientPosition.x,
maskWidgetClientPosition.y + widgetRowClientHeight * (index + 1)
)
await comfyPage.nextFrame()
await expect(legacyPrompt).toBeHidden()
})
}
})
}
)
test.describe(
'Painter — input image connection',
{ tag: ['@widget', '@vue-nodes', '@slow'] },

View File

@@ -143,7 +143,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
const objectInfo = await response.json()
const ckptName =
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
ckptName[0] = [...ckptName[0], FAKE_MODEL_NAME]
await route.fulfill({ response, json: objectInfo })
})
@@ -151,21 +151,11 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
const url = new URL(response.url())
return url.pathname.endsWith('/object_info') && response.ok()
})
const modelFoldersResponse = comfyPage.page.waitForResponse(
(response) => {
const url = new URL(response.url())
return url.pathname.endsWith('/experiment/models') && response.ok()
}
)
const refreshButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelRefresh
)
await Promise.all([
objectInfoResponse,
modelFoldersResponse,
refreshButton.click()
])
await Promise.all([objectInfoResponse, refreshButton.click()])
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeHidden()

View File

@@ -13,10 +13,6 @@ import type {
// Legacy coverage backed by AssetsHelper's shadow backend. New assets-sidebar
// browser coverage should use typed route mocks in assetsSidebarTab.spec.ts.
// ---------------------------------------------------------------------------
// Shared fixtures
// ---------------------------------------------------------------------------
const SAMPLE_JOBS: RawJobListItem[] = [
createMockJob({
id: 'job-alpha',
@@ -180,12 +176,10 @@ test.describe('Assets sidebar - tab navigation', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Switch to Imported
await tab.switchToImported()
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
// Switch back to Generated
await tab.switchToGenerated()
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
})
@@ -194,11 +188,9 @@ test.describe('Assets sidebar - tab navigation', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Type search in Generated tab
await tab.searchInput.fill('landscape')
await expect(tab.searchInput).toHaveValue('landscape')
// Switch to Imported tab
await tab.switchToImported()
await expect(tab.searchInput).toHaveValue('')
})
@@ -235,10 +227,8 @@ test.describe('Assets sidebar - grid view display', () => {
await tab.open()
await tab.switchToImported()
// Wait for imported assets to render
await expect(tab.assetCards.first()).toBeVisible()
// Imported tab should show the mocked files
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
})
@@ -286,11 +276,9 @@ test.describe('Assets sidebar - view mode toggle', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Open settings menu and select list view
await tab.openSettingsMenu()
await tab.listViewOption.click()
// List view items should now be visible
await expect(tab.listViewItems.first()).toBeVisible()
})
@@ -298,16 +286,13 @@ test.describe('Assets sidebar - view mode toggle', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Switch to list view
await tab.openSettingsMenu()
await tab.listViewOption.click()
await expect(tab.listViewItems.first()).toBeVisible()
// Switch back to grid view (settings popover is still open)
await tab.gridViewOption.click()
await tab.waitForAssets()
// Grid cards (with data-selected attribute) should be visible again
await expect(tab.assetCards.first()).toBeVisible()
})
})
@@ -342,10 +327,8 @@ test.describe('Assets sidebar - search', () => {
const initialCount = await tab.assetCards.count()
// Search for a specific filename that matches only one asset
await tab.searchInput.fill('landscape')
// Wait for filter to reduce the count
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
})
@@ -355,7 +338,6 @@ test.describe('Assets sidebar - search', () => {
const initialCount = await tab.assetCards.count()
// Filter then clear
await tab.searchInput.fill('landscape')
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
@@ -391,10 +373,8 @@ test.describe('Assets sidebar - selection', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Click first asset card
await tab.assetCards.first().click()
// Should have data-selected="true"
await expect(tab.selectedCards).toHaveCount(1)
})
@@ -405,11 +385,9 @@ test.describe('Assets sidebar - selection', () => {
const cards = tab.assetCards
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
// Click first card
await cards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Ctrl+click second card
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
await expect(tab.selectedCards).toHaveCount(2)
})
@@ -420,10 +398,8 @@ test.describe('Assets sidebar - selection', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Select an asset
await tab.assetCards.first().click()
// Footer should show selection count
await expect(tab.selectionCountButton).toBeVisible()
})
@@ -431,15 +407,10 @@ test.describe('Assets sidebar - selection', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Select an asset
await tab.assetCards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Hover over the selection count button to reveal "Deselect all"
await tab.selectionCountButton.hover()
await expect(tab.deselectAllButton).toBeVisible()
// Click "Deselect all"
await tab.deselectAllButton.click()
await expect(tab.selectedCards).toHaveCount(0)
})
@@ -448,14 +419,11 @@ test.describe('Assets sidebar - selection', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Select an asset
await tab.assetCards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Switch to Imported tab
await tab.switchToImported()
// Switch back - selection should be cleared
await tab.switchToGenerated()
await tab.waitForAssets()
await expect(tab.selectedCards).toHaveCount(0)
@@ -481,10 +449,8 @@ test.describe('Assets sidebar - context menu', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Right-click first asset
await tab.assetCards.first().click({ button: 'right' })
// Context menu should appear with standard items
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible()
})
@@ -565,8 +531,6 @@ test.describe('Assets sidebar - context menu', () => {
test('Cancelling export-workflow filename prompt does not show an error toast', async ({
comfyPage
}) => {
// job-gamma is the first card; its detail carries a valid workflow so
// extraction succeeds and the filename prompt opens.
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
const tab = comfyPage.menu.assetsTab
@@ -614,8 +578,6 @@ test.describe('Assets sidebar - context menu', () => {
test('Export-workflow shows a warning toast when the asset has no workflow', async ({
comfyPage
}) => {
// Strip the workflow field so extraction yields null and the export
// action returns { success: false, error: 'No workflow…' }.
const { workflow: _, ...detailWithoutWorkflow } = JOB_GAMMA_DETAIL
await comfyPage.assets.mockJobDetail('job-gamma', detailWithoutWorkflow)
@@ -625,7 +587,6 @@ test.describe('Assets sidebar - context menu', () => {
await tab.assetCards.first().click({ button: 'right' })
await tab.contextMenuItem('Export workflow').click()
// Filename prompt should be skipped: extraction fails before the prompt.
await expect(comfyPage.toast.toastWarnings).toBeVisible()
await expect(comfyPage.toast.toastSuccesses).toBeHidden({ timeout: 1500 })
})
@@ -639,23 +600,18 @@ test.describe('Assets sidebar - context menu', () => {
const cards = tab.assetCards
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
// Dismiss any toasts that appeared after asset loading
await tab.dismissToasts()
// Multi-select: use keyboard.down/up so useKeyModifier('Control') detects
// the modifier — click({ modifiers }) only sets the mouse event flag and
// does not fire a keydown event that VueUse tracks.
// useKeyModifier('Control') needs keyboard events, not click modifiers.
await cards.first().click()
await comfyPage.page.keyboard.down('Control')
await cards.nth(1).click()
await comfyPage.page.keyboard.up('Control')
// Verify multi-selection took effect and footer is stable before right-clicking
await expect(tab.selectedCards).toHaveCount(2)
await expect(tab.selectionFooter).toBeVisible()
// Use dispatchEvent instead of click({ button: 'right' }) to avoid any
// overlay intercepting the event, and assert directly without toPass.
// dispatchEvent avoids the selection footer intercepting a right click.
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await cards.first().dispatchEvent('contextmenu', {
bubbles: true,
@@ -664,7 +620,6 @@ test.describe('Assets sidebar - context menu', () => {
})
await expect(contextMenu).toBeVisible()
// Bulk menu should show bulk download action
await expect(tab.contextMenuItem('Download all')).toBeVisible()
})
})
@@ -692,7 +647,6 @@ test.describe('Assets sidebar - bulk actions', () => {
await tab.assetCards.first().click()
// Download button in footer should be visible
await expect(tab.downloadSelectedButton).toBeVisible()
})
@@ -704,7 +658,6 @@ test.describe('Assets sidebar - bulk actions', () => {
await tab.assetCards.first().click()
// Delete button in footer should be visible
await expect(tab.deleteSelectedButton).toBeVisible()
})
@@ -712,21 +665,67 @@ test.describe('Assets sidebar - bulk actions', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Select the two single-output assets (job-alpha, job-beta).
// The count reflects total outputs, not cards — job-gamma has
// outputs_count: 2 which would inflate the total.
const cards = tab.assetCards
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(3)
// Cards are sorted newest-first: gamma (idx 0), beta (1), alpha (2)
await cards.nth(1).click()
await comfyPage.page.keyboard.down('Control')
await cards.nth(2).click()
await comfyPage.page.keyboard.up('Control')
// Selection count should show the count
await expect(tab.selectionCountButton).toBeVisible()
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
})
test('Selection count sums the outputs of a stacked asset', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.assetCards.first().click()
await expect(tab.selectionCountButton).toBeVisible()
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
})
test('Selection bar stays capped, not stretched, on a wide panel', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1600, height: 900 })
const tab = comfyPage.menu.assetsTab
await tab.open()
const gutter = comfyPage.page.locator('.p-splitter-gutter').first()
await expect(gutter).toBeVisible()
const gutterBox = await gutter.boundingBox()
if (!gutterBox) {
throw new Error('sidebar splitter gutter has no bounding box')
}
await comfyPage.page.mouse.move(
gutterBox.x + gutterBox.width / 2,
gutterBox.y + gutterBox.height / 2
)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(900, gutterBox.y + gutterBox.height / 2, {
steps: 12
})
await comfyPage.page.mouse.up()
await tab.assetCards.first().click()
await expect(tab.selectionFooter).toBeVisible()
const sidebar = comfyPage.page.locator('.side-bar-panel').first()
await expect
.poll(async () => (await sidebar.boundingBox())?.width ?? 0)
.toBeGreaterThan(520)
await expect
.poll(async () => {
const bar = await tab.selectionFooter.boundingBox()
const side = await sidebar.boundingBox()
return bar && side ? side.width - bar.width : 0
})
.toBeGreaterThan(100)
})
})
@@ -833,8 +832,7 @@ test.describe('Assets sidebar - pagination', () => {
await comfyPage.assets.mockOutputHistory(manyJobs)
await comfyPage.setup()
// Capture the first history fetch (terminal statuses only).
// Queue polling also hits /jobs but with status=in_progress,pending.
// Queue polling also calls /jobs, so wait for completed history only.
const firstRequest = comfyPage.page.waitForRequest((req) => {
if (!/\/api\/jobs\?/.test(req.url())) return false
const url = new URL(req.url())
@@ -1002,9 +1000,7 @@ const MIXED_MEDIA_JOBS: RawJobListItem[] = [
})
]
// Filter button is guarded by isCloud (compile-time). The cloud CI project
// cannot use comfyPageFixture (auth required). Enable once cloud E2E infra
// supports authenticated comfyPage setup.
// Filter button is guarded by isCloud; cloud CI needs authenticated setup.
test.describe('Assets sidebar - media type filter', () => {
test.fixme(true, 'Requires DISTRIBUTION=cloud build with auth bypass')
@@ -1040,12 +1036,9 @@ test.describe('Assets sidebar - media type filter', () => {
'All three mixed-media jobs should render'
).toHaveCount(3)
// Open filter menu and enable only image filter (selecting a filter
// restricts to that type only, hiding unselected types)
await tab.openFilterMenu()
await tab.filterCheckbox('Image').click()
// Only the image asset should remain
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
await expect(tab.getAssetCardByName('photo.png')).toBeVisible()
})
@@ -1056,12 +1049,10 @@ test.describe('Assets sidebar - media type filter', () => {
const initialCount = await tab.assetCards.count()
// Enable image filter to restrict to images only
await tab.openFilterMenu()
await tab.filterCheckbox('Image').click()
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
// Uncheck image filter to remove all filters (restores all assets)
await tab.filterCheckbox('Image').click()
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
})

View File

@@ -214,7 +214,7 @@ test.describe('FE-130 assets sidebar route mocks', () => {
await tab.open()
await tab.getAssetCardByName('alpha').click()
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*1\b/)
await expect(tab.selectionCountButton).toHaveText(/\b1 selected\b/)
await expect(tab.deleteSelectedButton).toBeVisible()
await expect(tab.downloadSelectedButton).toBeVisible()
@@ -222,7 +222,7 @@ test.describe('FE-130 assets sidebar route mocks', () => {
await tab.getAssetCardByName('beta').click()
await comfyPage.page.keyboard.up('Control')
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
await expect(tab.deleteSelectedButton).toBeVisible()
await expect(tab.downloadSelectedButton).toBeVisible()
})

View File

@@ -233,4 +233,64 @@ test.describe('Model library sidebar - empty state', () => {
await expect(tab.folderNodes).toHaveCount(0)
await expect(tab.leafNodes).toHaveCount(0)
})
test.describe('Model library sidebar - add node', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
await comfyPage.setup()
await comfyPage.nodeOps.clearGraph()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.clearMocks()
})
test('Clicking a model defers creation until placed on the canvas', async ({
comfyPage
}) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await tab.getFolderByLabel('checkpoints').click()
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
await tab.getLeafByLabel('sd_xl_base_1.0').click()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
.toBe(0)
const canvasBox = (await comfyPage.canvas.boundingBox())!
await comfyPage.canvas.click({
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
})
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(1)
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
'CheckpointLoaderSimple'
)
expect(loader).toBeDefined()
const widget = await loader.getWidgetByName('ckpt_name')
expect(await widget.getValue()).toBe('sd_xl_base_1.0.safetensors')
})
test('Ghost preview shows the model in the loader widget before placing', async ({
comfyPage
}) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await tab.getFolderByLabel('checkpoints').click()
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
await tab.getLeafByLabel('sd_xl_base_1.0').click()
const ghost = comfyPage.page.locator(
'[data-node-id="preview-CheckpointLoaderSimple"]'
)
await expect(ghost).toContainText('sd_xl_base_1.0.safetensors')
})
})
})

View File

@@ -1,13 +1,13 @@
import { expect, mergeTests } from '@playwright/test'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture'
import { makeTemplate } from '@e2e/fixtures/data/templateFixtures'
import { withTemplates } from '@e2e/fixtures/helpers/TemplateHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { templateApiFixture } from '@e2e/fixtures/templateApiFixture'
const test = mergeTests(comfyPageFixture, templateApiFixture)
const test = mergeTests(createCloudAssetsFixture([]), templateApiFixture)
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
const Desktop = TemplateIncludeOnDistributionEnum.Desktop

View File

@@ -0,0 +1,139 @@
import type { Locator, WebSocketRoute } from '@playwright/test'
import { mergeTests } from '@playwright/test'
import {
comfyPageFixture,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
import { TestIds } from '@e2e/fixtures/selectors'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const KSAMPLER_NODE = '3'
async function runOnBackgroundTab(
comfyPage: ComfyPage,
ws: WebSocketRoute
): Promise<{ exec: ExecutionHelper; jobId: string; backgroundTab: Locator }> {
const topbar = comfyPage.menu.topbar
await comfyPage.workflow.waitForActiveWorkflow()
await comfyPage.workflow.waitForWorkflowIdle()
const exec = new ExecutionHelper(comfyPage, ws)
const jobId = await exec.run()
await comfyPage.nextFrame()
await topbar.newWorkflowButton.click()
await comfyPage.workflow.waitForWorkflowIdle()
await expect(topbar.getActiveTab()).toContainText('(2)')
const backgroundTab = topbar.getTab(0)
exec.executionStart(jobId)
await expect(
backgroundTab.getByRole('img', { name: 'Running' })
).toBeVisible()
return { exec, jobId, backgroundTab }
}
test.describe('Workflow tab status indicator', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
await comfyPage.setup()
})
test('replaces the running indicator with completed when the job finishes', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
comfyPage,
ws
)
exec.executionSuccess(jobId)
await expect(
backgroundTab.getByRole('img', { name: 'Completed' })
).toBeVisible()
await expect(
backgroundTab.getByRole('img', { name: 'Running' })
).toHaveCount(0)
})
test('shows failed when the background job errors', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
comfyPage,
ws
)
exec.executionError(jobId, KSAMPLER_NODE, 'boom')
// The error opens a modal dialog that aria-hides the rest of the app
// (focus trap), taking the tab out of the accessibility tree. Dismiss it
// so the badge is reachable by role.
const errorDialog = comfyPage.page.getByTestId(TestIds.dialogs.errorDialog)
await expect(errorDialog).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(errorDialog).toBeHidden()
await expect(
backgroundTab.getByRole('img', { name: 'Failed' })
).toBeVisible()
})
test('drops the indicator on user interrupt rather than showing an error', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
comfyPage,
ws
)
exec.executionInterrupted(jobId, KSAMPLER_NODE)
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
})
test('clears the indicator once the tab is activated', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
comfyPage,
ws
)
exec.executionSuccess(jobId)
await expect(
backgroundTab.getByRole('img', { name: 'Completed' })
).toBeVisible()
const currentTab = comfyPage.menu.topbar.getActiveTab()
await expect(
backgroundTab.getByRole('img', { name: 'Completed' })
).toBeVisible()
await backgroundTab.click()
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
await currentTab.click()
await comfyPage.workflow.waitForWorkflowIdle()
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
})
})

View File

@@ -280,3 +280,36 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
await expect.poll(bypassCount, "won't toggle double selected node").toBe(7)
})
})
test.describe(
'Vue Node Group Context Menu',
{ tag: ['@vue-nodes', '@canvas'] },
() => {
test('right-clicking a group opens the Vue context menu instead of the legacy menu', async ({
comfyPage
}) => {
// Deselect so the right-click selects the group itself.
await comfyPage.keyboard.selectAll()
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
await expect
.poll(() => comfyPage.page.evaluate(() => graph!.groups.length))
.toBe(1)
await comfyPage.page.mouse.click(100, 100)
await comfyPage.nextFrame()
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
await comfyPage.page.mouse.click(groupPos.x, groupPos.y, {
button: 'right'
})
await expect(comfyPage.contextMenu.primeVueMenu).toBeVisible()
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden()
await expect(comfyPage.contextMenu.litegraphMenu).toBeHidden()
// Group-only action confirms it is the group menu.
await expect(
comfyPage.contextMenu.primeVueMenu.getByText('Fit Group To Nodes')
).toBeVisible()
})
}
)

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -355,7 +355,7 @@ describe('TreeExplorerV2Node', () => {
const nodeDiv = getTreeNode(container)
await fireEvent.dragStart(nodeDiv)
expect(mockStartDrag).toHaveBeenCalledWith(mockData, 'native')
expect(mockStartDrag).toHaveBeenCalledWith(mockData, { mode: 'native' })
})
it('does not call startDrag for folder items on dragstart', async () => {

View File

@@ -44,16 +44,32 @@ describe('GlobalDialog renderer branching', () => {
cleanup()
})
it('renders the PrimeVue branch when renderer is omitted', async () => {
it('renders the Reka branch when renderer is omitted (default)', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'primevue-default',
title: 'PrimeVue dialog',
key: 'renderer-default',
title: 'Default renderer dialog',
component: Body
})
const dialogs = await screen.findAllByRole('dialog')
expect(dialogs.length).toBeGreaterThan(0)
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(false)
})
it("renders the legacy PrimeVue branch when renderer is 'primevue'", async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'primevue-escape-hatch',
title: 'PrimeVue dialog',
component: Body,
dialogComponentProps: { renderer: 'primevue' }
})
const dialogs = await screen.findAllByRole('dialog')
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(true)
})

View File

@@ -0,0 +1,54 @@
/**
* Dialog migration regression net: the showConfirmDialog helper must open
* its dialog through the Reka renderer with zeroed section padding (the
* Confirm* sections carry their own). Catches accidental reverts of the
* Phase 6 renderer flip.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const showDialog = vi.hoisted(() => vi.fn())
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ showDialog })
}))
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
describe('showConfirmDialog Reka renderer opt-in', () => {
beforeEach(() => {
showDialog.mockReset()
})
it("sets renderer 'reka' with size 'md' and zeroed section padding", () => {
showConfirmDialog()
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.size).toBe('md')
expect(args.dialogComponentProps.headerClass).toBe('p-0')
expect(args.dialogComponentProps.bodyClass).toBe('p-0')
expect(args.dialogComponentProps.footerClass).toBe('p-0')
expect(args.dialogComponentProps.pt).toBeUndefined()
})
it('forwards the confirm section components and caller props', () => {
showConfirmDialog({
key: 'confirm-test',
headerProps: { title: 'Title' },
props: { promptText: 'Prompt' },
footerProps: { confirmText: 'Delete' }
})
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('confirm-test')
expect(args.headerComponent).toBe(ConfirmHeader)
expect(args.component).toBe(ConfirmBody)
expect(args.footerComponent).toBe(ConfirmFooter)
expect(args.headerProps).toEqual({ title: 'Title' })
expect(args.props).toEqual({ promptText: 'Prompt' })
expect(args.footerProps).toEqual({ confirmText: 'Delete' })
})
})

View File

@@ -1,6 +1,7 @@
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
import type { DialogInstance } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
import type { ComponentAttrs } from 'vue-component-type-helpers'
@@ -11,7 +12,9 @@ interface ConfirmDialogOptions {
footerProps?: ComponentAttrs<typeof ConfirmFooter>
}
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
export function showConfirmDialog(
options: ConfirmDialogOptions = {}
): DialogInstance {
const dialogStore = useDialogStore()
const { key, headerProps, props, footerProps } = options
return dialogStore.showDialog({
@@ -23,11 +26,13 @@ export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
props,
footerProps,
dialogComponentProps: {
pt: {
header: 'py-0! px-0!',
content: 'p-0!',
footer: 'p-0!'
}
renderer: 'reka',
size: 'md',
// Confirm sections carry their own padding — zero out the dialog
// chrome padding, like the PrimeVue `pt` overrides did.
headerClass: 'p-0',
bodyClass: 'p-0',
footerClass: 'p-0'
}
})
}

View File

@@ -7,6 +7,7 @@ import { createI18n } from 'vue-i18n'
import { render, screen, waitFor } from '@testing-library/vue'
import type * as DistributionTypes from '@/platform/distribution/types'
import type { AuditLog } from '@/services/customerEventsService'
import { EventType } from '@/services/customerEventsService'
@@ -38,6 +39,23 @@ vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
}))
const mockFlags = vi.hoisted(() => ({ teamWorkspacesEnabled: false }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: mockFlags })
}))
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
...(await importOriginal<typeof DistributionTypes>()),
isCloud: true
}))
const mockWorkspaceApi = vi.hoisted(() => ({
getBillingEvents: vi.fn()
}))
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: mockWorkspaceApi
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -118,6 +136,8 @@ describe('UsageLogsTable', () => {
vi.clearAllMocks()
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
mockWorkspaceApi.getBillingEvents.mockResolvedValue(mockEventsResponse)
mockFlags.teamWorkspacesEnabled = false
mockCustomerEventsService.formatEventType.mockImplementation(
(type: string) => {
switch (type) {
@@ -320,6 +340,20 @@ describe('UsageLogsTable', () => {
})
})
describe('billing events source', () => {
it('uses workspaceApi.getBillingEvents when teamWorkspacesEnabled is on', async () => {
mockFlags.teamWorkspacesEnabled = true
await renderLoaded()
expect(mockWorkspaceApi.getBillingEvents).toHaveBeenCalledWith({
page: 1,
limit: 7
})
expect(mockCustomerEventsService.getMyEvents).not.toHaveBeenCalled()
})
})
describe('EventType integration', () => {
it('renders credit_added event with correct detail template', async () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(

View File

@@ -99,7 +99,10 @@ import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import type { AuditLog } from '@/services/customerEventsService'
import {
EventType,
@@ -112,6 +115,9 @@ const error = ref<string | null>(null)
const customerEventService = useCustomerEventsService()
const { flags } = useFeatureFlags()
const useBillingApi = computed(() => isCloud && flags.teamWorkspacesEnabled)
const pagination = ref({
page: 1,
limit: 7,
@@ -138,10 +144,13 @@ const loadEvents = async () => {
error.value = null
try {
const response = await customerEventService.getMyEvents({
const params = {
page: pagination.value.page,
limit: pagination.value.limit
})
}
const response = useBillingApi.value
? await workspaceApi.getBillingEvents(params)
: await customerEventService.getMyEvents(params)
if (response) {
if (response.events) {

View File

@@ -93,6 +93,7 @@
<NodeTooltip v-if="tooltipEnabled" />
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
<NodeDragPreview />
<VueNodeSwitchPopup />
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
@@ -136,6 +137,7 @@ import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
@@ -145,6 +147,7 @@ import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useGroupContextMenu } from '@/composables/graph/useGroupContextMenu'
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
@@ -464,6 +467,7 @@ useNodeBadge()
useGlobalLitegraph()
useContextMenuTranslation()
useGroupContextMenu()
useCopy()
usePaste()
useWorkflowAutoSave()

View File

@@ -0,0 +1,97 @@
import { render } from '@testing-library/vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { fromPartial } from '@total-typescript/shoehorn'
vi.mock(
'@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue',
() => ({
default: { template: '<div data-testid="node-preview" />' }
})
)
const nodeDef = fromPartial<ComfyNodeDefImpl>({ name: 'TestNode' })
function moveMouse(clientX: number, clientY: number) {
window.dispatchEvent(new MouseEvent('mousemove', { clientX, clientY }))
}
function ghostElement() {
return document.querySelector('[data-testid="node-preview"]')?.parentElement
?.parentElement
}
describe('NodeDragPreview', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
useNodeDragToCanvas().cancelDrag()
vi.useRealTimers()
})
it('shows no ghost when nothing is being dragged', async () => {
render(NodeDragPreview)
moveMouse(100, 200)
vi.advanceTimersByTime(16)
await nextTick()
expect(ghostElement()).toBeFalsy()
})
it('keeps the ghost hidden until the mouse position is known', async () => {
render(NodeDragPreview)
useNodeDragToCanvas().startDrag(nodeDef)
await nextTick()
vi.advanceTimersByTime(16)
await nextTick()
expect(ghostElement()).toBeFalsy()
})
it('follows the mouse with an offset while dragging', async () => {
render(NodeDragPreview)
useNodeDragToCanvas().startDrag(nodeDef)
await nextTick()
moveMouse(100, 200)
vi.advanceTimersByTime(16)
await nextTick()
expect(ghostElement()?.style.transform).toBe('translate(112px, 212px)')
vi.advanceTimersByTime(16)
await nextTick()
expect(ghostElement()?.style.transform).toBe('translate(112px, 212px)')
moveMouse(300, 400)
vi.advanceTimersByTime(16)
await nextTick()
expect(ghostElement()?.style.transform).toBe('translate(312px, 412px)')
})
it('removes the ghost when the drag is cancelled', async () => {
render(NodeDragPreview)
useNodeDragToCanvas().startDrag(nodeDef)
await nextTick()
moveMouse(100, 200)
vi.advanceTimersByTime(16)
await nextTick()
expect(ghostElement()).toBeTruthy()
useNodeDragToCanvas().cancelDrag()
await nextTick()
expect(ghostElement()).toBeFalsy()
})
})

View File

@@ -0,0 +1,57 @@
<template>
<Teleport to="body">
<div
v-if="showGhost && rafPosition"
class="pointer-events-none fixed top-0 left-0 z-10000 will-change-transform"
:style="{
transform: `translate(${rafPosition.x + 12}px, ${rafPosition.y + 12}px)`
}"
>
<div class="origin-top-left scale-50 opacity-80">
<LGraphNodePreview
:node-def="draggedNode!"
:widget-values="pendingWidgetValues"
position="relative"
/>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useMouse, useRafFn } from '@vueuse/core'
import { computed, shallowRef, watch } from 'vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
const { isDragging, draggedNode, pendingWidgetValues } = useNodeDragToCanvas()
const { x, y, sourceType } = useMouse({ type: 'client' })
const showGhost = computed(() => Boolean(isDragging.value && draggedNode.value))
const rafPosition = shallowRef<{ x: number; y: number }>()
const { pause, resume } = useRafFn(
() => {
if (sourceType.value === null) return
const pos = rafPosition.value
if (pos && pos.x === x.value && pos.y === y.value) return
rafPosition.value = { x: x.value, y: y.value }
},
{ immediate: false }
)
watch(
showGhost,
(show) => {
if (show) {
resume()
} else {
pause()
rafPosition.value = undefined
}
},
{ immediate: true }
)
</script>

View File

@@ -66,7 +66,6 @@ import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
import { isCloud } from '@/platform/distribution/types'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
@@ -195,20 +194,15 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
const jobId = item.taskRef?.jobId
if (!jobId) return
if (item.state === 'running' || item.state === 'initialization') {
// Running/initializing jobs: interrupt execution
// Cloud backend uses deleteItem, local uses interrupt
if (isCloud) {
await api.deleteItem('queue', jobId)
} else {
await api.interrupt(jobId)
}
if (
item.state === 'running' ||
item.state === 'initialization' ||
item.state === 'pending'
) {
// State-agnostic cancel (see api.ts cancelJob for the runtime-parity caveat).
await api.cancelJob(jobId)
executionStore.clearInitializationByJobId(jobId)
await queueStore.update()
} else if (item.state === 'pending') {
// Pending jobs: remove from queue
await api.deleteItem('queue', jobId)
await queueStore.update()
}
})
@@ -292,17 +286,8 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
if (!jobIds.length) return
// Cloud backend supports cancelling specific jobs via /queue delete,
// while /interrupt always targets the "first" job. Use the targeted API
// on cloud to ensure we cancel the workflow the user clicked.
if (isCloud) {
await Promise.all(jobIds.map((id) => api.deleteItem('queue', id)))
executionStore.clearInitializationByJobIds(jobIds)
await queueStore.update()
return
}
await Promise.all(jobIds.map((id) => api.interrupt(id)))
// State-agnostic batch cancel (see api.ts cancelJobs for the runtime-parity caveat).
await api.cancelJobs(jobIds)
executionStore.clearInitializationByJobIds(jobIds)
await queueStore.update()
})

View File

@@ -115,69 +115,14 @@
</div>
</template>
<template #footer>
<div
<MediaAssetSelectionBar
v-if="hasSelection"
ref="footerRef"
class="flex h-18 w-full items-center justify-between gap-1"
>
<div class="flex-1 pl-4">
<div ref="selectionCountButtonRef" class="inline-flex w-48">
<Button
variant="secondary"
:class="cn(isCompact && 'text-left')"
@click="handleDeselectAll"
>
{{
isHoveringSelectionCount
? $t('mediaAsset.selection.deselectAll')
: $t('mediaAsset.selection.selectedCount', {
count: totalOutputCount
})
}}
</Button>
</div>
</div>
<div class="flex shrink items-center-safe justify-end-safe gap-2 pr-4">
<template v-if="isCompact">
<!-- Compact mode: Icon only -->
<Button
v-if="shouldShowDeleteButton"
size="icon"
data-testid="assets-delete-selected"
@click="handleDeleteSelected"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
size="icon"
data-testid="assets-download-selected"
@click="handleDownloadSelected"
>
<i class="icon-[lucide--download] size-4" />
</Button>
</template>
<template v-else>
<!-- Normal mode: Icon + Text -->
<Button
v-if="shouldShowDeleteButton"
variant="secondary"
data-testid="assets-delete-selected"
@click="handleDeleteSelected"
>
<span>{{ $t('mediaAsset.selection.deleteSelected') }}</span>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
variant="secondary"
data-testid="assets-download-selected"
@click="handleDownloadSelected"
>
<span>{{ $t('mediaAsset.selection.downloadSelected') }}</span>
<i class="icon-[lucide--download] size-4" />
</Button>
</template>
</div>
</div>
:count="totalOutputCount"
:show-delete="shouldShowDeleteButton"
@deselect="handleDeselectAll"
@download="handleDownloadSelected"
@delete="handleDeleteSelected"
/>
</template>
</SidebarTabTemplate>
<MediaLightbox
@@ -208,8 +153,6 @@
import {
useAsyncState,
useDebounceFn,
useElementHover,
useResizeObserver,
useStorage,
useTimeoutFn
} from '@vueuse/core'
@@ -236,6 +179,7 @@ import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import MediaAssetSelectionBar from '@/platform/assets/components/MediaAssetSelectionBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
@@ -257,7 +201,6 @@ import {
getMediaTypeFromFilename,
isPreviewableMediaType
} from '@/utils/formatUtil'
import { cn } from '@comfyorg/tailwind-utils'
const Load3dViewerContent = defineAsyncComponent(
() => import('@/components/load3d/Load3dViewerContent.vue')
@@ -335,33 +278,6 @@ const {
exportMultipleWorkflows
} = useMediaAssetActions()
// Footer responsive behavior
const footerRef = ref<HTMLElement | null>(null)
const footerWidth = ref(0)
// Track footer width changes
useResizeObserver(footerRef, (entries) => {
const entry = entries[0]
footerWidth.value = entry.contentRect.width
})
// Determine if we should show compact mode (icon only)
// Threshold matches when grid switches from 2 columns to 1 column
// 2 columns need about ~430px
const COMPACT_MODE_THRESHOLD_PX = 430
const isCompact = computed(
() => footerWidth.value > 0 && footerWidth.value <= COMPACT_MODE_THRESHOLD_PX
)
// Hover state for selection count button
const selectionCountButtonRef = ref<HTMLElement | null>(null)
const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
// Total output count for all selected assets
const totalOutputCount = computed(() => {
return getTotalOutputCount(selectedAssets.value)
})
const currentAssets = computed(() =>
activeTab.value === 'input' ? inputAssets : outputAssets
)
@@ -429,6 +345,10 @@ const previewableVisibleAssets = computed(() =>
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
const totalOutputCount = computed(() =>
getTotalOutputCount(selectedAssets.value)
)
const isBulkMode = computed(
() => hasSelection.value && selectedAssets.value.length > 1
)

View File

@@ -14,7 +14,7 @@ const {
captureRoot,
getRoot,
resetRoot,
mockAddNodeOnGraph,
mockStartDrag,
mockGetNodeProvider,
mockToggleNodeOnEvent,
mockRefreshModelFolder,
@@ -29,7 +29,7 @@ const {
resetRoot: () => {
capturedRoot = null
},
mockAddNodeOnGraph: vi.fn(),
mockStartDrag: vi.fn(),
mockGetNodeProvider: vi.fn(),
mockToggleNodeOnEvent: vi.fn(),
mockRefreshModelFolder: vi.fn().mockResolvedValue(undefined),
@@ -37,8 +37,8 @@ const {
}
})
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ addNodeOnGraph: mockAddNodeOnGraph })
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({ startDrag: mockStartDrag })
}))
vi.mock('@/stores/modelToNodeStore', () => ({
@@ -173,16 +173,13 @@ describe('ModelLibrarySidebarTab', () => {
expect(screen.getByTestId('search-input')).toBeInTheDocument()
})
it('handles model click and adds node to graph', async () => {
it('starts a ghost drag carrying the widget value to fill on placement', async () => {
const mockNodeDef = { name: 'CheckpointLoaderSimple' }
const mockWidget = { name: 'ckpt_name', value: '' }
const mockGraphNode = { widgets: [mockWidget] }
mockGetNodeProvider.mockReturnValue({
nodeDef: mockNodeDef,
key: 'ckpt_name'
})
mockAddNodeOnGraph.mockReturnValue(mockGraphNode)
renderComponent()
await nextTick()
@@ -198,8 +195,10 @@ describe('ModelLibrarySidebarTab', () => {
await modelLeaf?.handleClick?.(mockEvent)
expect(mockGetNodeProvider).toHaveBeenCalledWith('checkpoints')
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef)
expect(mockWidget.value).toBe('model.safetensors')
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, {
widgetValues: { ckpt_name: 'model.safetensors' },
source: 'sidebar_drag'
})
})
it('toggles folder expansion on click', async () => {

View File

@@ -63,10 +63,9 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'
import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.vue'
import Button from '@/components/ui/button/Button.vue'
import { startModelLoaderDrag } from '@/composables/node/startModelNodeDragFromAsset'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useSettingStore } from '@/platform/settings/settingStore'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useLitegraphService } from '@/services/litegraphService'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
import { ResourceState, useModelStore } from '@/stores/modelStore'
@@ -156,15 +155,7 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
if (this.leaf && model) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
const graphNode = withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(provider.nodeDef)
)
const widget = graphNode?.widgets?.find(
(widget) => widget.name === provider.key
)
if (widget) {
widget.value = model.file_name
}
startModelLoaderDrag(provider, model.file_name)
}
} else {
toggleNodeOnEvent(e, node)

View File

@@ -31,11 +31,8 @@ vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({
isDragging: { value: false },
draggedNode: { value: null },
cursorPosition: { value: { x: 0, y: 0 } },
startDrag: vi.fn(),
cancelDrag: vi.fn(),
setupGlobalListeners: vi.fn(),
cleanupGlobalListeners: vi.fn()
cancelDrag: vi.fn()
})
}))

View File

@@ -115,7 +115,6 @@
</div>
</template>
<template #body>
<NodeDragPreview />
<div class="flex h-full flex-col">
<div
v-if="hasNoMatches"
@@ -215,7 +214,6 @@ import type {
import AllNodesPanel from './nodeLibrary/AllNodesPanel.vue'
import BlueprintsPanel from './nodeLibrary/BlueprintsPanel.vue'
import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
import SidebarTabTemplate from './SidebarTabTemplate.vue'
const { flags } = useFeatureFlags()

View File

@@ -1,69 +0,0 @@
<template>
<Teleport to="body">
<div
v-if="isDragging && draggedNode && showPreview"
class="pointer-events-none fixed z-10000"
:style="{
left: `${previewPosition.x + 12}px`,
top: `${previewPosition.y + 12}px`
}"
>
<div class="origin-top-left scale-50 opacity-80">
<LGraphNodePreview :node-def="draggedNode" position="relative" />
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
const {
isDragging,
draggedNode,
cursorPosition,
dragMode,
setupGlobalListeners,
cleanupGlobalListeners
} = useNodeDragToCanvas()
const nativeDragPosition = ref({ x: 0, y: 0 })
const previewPosition = computed(() => {
if (dragMode.value === 'native') {
return nativeDragPosition.value
}
return cursorPosition.value
})
const showPreview = computed(() => {
if (dragMode.value === 'native') {
return nativeDragPosition.value.x > 0 || nativeDragPosition.value.y > 0
}
return true
})
function handleDrag(e: DragEvent) {
if (e.clientX === 0 && e.clientY === 0) return
nativeDragPosition.value = { x: e.clientX, y: e.clientY }
}
function handleDragEnd() {
nativeDragPosition.value = { x: 0, y: 0 }
}
onMounted(() => {
setupGlobalListeners()
document.addEventListener('drag', handleDrag)
document.addEventListener('dragend', handleDragEnd)
})
onUnmounted(() => {
cleanupGlobalListeners()
document.removeEventListener('drag', handleDrag)
document.removeEventListener('dragend', handleDragEnd)
})
</script>

View File

@@ -0,0 +1,233 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { markRaw } from 'vue'
import { createI18n } from 'vue-i18n'
import type { ComponentProps } from 'vue-component-type-helpers'
import type * as ExecutionStoreModule from '@/stores/executionStore'
import type { WorkflowExecutionStatus } from '@/stores/executionStore'
const { mockWorkflowStatus, mockCloseWorkflow } = await vi.hoisted(async () => {
const { shallowRef } = await import('vue')
return {
mockWorkflowStatus: shallowRef<Map<object, WorkflowExecutionStatus>>(
new Map()
),
mockCloseWorkflow: vi.fn().mockResolvedValue(true)
}
})
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
currentUser: null,
isAuthenticated: false,
isLoading: false
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
currentUser: null,
isAuthenticated: false,
isInitialized: true
})
}))
vi.mock('@/stores/executionStore', async (importOriginal) => {
const actual = await importOriginal<typeof ExecutionStoreModule>()
return {
WORKFLOW_STATUS_I18N_KEYS: actual.WORKFLOW_STATUS_I18N_KEYS,
useExecutionStore: () => ({
getWorkflowStatus(workflow: object | undefined | null) {
if (!workflow) return undefined
return mockWorkflowStatus.value.get(workflow)
}
})
}
})
vi.mock('@/composables/usePragmaticDragAndDrop', () => ({
usePragmaticDraggable: vi.fn(),
usePragmaticDroppable: vi.fn()
}))
vi.mock('@/composables/useWorkflowActionsMenu', () => ({
useWorkflowActionsMenu: () => ({
menuItems: { value: [] }
})
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
closeWorkflow: mockCloseWorkflow
})
}))
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
useWorkflowThumbnail: () => ({
getThumbnail: vi.fn(() => null)
})
}))
vi.mock('./WorkflowTabPopover.vue', () => ({
default: {
render: () => null,
methods: {
showPopover: () => {},
hidePopover: () => {},
togglePopover: () => {}
}
}
}))
import WorkflowTab from './WorkflowTab.vue'
type WorkflowTabProps = ComponentProps<typeof WorkflowTab>
const statusAriaLabels: Record<WorkflowExecutionStatus, string> = {
running: 'Running',
completed: 'Completed',
failed: 'Failed'
}
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { close: 'Close', ...statusAriaLabels }
}
}
})
type WorkflowOption = WorkflowTabProps['workflowOption']
type Workflow = WorkflowOption['workflow']
type WorkflowOverrides = Partial<Workflow>
// ComfyWorkflow has many required fields the component never reads (file
// IO, change tracking). Validate the fields we *do* set against the real
// type via Partial<Workflow>, then cast — adding/renaming a read field in
// the component will fail typecheck on the override map.
function makeWorkflowOption(overrides: WorkflowOverrides = {}): WorkflowOption {
const workflow = {
key: 'test-key',
path: '/workflows/test.json',
filename: 'test.json',
isPersisted: true,
isModified: false,
activeMode: 'graph',
changeTracker: null,
...overrides
} satisfies WorkflowOverrides
// markRaw keeps a stable identity through prop reactivity so the store's
// identity-based status lookup resolves against the same object.
return { value: 'test-key', workflow: markRaw(workflow) as Workflow }
}
function renderTab({
workflowOption = makeWorkflowOption(),
activeWorkflowKey = 'other-key'
}: {
workflowOption?: WorkflowOption
activeWorkflowKey?: string
} = {}) {
return render(WorkflowTab, {
global: {
plugins: [
createTestingPinia({
stubActions: false,
initialState: {
workspace: { shiftDown: false },
workflow: {
activeWorkflow: { key: activeWorkflowKey }
},
setting: { settingValues: { 'Comfy.Workflow.AutoSave': 'off' } }
}
}),
i18n
],
stubs: {
WorkflowActionsList: true,
Button: {
template: '<button v-bind="$attrs"><slot /></button>'
}
}
},
props: {
workflowOption,
isFirst: false,
isLast: false
}
})
}
describe('WorkflowTab - workflow status indicator', () => {
beforeEach(() => {
mockWorkflowStatus.value = new Map()
})
it.for(['running', 'completed', 'failed'] as const)(
'labels the %s indicator with a translated status name',
(status) => {
const workflowOption = makeWorkflowOption()
mockWorkflowStatus.value = new Map([[workflowOption.workflow, status]])
renderTab({ workflowOption })
expect(
screen.getByRole('img', { name: statusAriaLabels[status] })
).toBeTruthy()
}
)
it('does not badge the active tab with its own status', () => {
const workflowOption = makeWorkflowOption()
mockWorkflowStatus.value = new Map([[workflowOption.workflow, 'running']])
renderTab({ workflowOption, activeWorkflowKey: 'test-key' })
expect(screen.queryByRole('img')).toBeNull()
})
it('shows unsaved dot when no workflow status and workflow is unsaved', () => {
renderTab({ workflowOption: makeWorkflowOption({ isPersisted: false }) })
expect(screen.queryByRole('img')).toBeNull()
expect(screen.getByTestId('workflow-dirty-indicator').textContent).toBe('•')
})
it('shows the unsaved dot when modified and autosave is off', () => {
renderTab({ workflowOption: makeWorkflowOption({ isModified: true }) })
expect(screen.getByTestId('workflow-dirty-indicator').textContent).toBe('•')
})
it('workflow status replaces the unsaved dot', () => {
const workflowOption = makeWorkflowOption({ isPersisted: false })
mockWorkflowStatus.value = new Map([[workflowOption.workflow, 'running']])
renderTab({ workflowOption })
expect(
screen.getByRole('img', { name: statusAriaLabels.running })
).toBeTruthy()
expect(screen.queryByTestId('workflow-dirty-indicator')).toBeNull()
})
})
describe('WorkflowTab - close button', () => {
beforeEach(() => {
mockCloseWorkflow.mockClear()
})
it('delegates close to workflow service with the tab workflow', async () => {
renderTab()
const user = userEvent.setup()
await user.click(screen.getByTestId('close-workflow-button'))
expect(mockCloseWorkflow).toHaveBeenCalledWith(
expect.objectContaining({ key: 'test-key' }),
expect.anything()
)
})
})

View File

@@ -21,8 +21,19 @@
{{ workflowOption.workflow.filename }}
</span>
<div class="relative">
<i
v-if="workflowStatus"
role="img"
:aria-label="workflowStatusLabel"
:class="
cn(
'absolute top-1/2 left-1/2 z-10 size-4 -translate-1/2 group-hover:hidden',
workflowStatusIconClasses[workflowStatus]
)
"
/>
<span
v-if="shouldShowStatusIndicator"
v-else-if="shouldShowUnsavedIndicator"
data-testid="workflow-dirty-indicator"
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
></span
@@ -32,6 +43,7 @@
variant="muted-textonly"
size="icon-sm"
:aria-label="t('g.close')"
data-testid="close-workflow-button"
@click.stop="onCloseWorkflow(workflowOption)"
>
<i class="pi pi-times" />
@@ -85,8 +97,14 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { useCommandStore } from '@/stores/commandStore'
import type { WorkflowExecutionStatus } from '@/stores/executionStore'
import {
useExecutionStore,
WORKFLOW_STATUS_I18N_KEYS
} from '@/stores/executionStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
import { cn } from '@comfyorg/tailwind-utils'
import WorkflowTabPopover from './WorkflowTabPopover.vue'
@@ -113,6 +131,7 @@ const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const executionStore = useExecutionStore()
const workflowTabRef = ref<HTMLElement | null>(null)
const popoverRef = ref<InstanceType<typeof WorkflowTabPopover> | null>(null)
const workflowThumbnail = useWorkflowThumbnail()
@@ -125,7 +144,7 @@ const autoSaveDelay = computed(() =>
settingStore.get('Comfy.Workflow.AutoSaveDelay')
)
const shouldShowStatusIndicator = computed(() => {
const shouldShowUnsavedIndicator = computed(() => {
if (workspaceStore.shiftDown) {
// Branch 1: Shift key is held down, do not show the status indicator.
return false
@@ -160,6 +179,27 @@ const isActiveTab = computed(() => {
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
})
const workflowStatusIconClasses: Record<WorkflowExecutionStatus, string> = {
running:
'text-base-foreground icon-[lucide--loader-circle] motion-safe:animate-spin',
completed: 'icon-[lucide--circle-check] text-success-background',
failed: 'icon-[lucide--octagon-alert] text-destructive-background'
}
// The active tab doesn't badge its own status - the user is already looking
// at it. Background tabs surface the recorded execution status.
const workflowStatus = computed(() =>
isActiveTab.value
? undefined
: executionStore.getWorkflowStatus(props.workflowOption.workflow)
)
const workflowStatusLabel = computed(() =>
workflowStatus.value
? t(WORKFLOW_STATUS_I18N_KEYS[workflowStatus.value])
: undefined
)
const thumbnailUrl = computed(() => {
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
})

View File

@@ -43,6 +43,10 @@ vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: { showSignInButton: false } })
}))
vi.mock('@/composables/useWorkflowStatusDismissal', () => ({
useWorkflowStatusDismissal: vi.fn()
}))
vi.mock('@/composables/element/useOverflowObserver', () => ({
useOverflowObserver: () => ({
isOverflowing: { value: false },

View File

@@ -117,6 +117,7 @@ import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useWorkflowStatusDismissal } from '@/composables/useWorkflowStatusDismissal'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
@@ -145,6 +146,9 @@ const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const commandStore = useCommandStore()
const { isLoggedIn } = useCurrentUser()
// Dismiss a tab's terminal status badge once it has been viewed
useWorkflowStatusDismissal()
const { flags } = useFeatureFlags()
const isIntegratedTabBar = computed(

View File

@@ -0,0 +1,110 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import CreditSlider from './CreditSlider.vue'
const meta: Meta<typeof CreditSlider> = {
title: 'Components/CreditSlider',
component: CreditSlider,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
disabled: { control: 'boolean' }
},
args: {
disabled: false
},
decorators: [
(story) => ({
components: { story },
// Previews at the real layout width: the Figma "Team Plan" card column is
// 512px wide with 32px padding (DES-197), i.e. a 448px content area — the
// width the slider actually renders into inside PricingTableWorkspace.
template: '<div class="w-[512px] px-8"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { CreditSlider },
setup() {
const value = ref(700)
return { args, value }
},
template: '<CreditSlider v-model="value" :disabled="args.disabled" />'
})
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { CreditSlider },
setup() {
const value = ref(700)
return { args, value }
},
template: '<CreditSlider v-model="value" :disabled="args.disabled" />'
})
}
// Sample `GET /api/billing/plans → team_credit_stops` payload (DES-197 yearly).
// In production this comes from the API; here it shows the stops being driven
// entirely through props rather than the hardcoded default constant.
const apiTeamCreditStops = {
default_stop_index: 2,
stops: [
{
id: 'team_200',
credits: 42_200,
yearly: { price_cents: 20_000, discount_percent: 0 }
},
{
id: 'team_400',
credits: 84_400,
yearly: { price_cents: 38_000, discount_percent: 5 }
},
{
id: 'team_700',
credits: 147_700,
yearly: { price_cents: 63_000, discount_percent: 10 }
},
{
id: 'team_1400',
credits: 295_400,
yearly: { price_cents: 119_000, discount_percent: 15 }
},
{
id: 'team_2500',
credits: 527_500,
yearly: { price_cents: 200_000, discount_percent: 20 }
}
]
}
// Reference adapter (FE-934 will own this in the data layer): API → CreditStop[].
// The pre-discount list price is recovered as discounted / (1 - discount).
const mappedStops = apiTeamCreditStops.stops.map((s) => ({
credits: s.credits,
discountPercentYearly: s.yearly.discount_percent,
usd: Math.round(
s.yearly.price_cents / 100 / (1 - s.yearly.discount_percent / 100)
)
}))
export const BackendDrivenStops: Story = {
name: 'Backend-driven stops (props)',
render: (args) => ({
components: { CreditSlider },
setup() {
const defaultStopIndex = apiTeamCreditStops.default_stop_index
const value = ref(mappedStops[defaultStopIndex].usd)
return { args, value, mappedStops, defaultStopIndex }
},
template:
'<CreditSlider v-model="value" :stops="mappedStops" :default-stop-index="defaultStopIndex" :disabled="args.disabled" />'
})
}

View File

@@ -0,0 +1,208 @@
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { usdToCredits } from '@/base/credits/comfyCredits'
import { TEAM_PLAN_CREDIT_STOPS } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import CreditSlider from './CreditSlider.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subscription: {
usdPerMonth: 'USD / mo',
billedYearly: '{total} Billed yearly',
billedMonthly: 'Billed monthly',
creditSliderSave: 'Save {percent}% ({amount})'
}
}
}
})
function renderSlider(props: Record<string, unknown> = {}) {
return render(CreditSlider, { props, global: { plugins: [i18n] } })
}
async function flush() {
await nextTick()
await nextTick()
}
describe('CreditSlider', () => {
it('defaults to the $700 stop (index 2) when no value is bound', async () => {
renderSlider()
await flush()
const thumb = screen.getByRole('slider')
expect(thumb).toHaveAttribute('aria-valuemin', '0')
expect(thumb).toHaveAttribute('aria-valuemax', '4')
expect(thumb).toHaveAttribute('aria-valuenow', '2')
})
it('snaps to the next fixed stop on ArrowRight (never a value in between)', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(usd: number) => void>()
renderSlider({ modelValue: 700, 'onUpdate:modelValue': onUpdate })
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onUpdate).toHaveBeenCalledWith(1400)
})
it('snaps to the previous fixed stop on ArrowLeft', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(usd: number) => void>()
renderSlider({ modelValue: 700, 'onUpdate:modelValue': onUpdate })
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowLeft}')
expect(onUpdate).toHaveBeenCalledWith(400)
})
it('emits change with the full {index, usd, credits} payload', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
renderSlider({ modelValue: 700, onChange })
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onChange).toHaveBeenCalledWith({
index: 3,
usd: 1400,
credits: 295_400
})
})
it('emits nothing when disabled (keyboard interaction suppressed)', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(usd: number) => void>()
const onChange = vi.fn()
renderSlider({
modelValue: 700,
disabled: true,
'onUpdate:modelValue': onUpdate,
onChange
})
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onUpdate).not.toHaveBeenCalled()
expect(onChange).not.toHaveBeenCalled()
})
it('shows the discounted price, struck original, save badge and yearly total (DES-197)', async () => {
renderSlider() // default $700 stop → 10% yearly discount
await flush()
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$630')
expect(
screen.getByTestId('credit-slider-original-price')
).toHaveTextContent('$700')
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
'Save 10% ($70)'
)
expect(screen.getByTestId('credit-slider-billed-yearly')).toHaveTextContent(
'$7,560'
)
})
it('halves the discount and reads "billed monthly" when cycle=monthly (PRD)', async () => {
renderSlider({ cycle: 'monthly' }) // default $700 stop → 10% yearly → 5% monthly
await flush()
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$665')
expect(
screen.getByTestId('credit-slider-original-price')
).toHaveTextContent('$700')
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
'Save 5% ($35)'
)
expect(screen.getByTestId('credit-slider-billed-yearly')).toHaveTextContent(
'Billed monthly'
)
})
it('applies the fractional monthly discount at $400 (2.5%)', async () => {
renderSlider({ modelValue: 400, cycle: 'monthly' })
await flush()
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$390')
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
'Save 2.5% ($10)'
)
})
it('hides the discount UI at the 0% stop ($200)', async () => {
renderSlider({ modelValue: 200 })
await flush()
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$200')
expect(
screen.queryByTestId('credit-slider-original-price')
).not.toBeInTheDocument()
expect(screen.queryByTestId('credit-slider-save')).not.toBeInTheDocument()
})
it('renders all five fixed credit stop labels', async () => {
renderSlider({ modelValue: 700 })
await flush()
const stops = within(screen.getByTestId('credit-slider-stops'))
for (const label of ['42.2K', '84.4K', '147.7K', '295.4K', '527.5K']) {
expect(stops.getByText(label)).toBeInTheDocument()
}
})
it('renders stops + default index supplied via props (BE-sourced override)', async () => {
const stops = [
{ usd: 50, credits: 10_550, discountPercentYearly: 0 },
{ usd: 100, credits: 21_100, discountPercentYearly: 25 }
]
// No modelValue → the model default ($700) matches no stop, so selectedIndex
// falls back to defaultStopIndex (here index 1 → $100).
renderSlider({ stops, defaultStopIndex: 1 })
await flush()
const thumb = screen.getByRole('slider')
expect(thumb).toHaveAttribute('aria-valuemax', '1') // 2 stops → max index 1
expect(thumb).toHaveAttribute('aria-valuenow', '1') // default index honored
// index 1 → $100 at 25% yearly → $75 discounted, struck $100, save $25
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$75')
expect(
screen.getByTestId('credit-slider-original-price')
).toHaveTextContent('$100')
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
'Save 25% ($25)'
)
// Only the prop's labels render — none of the DES-197 defaults.
const labels = within(screen.getByTestId('credit-slider-stops'))
expect(labels.getByText('10.6K')).toBeInTheDocument()
expect(labels.getByText('21.1K')).toBeInTheDocument()
expect(labels.queryByText('147.7K')).not.toBeInTheDocument()
})
it('keeps every credit amount equal to usdToCredits(usd) (guards rate drift)', () => {
for (const stop of TEAM_PLAN_CREDIT_STOPS) {
expect(stop.credits).toBe(usdToCredits(stop.usd))
}
})
})

View File

@@ -0,0 +1,235 @@
<script setup lang="ts">
import {
TransitionPresets,
usePreferredReducedMotion,
useTransition
} from '@vueuse/core'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Slider from '@/components/ui/slider/Slider.vue'
import {
DEFAULT_TEAM_PLAN_STOP_INDEX,
TEAM_PLAN_CREDIT_STOPS
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import type { CreditStop } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
const {
disabled = false,
class: rootClass,
stops = TEAM_PLAN_CREDIT_STOPS,
defaultStopIndex = DEFAULT_TEAM_PLAN_STOP_INDEX,
cycle = 'yearly'
} = defineProps<{
disabled?: boolean
class?: HTMLAttributes['class']
/**
* The fixed credit stops the slider snaps to. Must be non-empty. Defaults to
* the hardcoded DES-197 set; pass the backend-sourced stops once the contract
* lands — map `GET /api/billing/plans → team_credit_stops.stops` to
* `CreditStop[]` (credits, the pre-discount `usd`, and `discountPercentYearly`).
*/
stops?: readonly CreditStop[]
/**
* Stop selected when the bound value matches none (e.g. first render).
* Maps to `team_credit_stops.default_stop_index`. Defaults to DES-197 ($700).
*/
defaultStopIndex?: number
/**
* Billing cycle. Yearly applies the full `discountPercentYearly`; monthly
* applies half of it (PRD: GA Team Billing — "for monthly the discount is
* halved": yearly 0/5/10/15/20% → monthly 0/2.5/5/7.5/10%).
*/
cycle?: 'monthly' | 'yearly'
}>()
const emit = defineEmits<{
/** Fired when the selected stop changes, with the full derived payload. */
change: [stop: { index: number; usd: number; credits: number }]
}>()
/**
* v-model carries the selected USD value (one of the `stops`). The literal
* default keeps `defineModel` statically analyzable; when custom `stops` are
* passed without a matching v-model, `selectedIndex` falls back to
* `defaultStopIndex`, so the displayed stop is still correct.
*/
const usd = defineModel<number>({
default: TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX].usd
})
const selectedIndex = computed(() => {
const i = stops.findIndex((stop) => stop.usd === usd.value)
if (i !== -1) return i
// Fall back to the default stop, clamped into range: a backend-driven `stops`
// array can be shorter than expected (or `defaultStopIndex` out of bounds), so
// clamping keeps `current` defined and the price computeds below from reading
// `undefined.usd` at runtime. (`stops` is required to be non-empty.)
return Math.min(Math.max(defaultStopIndex, 0), Math.max(stops.length - 1, 0))
})
const current = computed<CreditStop>(() => stops[selectedIndex.value])
// The discount applies to the monthly figure. Yearly uses the full
// `discountPercentYearly`; monthly halves it (PRD: GA Team Billing). The card
// shows the discounted monthly price, the struck pre-discount price, the
// saving, and — for yearly — the annual total.
const effectiveDiscountPercent = computed(() =>
cycle === 'monthly'
? current.value.discountPercentYearly / 2
: current.value.discountPercentYearly
)
const discountedMonthly = computed(() =>
Math.round(current.value.usd * (1 - effectiveDiscountPercent.value / 100))
)
const saveAmount = computed(() => current.value.usd - discountedMonthly.value)
const hasDiscount = computed(() => effectiveDiscountPercent.value > 0)
/**
* Smoothly count the price figures up/down as the slider moves between stops
* instead of snapping. Honors the user's reduced-motion preference. The save
* badge ("X% ($Y)") is intentionally left snapping — its percent is a discrete
* tier, so animating the bracketed amount alone would read inconsistently.
*/
const prefersReducedMotion = usePreferredReducedMotion()
const priceTween = {
duration: 350,
easing: TransitionPresets.easeOutCubic,
disabled: computed(() => prefersReducedMotion.value === 'reduce')
}
const animatedMonthly = useTransition(discountedMonthly, priceTween)
const animatedOriginal = useTransition(() => current.value.usd, priceTween)
const displayMonthly = computed(() => Math.round(animatedMonthly.value))
const displayOriginal = computed(() => Math.round(animatedOriginal.value))
// Derive the yearly total from the displayed monthly so it always reads as
// exactly 12× the price shown — even mid-count — rather than drifting as a
// second, independently-phased tween would.
const displayBilledYearly = computed(() => displayMonthly.value * 12)
/**
* Bridge the discrete stop index (0..n-1) to the reka-ui slider's `number[]`
* model. Driving the slider in index space with `step = 1` guarantees the
* thumb can only land on the fixed stops — never a value in between.
*/
const sliderModel = computed<number[]>({
get: () => [selectedIndex.value],
set: ([index]) => {
const stop = stops[index]
if (!stop) return
usd.value = stop.usd
emit('change', { index, usd: stop.usd, credits: stop.credits })
}
})
const lastIndex = computed(() => Math.max(stops.length - 1, 0))
const formatUsd = (value: number) => `$${value.toLocaleString('en-US')}`
const formatCreditsCompact = (value: number) =>
new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1
}).format(value)
const { t } = useI18n()
</script>
<template>
<div :class="cn('flex w-full flex-col gap-3', rootClass)">
<!-- Price: discounted monthly + struck pre-discount + save badge -->
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span class="flex shrink-0 items-baseline gap-1.5 whitespace-nowrap">
<span
class="text-[2rem]/none font-semibold text-base-foreground tabular-nums"
data-testid="credit-slider-price"
>
{{ formatUsd(displayMonthly) }}
</span>
<span
v-if="hasDiscount"
class="text-base text-muted-foreground tabular-nums line-through"
data-testid="credit-slider-original-price"
>
{{ formatUsd(displayOriginal) }}
</span>
<span class="text-base text-muted-foreground">
{{ t('subscription.usdPerMonth') }}
</span>
</span>
<!-- Save badge: outlined primary pill. On wide layouts it's pushed to
the right of the price; when the column narrows (mobile) it wraps
and aligns left under the price instead (DES QA). -->
<span
v-if="hasDiscount"
data-testid="credit-slider-save"
class="shrink-0 rounded-full border-2 border-primary-background px-2 py-1 text-sm font-bold whitespace-nowrap text-primary-background xl:ms-auto"
>
{{
t('subscription.creditSliderSave', {
percent: effectiveDiscountPercent,
amount: formatUsd(saveAmount)
})
}}
</span>
</div>
<p
class="m-0 text-sm text-muted-foreground tabular-nums"
data-testid="credit-slider-billed-yearly"
>
{{
cycle === 'monthly'
? t('subscription.billedMonthly')
: t('subscription.billedYearly', {
total: formatUsd(displayBilledYearly)
})
}}
</p>
</div>
<!-- Discrete slider: snaps to the 5 fixed DES-197 stops -->
<Slider
v-model="sliderModel"
:min="0"
:max="lastIndex"
:step="1"
:disabled="disabled"
range-class="bg-base-foreground"
thumb-class="bg-base-foreground"
/>
<!-- Credit stop labels; the selected stop is emphasized -->
<ol
data-testid="credit-slider-stops"
class="m-0 flex list-none justify-between p-0"
>
<li
v-for="(stop, i) in stops"
:key="stop.usd"
:data-selected="i === selectedIndex ? '' : undefined"
:class="
cn(
'flex items-center gap-1 text-xs tabular-nums',
i === selectedIndex
? 'font-semibold text-base-foreground'
: 'text-muted-foreground'
)
"
>
<i
:class="
cn(
'icon-[comfy--credits] size-3 shrink-0',
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
)
"
aria-hidden="true"
/>
{{ formatCreditsCompact(stop.credits) }}
</li>
</ol>
</div>
</template>

View File

@@ -15,7 +15,11 @@ import { cn } from '@comfyorg/tailwind-utils'
const props = defineProps<
// eslint-disable-next-line vue/no-unused-properties
SliderRootProps & { class?: HTMLAttributes['class'] }
SliderRootProps & {
class?: HTMLAttributes['class']
rangeClass?: HTMLAttributes['class']
thumbClass?: HTMLAttributes['class']
}
>()
const pressed = ref(false)
@@ -25,7 +29,7 @@ const setPressed = (val: boolean) => {
const emits = defineEmits<SliderRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, 'class', 'rangeClass', 'thumbClass')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
@@ -60,7 +64,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
>
<SliderRange
data-slot="slider-range"
class="absolute bg-node-component-surface-highlight data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
:class="
cn(
'absolute bg-node-component-surface-highlight data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full',
props.rangeClass
)
"
/>
</SliderTrack>
@@ -74,7 +83,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
'cursor-grab',
'before:absolute before:-inset-1 before:block before:rounded-full before:bg-transparent',
'hover:ring-2 focus-visible:ring-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
{ 'cursor-grabbing': pressed }
{ 'cursor-grabbing': pressed },
props.thumbClass
)
"
/>

View File

@@ -225,6 +225,40 @@ describe('useAuthActions.reportError', () => {
expect(mockToastErrorHandler).not.toHaveBeenCalled()
})
it('shows the signupBlocked message when the error carries the signup_blocked token', () => {
const { reportError } = useAuthActions()
// The backend wraps the rejection in a generic code; we match the token in
// the message, so it must win over the auth.errors.${code} fallback.
reportError(
new FirebaseError(
'auth/internal-error',
'Account creation is temporarily unavailable. (ref: signup_blocked)'
)
)
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'auth.errors.signupBlocked'
})
expect(mockToastErrorHandler).not.toHaveBeenCalled()
})
it('matches the signup_blocked token case-insensitively', () => {
const { reportError } = useAuthActions()
reportError(
new FirebaseError('auth/internal-error', 'rejected: SIGNUP_BLOCKED')
)
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'auth.errors.signupBlocked'
})
})
it('shows the generic fallback for an unknown Firebase auth code', () => {
const { reportError } = useAuthActions()

View File

@@ -47,6 +47,19 @@ export const useAuthActions = () => {
email: 'support@comfy.org'
})
})
} else if (
error instanceof FirebaseError &&
error.message.toLowerCase().includes('signup_blocked')
) {
// Match on `error.message`, not `error.code`: Firebase `beforeUserCreated`
// rejections collapse the thrown code into a generic `auth/internal-error`,
// so the message is the only reliable channel. `signup_blocked` is a
// cross-repo contract token; matched case-insensitively.
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('auth.errors.signupBlocked')
})
} else if (error instanceof FirebaseError) {
toastStore.add({
severity: 'error',

View File

@@ -5,11 +5,13 @@ import type {
BillingStatus,
BillingSubscriptionStatus,
CreateTopupResponse,
CurrentTeamCreditStop,
Plan,
PreviewSubscribeResponse,
SubscribeResponse,
SubscriptionDuration,
SubscriptionTier
SubscriptionTier,
TeamCreditStops
} from '@/platform/workspace/api/workspaceApi'
export type BillingType = 'legacy' | 'workspace'
@@ -71,6 +73,10 @@ export interface BillingState {
balance: ComputedRef<BalanceInfo | null>
plans: ComputedRef<Plan[]>
currentPlanSlug: ComputedRef<string | null>
/** Team per-credit pricing ladder; null for personal/legacy. */
teamCreditStops: ComputedRef<TeamCreditStops | null>
/** The team's currently-subscribed credit stop; null for personal/legacy. */
currentTeamCreditStop: ComputedRef<CurrentTeamCreditStop | null>
isLoading: Ref<boolean>
error: Ref<string | null>
isActiveSubscription: ComputedRef<boolean>
@@ -83,5 +89,10 @@ export interface BillingState {
export interface BillingContext extends BillingState, BillingActions {
type: ComputedRef<BillingType>
/**
* True when the active team workspace is still on a pre-credit-slider
* (legacy) per-member tier plan, which keeps the old team pricing table.
*/
isLegacyTeamPlan: ComputedRef<boolean>
getMaxSeats: (tierKey: TierKey) => number
}

View File

@@ -1,20 +1,39 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Plan } from '@/platform/workspace/api/workspaceApi'
import type {
BillingStatusResponse,
Plan
} from '@/platform/workspace/api/workspaceApi'
import { useBillingContext } from './useBillingContext'
const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY'
}
const {
mockTeamWorkspacesEnabled,
mockIsPersonal,
mockPlans,
mockPurchaseCredits
mockPurchaseCredits,
mockBillingStatus
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn()
mockPurchaseCredits: vi.fn(),
mockBillingStatus: {
value: {
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY'
} as BillingStatusResponse
}
}))
vi.mock('@vueuse/core', async (importOriginal) => {
@@ -103,12 +122,7 @@ vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => ({
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
getBillingStatus: vi.fn().mockResolvedValue({
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY'
}),
getBillingStatus: vi.fn(() => Promise.resolve(mockBillingStatus.value)),
getBillingBalance: vi.fn().mockResolvedValue({
amount_micros: 10000000,
currency: 'usd'
@@ -125,6 +139,7 @@ describe('useBillingContext', () => {
mockTeamWorkspacesEnabled.value = false
mockIsPersonal.value = true
mockPlans.value = []
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
})
it('returns legacy type for personal workspace', () => {
@@ -252,4 +267,158 @@ describe('useBillingContext', () => {
expect(getMaxSeats('creator')).toBe(5)
})
})
describe('isLegacyTeamPlan', () => {
it('is false for a personal workspace', () => {
const { isLegacyTeamPlan } = useBillingContext()
expect(isLegacyTeamPlan.value).toBe(false)
})
it('is true for an active team plan: team- slug and no credit stop', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'STANDARD',
subscription_duration: 'ANNUAL',
plan_slug: 'team-standard-annual'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(true)
})
it('is true for any legacy team tier, not just standard', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
subscription_duration: 'ANNUAL',
plan_slug: 'team-pro-annual'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(true)
})
it('is false for a new credit-slider team subscriber', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
// Real BE shape: underscore slug + populated credit stop. (subscription_tier
// is 'TEAM' on the wire, not yet in the FE SubscriptionTier union, so it is
// omitted here — the predicate does not depend on it.)
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_status: 'active',
subscription_duration: 'ANNUAL',
plan_slug: 'team_per_credit_annual',
team_credit_stop: {
id: 'team_700',
credits_monthly: 147700,
stop_usd: 700
}
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(false)
})
it('is false for a new team sub even before its credit stop is populated', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
// Provisioning lag: credit stop not yet attached. The underscore slug
// (team_per_credit, not team-) must still exclude it from the legacy table.
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_status: 'active',
subscription_duration: 'ANNUAL',
plan_slug: 'team_per_credit_annual'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(false)
})
it('is false for a team workspace on a personal-tier plan', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'STANDARD',
subscription_duration: 'ANNUAL',
plan_slug: 'standard-annual'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(false)
})
it('stays true for a cancelled-but-still-active legacy team sub', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_status: 'canceled',
subscription_tier: 'STANDARD',
subscription_duration: 'ANNUAL',
plan_slug: 'team-standard-annual',
cancel_at: '2099-01-01T00:00:00Z'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(true)
})
it('is false for a FREE-tier team even on a team- prefixed slug', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'FREE',
plan_slug: 'team-free'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(false)
})
it('matches the legacy slug case-insensitively', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'STANDARD',
subscription_duration: 'ANNUAL',
plan_slug: 'Team-Standard-Annual'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(true)
})
})
})

View File

@@ -20,6 +20,12 @@ import type {
import { useLegacyBilling } from './useLegacyBilling'
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
// Legacy per-member team plans use a hyphenated `team-{tier}-{cycle}` slug; the
// new credit-slider plan uses an underscore `team_per_credit_{cycle}` slug and
// carries a team_credit_stop. The hyphen prefix alone separates the two, so a
// new sub is never misrouted even before its credit stop is populated.
const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
/**
* Unified billing context that automatically switches between legacy (user-scoped)
* and workspace billing based on the active workspace type.
@@ -116,12 +122,32 @@ function useBillingContextInternal(): BillingContext {
toValue(activeContext.value.currentPlanSlug)
)
const teamCreditStops = computed(() =>
toValue(activeContext.value.teamCreditStops)
)
const currentTeamCreditStop = computed(() =>
toValue(activeContext.value.currentTeamCreditStop)
)
const isActiveSubscription = computed(() =>
toValue(activeContext.value.isActiveSubscription)
)
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
const isLegacyTeamPlan = computed(
() =>
type.value === 'workspace' &&
isActiveSubscription.value &&
!isFreeTier.value &&
currentTeamCreditStop.value === null &&
(currentPlanSlug.value
?.toLowerCase()
.startsWith(LEGACY_TEAM_PLAN_SLUG_PREFIX) ??
false)
)
const billingStatus = computed(() =>
toValue(activeContext.value.billingStatus)
)
@@ -254,10 +280,13 @@ function useBillingContextInternal(): BillingContext {
balance,
plans,
currentPlanSlug,
teamCreditStops,
currentTeamCreditStop,
isLoading,
error,
isActiveSubscription,
isFreeTier,
isLegacyTeamPlan,
billingStatus,
subscriptionStatus,
tier,

View File

@@ -93,6 +93,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
// Legacy billing doesn't have workspace-style plans
const plans = computed(() => [])
const currentPlanSlug = computed(() => null)
const teamCreditStops = computed(() => null)
const currentTeamCreditStop = computed(() => null)
async function initialize(): Promise<void> {
if (isInitialized.value) return
@@ -200,6 +202,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
balance,
plans,
currentPlanSlug,
teamCreditStops,
currentTeamCreditStop,
isLoading,
error,
isActiveSubscription,

View File

@@ -1,4 +1,4 @@
import type { CSSProperties } from 'vue'
import type { CSSProperties, Ref } from 'vue'
import { ref, watch } from 'vue'
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
@@ -15,7 +15,14 @@ export interface PositionConfig {
scale?: number
}
export function useAbsolutePosition(options: { useTransform?: boolean } = {}) {
interface UseAbsolutePositionReturn {
style: Ref<CSSProperties>
updatePosition: (config: PositionConfig) => void
}
export function useAbsolutePosition(
options: { useTransform?: boolean } = {}
): UseAbsolutePositionReturn {
const { useTransform = false } = options
const canvasStore = useCanvasStore()

View File

@@ -1,4 +1,4 @@
import type { CSSProperties } from 'vue'
import type { CSSProperties, Ref } from 'vue'
import { ref } from 'vue'
interface Rect {
@@ -28,7 +28,26 @@ interface ClippingOptions {
margin?: number
}
export const useDomClipping = (options: ClippingOptions = {}) => {
interface UseDomClippingReturn {
style: Ref<CSSProperties>
updateClipPath: (
element: HTMLElement,
canvasElement: HTMLCanvasElement,
isSelected: boolean,
selectedArea?: {
x: number
y: number
width: number
height: number
scale: number
offset: [number, number]
}
) => void
}
export function useDomClipping(
options: ClippingOptions = {}
): UseDomClippingReturn {
const style = ref<CSSProperties>({})
const { margin = 4 } = options

View File

@@ -0,0 +1,184 @@
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useGroupContextMenu } from '@/composables/graph/useGroupContextMenu'
import type {
CanvasPointerEvent,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
const {
mockShowNodeOptions,
mockUpdateSelectedItems,
mockGetCanvasContextMenuTarget
} = vi.hoisted(() => ({
mockShowNodeOptions: vi.fn(),
mockUpdateSelectedItems: vi.fn(),
mockGetCanvasContextMenuTarget: vi.fn<
() => { reroute?: unknown; group?: unknown }
>(() => ({}))
}))
vi.mock('@/composables/graph/useMoreOptionsMenu', () => ({
showNodeOptions: mockShowNodeOptions
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ updateSelectedItems: mockUpdateSelectedItems })
}))
vi.mock('@/lib/litegraph/src/canvas/getCanvasContextMenuTarget', () => ({
getCanvasContextMenuTarget: mockGetCanvasContextMenuTarget
}))
interface StubCanvas {
graph: object
deselectAll: ReturnType<typeof vi.fn>
selectedItems: Set<unknown>
state: { selectionChanged: boolean }
}
describe('useGroupContextMenu', () => {
const event = fromPartial<CanvasPointerEvent>({ canvasX: 10, canvasY: 20 })
let group: {
id: number
selected?: boolean
recomputeInsideNodes: ReturnType<typeof vi.fn>
}
let legacyMenuMock: ReturnType<typeof vi.fn>
let stubCanvas: StubCanvas
beforeEach(() => {
vi.clearAllMocks()
LiteGraph.vueNodesMode = true
group = { id: 1, recomputeInsideNodes: vi.fn() }
mockGetCanvasContextMenuTarget.mockReturnValue({ group })
legacyMenuMock = vi.fn()
LGraphCanvas.prototype.processContextMenu = fromAny(legacyMenuMock)
useGroupContextMenu()
stubCanvas = {
graph: {},
deselectAll: vi.fn(),
selectedItems: new Set(),
state: { selectionChanged: false }
}
stubCanvas.deselectAll.mockImplementation(() => {
stubCanvas.selectedItems.clear()
})
})
function invoke(node: LGraphNode | undefined) {
LGraphCanvas.prototype.processContextMenu.call(
fromAny(stubCanvas),
node,
event
)
}
it('opens the Vue menu and selects only the group in Nodes 2.0 mode', () => {
invoke(undefined)
expect(stubCanvas.deselectAll).toHaveBeenCalledOnce()
expect(group.selected).toBe(true)
expect(stubCanvas.selectedItems.has(group)).toBe(true)
expect(stubCanvas.state.selectionChanged).toBe(true)
expect(group.recomputeInsideNodes).toHaveBeenCalledOnce()
expect(mockUpdateSelectedItems).toHaveBeenCalledOnce()
expect(mockShowNodeOptions).toHaveBeenCalledWith(event)
expect(mockUpdateSelectedItems.mock.invocationCallOrder[0]).toBeLessThan(
mockShowNodeOptions.mock.invocationCallOrder[0]
)
expect(legacyMenuMock).not.toHaveBeenCalled()
})
it('falls through to the legacy menu when a node is under the cursor', () => {
invoke(fromPartial<LGraphNode>({}))
expect(mockGetCanvasContextMenuTarget).not.toHaveBeenCalled()
expect(legacyMenuMock).toHaveBeenCalledOnce()
expect(mockShowNodeOptions).not.toHaveBeenCalled()
})
it('falls through to the legacy menu in legacy (non-Nodes 2.0) mode', () => {
LiteGraph.vueNodesMode = false
invoke(undefined)
expect(mockGetCanvasContextMenuTarget).not.toHaveBeenCalled()
expect(legacyMenuMock).toHaveBeenCalledOnce()
expect(mockShowNodeOptions).not.toHaveBeenCalled()
})
it('falls through to the legacy menu when no group is under the cursor', () => {
mockGetCanvasContextMenuTarget.mockReturnValue({})
invoke(undefined)
expect(legacyMenuMock).toHaveBeenCalledOnce()
expect(mockShowNodeOptions).not.toHaveBeenCalled()
expect(stubCanvas.selectedItems.size).toBe(0)
})
it('falls through to the legacy menu when the cursor is on a reroute', () => {
mockGetCanvasContextMenuTarget.mockReturnValue({
reroute: { id: 5 },
group
})
invoke(undefined)
expect(legacyMenuMock).toHaveBeenCalledOnce()
expect(mockShowNodeOptions).not.toHaveBeenCalled()
expect(stubCanvas.selectedItems.size).toBe(0)
})
it('keeps the menu open without re-selecting when only the group is selected', () => {
group.selected = true
stubCanvas.selectedItems.add(group)
invoke(undefined)
expect(stubCanvas.deselectAll).not.toHaveBeenCalled()
expect(stubCanvas.selectedItems.size).toBe(1)
expect(stubCanvas.selectedItems.has(group)).toBe(true)
expect(stubCanvas.state.selectionChanged).toBe(false)
expect(group.recomputeInsideNodes).not.toHaveBeenCalled()
expect(mockUpdateSelectedItems).toHaveBeenCalledOnce()
expect(mockShowNodeOptions).toHaveBeenCalledWith(event)
expect(legacyMenuMock).not.toHaveBeenCalled()
})
it('reselects the group when selected child nodes would hide group actions', () => {
const childNode = { selected: true }
group.selected = true
stubCanvas.selectedItems.add(group)
stubCanvas.selectedItems.add(childNode)
invoke(undefined)
expect(stubCanvas.deselectAll).toHaveBeenCalledOnce()
expect(stubCanvas.selectedItems.size).toBe(1)
expect(stubCanvas.selectedItems.has(group)).toBe(true)
expect(stubCanvas.state.selectionChanged).toBe(true)
expect(group.recomputeInsideNodes).toHaveBeenCalledOnce()
expect(mockUpdateSelectedItems).toHaveBeenCalledOnce()
expect(mockShowNodeOptions).toHaveBeenCalledWith(event)
expect(legacyMenuMock).not.toHaveBeenCalled()
})
it('falls through to the legacy menu when the canvas has no graph', () => {
LGraphCanvas.prototype.processContextMenu.call(
fromAny({ deselectAll: vi.fn() }),
undefined,
event
)
expect(mockGetCanvasContextMenuTarget).not.toHaveBeenCalled()
expect(legacyMenuMock).toHaveBeenCalledOnce()
expect(mockShowNodeOptions).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,49 @@
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { getCanvasContextMenuTarget } from '@/lib/litegraph/src/canvas/getCanvasContextMenuTarget'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
/**
* Routes Nodes 2.0 group right-clicks to Vue while nodes, reroutes,
* background, and legacy mode stay on litegraph.
*/
export function useGroupContextMenu() {
const original = LGraphCanvas.prototype.processContextMenu
function processContextMenuWithVueGroupMenu(
this: LGraphCanvas,
...args: Parameters<typeof original>
): void {
const [node, event] = args
if (node || !LiteGraph.vueNodesMode || !this.graph) {
original.apply(this, args)
return
}
const { reroute, group } = getCanvasContextMenuTarget(
this,
event.canvasX,
event.canvasY
)
if (reroute || !group) {
original.apply(this, args)
return
}
const groupIsOnlySelection =
this.selectedItems.size === 1 && this.selectedItems.has(group)
if (!groupIsOnlySelection) {
this.deselectAll()
group.selected = true
group.recomputeInsideNodes()
this.selectedItems.add(group)
this.state.selectionChanged = true
}
useCanvasStore().updateSelectedItems()
showNodeOptions(event)
}
LGraphCanvas.prototype.processContextMenu = processContextMenuWithVueGroupMenu
}

View File

@@ -181,4 +181,24 @@ describe('useMaskEditorSaver', () => {
expect(store.nodeOutputs[locatorId]).toBeDefined()
expect(store.nodeOutputs[locatorId]?.images?.length).toBeGreaterThan(0)
})
it('omits subfolder from the upload FormData under the unified contract', async () => {
const fetchApiMock = vi.mocked(api.fetchApi)
const { save } = useMaskEditorSaver()
await save()
// The unified contract uploads to /upload/image with only image + type;
// subfolder is intentionally omitted (the server assigns it). Assert it
// here so the next reader knows the omission is deliberate, not accidental.
expect(fetchApiMock).toHaveBeenCalledWith(
'/upload/image',
expect.objectContaining({ method: 'POST' })
)
const [, init] = fetchApiMock.mock.calls[0]
const body = init?.body as FormData
expect(body).toBeInstanceOf(FormData)
expect(body.get('type')).toBe('input')
expect(body.get('subfolder')).toBeNull()
})
})

View File

@@ -1,3 +1,5 @@
import type { UploadImageResponse } from '@comfyorg/ingest-types'
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
@@ -6,7 +8,6 @@ import type {
EditorOutputLayer,
ImageRef
} from '@/stores/maskEditorDataStore'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
@@ -209,18 +210,11 @@ export function useMaskEditorSaver() {
}
async function uploadAllLayers(outputData: EditorOutputData): Promise<void> {
const sourceRef = dataStore.inputData!.sourceRef
const actualMaskedRef = await uploadMask(outputData.maskedImage, sourceRef)
const actualPaintRef = await uploadImage(outputData.paintLayer, sourceRef)
const actualPaintedRef = await uploadImage(
outputData.paintedImage,
sourceRef
)
const actualPaintedMaskedRef = await uploadMask(
outputData.paintedMaskedImage,
actualPaintedRef
const actualMaskedRef = await uploadLayer(outputData.maskedImage)
const actualPaintRef = await uploadLayer(outputData.paintLayer)
const actualPaintedRef = await uploadLayer(outputData.paintedImage)
const actualPaintedMaskedRef = await uploadLayer(
outputData.paintedMaskedImage
)
outputData.maskedImage.ref = actualMaskedRef
@@ -229,50 +223,10 @@ export function useMaskEditorSaver() {
outputData.paintedMaskedImage.ref = actualPaintedMaskedRef
}
async function uploadMask(
layer: EditorOutputLayer,
originalRef: ImageRef
): Promise<ImageRef> {
async function uploadLayer(layer: EditorOutputLayer): Promise<ImageRef> {
const formData = new FormData()
formData.append('image', layer.blob, layer.ref.filename)
formData.append('original_ref', JSON.stringify(originalRef))
formData.append('type', 'input')
formData.append('subfolder', 'clipspace')
const response = await api.fetchApi('/upload/mask', {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error(`Failed to upload mask: ${layer.ref.filename}`)
}
try {
const data = await response.json()
if (data?.name) {
return {
filename: data.name,
subfolder: data.subfolder || layer.ref.subfolder,
type: data.type || layer.ref.type
}
}
} catch (error) {
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
}
return layer.ref
}
async function uploadImage(
layer: EditorOutputLayer,
originalRef: ImageRef
): Promise<ImageRef> {
const formData = new FormData()
formData.append('image', layer.blob, layer.ref.filename)
formData.append('original_ref', JSON.stringify(originalRef))
formData.append('type', 'input')
formData.append('subfolder', 'clipspace')
const response = await api.fetchApi('/upload/image', {
method: 'POST',
@@ -280,23 +234,35 @@ export function useMaskEditorSaver() {
})
if (!response.ok) {
throw new Error(`Failed to upload image: ${layer.ref.filename}`)
const body = await response.text().catch(() => '')
throw new Error(
`Failed to upload ${layer.ref.filename} (${response.status}${body ? `: ${body}` : ''})`
)
}
let data: UploadImageResponse
try {
const data = await response.json()
if (data?.name) {
return {
filename: data.name,
subfolder: data.subfolder || layer.ref.subfolder,
type: data.type || layer.ref.type
}
}
data = await response.json()
} catch (error) {
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
throw new Error(
`Invalid upload response for ${layer.ref.filename}: ${
error instanceof Error ? error.message : String(error)
}`,
{ cause: error }
)
}
return layer.ref
if (!data?.name) {
throw new Error(
`Upload response missing 'name' for ${layer.ref.filename}`
)
}
return {
filename: data.name,
subfolder: data.subfolder || '',
type: data.type || 'input'
}
}
async function updateNodePreview(
@@ -322,19 +288,8 @@ export function useMaskEditorSaver() {
const imageWidget = node.widgets?.find((w) => w.name === 'image')
if (imageWidget) {
// Widget value format differs between Cloud and OSS:
// - Cloud: JUST the filename (subfolder handled by backend)
// - OSS: subfolder/filename (traditional format)
let widgetValue: string
if (isCloud) {
widgetValue =
mainRef.filename + (mainRef.type ? ` [${mainRef.type}]` : '')
} else {
widgetValue =
(mainRef.subfolder ? mainRef.subfolder + '/' : '') +
mainRef.filename +
(mainRef.type ? ` [${mainRef.type}]` : '')
}
const widgetValue =
mainRef.filename + (mainRef.type ? ` [${mainRef.type}]` : '')
imageWidget.value = widgetValue

View File

@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { startModelNodeDragFromAsset } from '@/composables/node/startModelNodeDragFromAsset'
const { mockStartDrag, mockGetNodeProvider } = vi.hoisted(() => ({
mockStartDrag: vi.fn(),
mockGetNodeProvider: vi.fn()
}))
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({ startDrag: mockStartDrag })
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({ getNodeProvider: mockGetNodeProvider })
}))
function createAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-123',
name: 'sd_xl_base_1.0.safetensors',
size: 1024,
created_at: '2025-10-01T00:00:00Z',
tags: ['models', 'checkpoints'],
user_metadata: { filename: 'sd_xl_base_1.0.safetensors' },
...overrides
}
}
describe('startModelNodeDragFromAsset', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('starts a ghost drag for the resolved node carrying the widget value', () => {
const nodeDef = { name: 'CheckpointLoaderSimple' }
mockGetNodeProvider.mockReturnValue({ nodeDef, key: 'ckpt_name' })
const error = startModelNodeDragFromAsset(createAsset())
expect(error).toBeUndefined()
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
widgetValues: { ckpt_name: 'sd_xl_base_1.0.safetensors' },
source: 'sidebar_drag'
})
})
it('threads the node-add source through to the drag', () => {
const nodeDef = { name: 'CheckpointLoaderSimple' }
mockGetNodeProvider.mockReturnValue({ nodeDef, key: 'ckpt_name' })
startModelNodeDragFromAsset(createAsset(), 'asset_browser')
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
widgetValues: { ckpt_name: 'sd_xl_base_1.0.safetensors' },
source: 'asset_browser'
})
})
it('carries no widget value when the provider has no key', () => {
const nodeDef = { name: 'FL_ChatterboxVC' }
mockGetNodeProvider.mockReturnValue({ nodeDef, key: '' })
startModelNodeDragFromAsset(
createAsset({
tags: ['models', 'chatterbox/chatterbox_vc'],
user_metadata: { filename: 'chatterbox_vc_model.pt' }
})
)
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
widgetValues: undefined,
source: 'sidebar_drag'
})
})
it('returns the resolution error and does not start a drag for an invalid asset', () => {
mockGetNodeProvider.mockReturnValue(null)
const error = startModelNodeDragFromAsset(createAsset())
expect(error?.code).toBe('NO_PROVIDER')
expect(mockStartDrag).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,38 @@
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveModelNodeFromAsset } from '@/platform/assets/utils/resolveModelNodeFromAsset'
import type { ResolveModelNodeError } from '@/platform/assets/utils/resolveModelNodeFromAsset'
import type { NodeAddSource } from '@/platform/telemetry/types'
import type { ModelNodeProvider } from '@/stores/modelToNodeStore'
/**
* Arms a ghost drag for a model loader node. Providers with no widget key
* (auto-load nodes) start the drag without widget values.
*/
export function startModelLoaderDrag(
provider: ModelNodeProvider,
filename: string,
source: NodeAddSource = 'sidebar_drag'
) {
const widgetValues = provider.key ? { [provider.key]: filename } : undefined
useNodeDragToCanvas().startDrag(provider.nodeDef, { widgetValues, source })
}
/**
* Starts a ghost drag for the model loader node described by an asset. The
* node is created where the user next clicks the canvas, with the asset's
* filename written into the loader widget.
*
* @returns the resolution error when the asset cannot be mapped to a node,
* otherwise `undefined`.
*/
export function startModelNodeDragFromAsset(
asset: AssetItem,
source: NodeAddSource = 'sidebar_drag'
): ResolveModelNodeError | undefined {
const resolved = resolveModelNodeFromAsset(asset)
if (!resolved.success) return resolved.error
const { provider, filename } = resolved.value
startModelLoaderDrag(provider, filename, source)
}

View File

@@ -7,7 +7,8 @@ const {
mockAddNodeOnGraph,
mockConvertEventToCanvasOffset,
mockSelectItems,
mockCanvas
mockCanvas,
mockToastAdd
} = vi.hoisted(() => {
const mockConvertEventToCanvasOffset = vi.fn()
const mockSelectItems = vi.fn()
@@ -15,6 +16,7 @@ const {
mockAddNodeOnGraph: vi.fn(),
mockConvertEventToCanvasOffset,
mockSelectItems,
mockToastAdd: vi.fn(),
mockCanvas: {
canvas: {
getBoundingClientRect: vi.fn()
@@ -37,6 +39,12 @@ vi.mock('@/services/litegraphService', () => ({
}))
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({ add: mockToastAdd }))
}))
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
describe('useNodeDragToCanvas', () => {
let useNodeDragToCanvas: typeof UseNodeDragToCanvasType
@@ -54,8 +62,8 @@ describe('useNodeDragToCanvas', () => {
})
afterEach(() => {
const { cleanupGlobalListeners } = useNodeDragToCanvas()
cleanupGlobalListeners()
const { cancelDrag } = useNodeDragToCanvas()
cancelDrag()
vi.restoreAllMocks()
})
@@ -71,22 +79,6 @@ describe('useNodeDragToCanvas', () => {
expect(isDragging.value).toBe(true)
expect(draggedNode.value).toBe(mockNodeDef)
})
it('should set dragMode to click by default', () => {
const { dragMode, startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
expect(dragMode.value).toBe('click')
})
it('should set dragMode to native when specified', () => {
const { dragMode, startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
expect(dragMode.value).toBe('native')
})
})
describe('cancelDrag', () => {
@@ -102,30 +94,15 @@ describe('useNodeDragToCanvas', () => {
expect(isDragging.value).toBe(false)
expect(draggedNode.value).toBeNull()
})
it('should reset dragMode to click', () => {
const { dragMode, startDrag, cancelDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
expect(dragMode.value).toBe('native')
cancelDrag()
expect(dragMode.value).toBe('click')
})
})
describe('setupGlobalListeners', () => {
it('should add event listeners to document', () => {
describe('drag listener lifecycle', () => {
it('should attach document listeners on startDrag', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
const { setupGlobalListeners } = useNodeDragToCanvas()
const { startDrag } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointermove',
expect.any(Function)
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointerdown',
expect.any(Function),
@@ -142,35 +119,53 @@ describe('useNodeDragToCanvas', () => {
)
})
it('should only setup listeners once', () => {
it('should not attach drag listeners until a drag starts', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
const { setupGlobalListeners } = useNodeDragToCanvas()
useNodeDragToCanvas()
setupGlobalListeners()
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
'pointerup',
expect.any(Function),
true
)
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
'keydown',
expect.any(Function)
)
})
it('should detach document listeners on cancelDrag', () => {
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener')
const { startDrag, cancelDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
cancelDrag()
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'pointerdown',
expect.any(Function),
true
)
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'pointerup',
expect.any(Function),
true
)
})
it('should only attach listeners once across re-arms', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
const callCount = addEventListenerSpy.mock.calls.length
setupGlobalListeners()
startDrag(mockNodeDef)
expect(addEventListenerSpy.mock.calls.length).toBe(callCount)
})
})
describe('cursorPosition', () => {
it('should update on pointermove', () => {
const { cursorPosition, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const pointerEvent = new PointerEvent('pointermove', {
clientX: 100,
clientY: 200
})
document.dispatchEvent(pointerEvent)
expect(cursorPosition.value).toEqual({ x: 100, y: 200 })
})
})
describe('endDrag behavior', () => {
it('should add node when pointer is over canvas', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
@@ -181,9 +176,7 @@ describe('useNodeDragToCanvas', () => {
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
const pointerEvent = new PointerEvent('pointerup', {
@@ -206,10 +199,7 @@ describe('useNodeDragToCanvas', () => {
bottom: 500
})
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef)
const pointerEvent = new PointerEvent('pointerup', {
@@ -224,10 +214,7 @@ describe('useNodeDragToCanvas', () => {
})
it('should cancel drag on Escape key', () => {
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef)
expect(isDragging.value).toBe(true)
@@ -239,10 +226,7 @@ describe('useNodeDragToCanvas', () => {
})
it('should not cancel drag on other keys', () => {
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef)
const keyEvent = new KeyboardEvent('keydown', { key: 'Enter' })
@@ -262,8 +246,7 @@ describe('useNodeDragToCanvas', () => {
const placedNode = { id: 1 }
mockAddNodeOnGraph.mockReturnValue(placedNode)
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
document.dispatchEvent(
@@ -277,6 +260,102 @@ describe('useNodeDragToCanvas', () => {
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
})
it('should apply the requested widget values to the placed node', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const widget = { name: 'ckpt_name', value: '' }
mockAddNodeOnGraph.mockReturnValue({ id: 1, widgets: [widget] })
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, {
widgetValues: { ckpt_name: 'model.safetensors' }
})
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
expect(widget.value).toBe('model.safetensors')
})
it('should warn but still place the node when a requested widget is missing', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const placedNode = { id: 1, widgets: [] }
mockAddNodeOnGraph.mockReturnValue(placedNode)
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, {
widgetValues: { ckpt_name: 'model.safetensors' }
})
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'warn',
detail: 'assetBrowser.failedToSetModelValue'
})
)
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('ckpt_name')
)
})
it('should show an error toast when the graph fails to add the node', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
mockAddNodeOnGraph.mockReturnValue(null)
vi.spyOn(console, 'error').mockImplementation(() => {})
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: 'assetBrowser.failedToCreateNode'
})
)
})
it('should not call selectItems when graph returns no node', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
@@ -286,9 +365,9 @@ describe('useNodeDragToCanvas', () => {
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
mockAddNodeOnGraph.mockReturnValue(null)
vi.spyOn(console, 'error').mockImplementation(() => {})
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
document.dispatchEvent(
@@ -311,11 +390,8 @@ describe('useNodeDragToCanvas', () => {
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const { startDrag, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
const pointerEvent = new PointerEvent('pointerup', {
clientX: 250,
@@ -341,7 +417,7 @@ describe('useNodeDragToCanvas', () => {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
startDrag(mockNodeDef, { mode: 'native' })
handleNativeDrop(250, 250)
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
@@ -359,7 +435,7 @@ describe('useNodeDragToCanvas', () => {
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
startDrag(mockNodeDef, { mode: 'native' })
handleNativeDrop(600, 250)
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
@@ -377,7 +453,7 @@ describe('useNodeDragToCanvas', () => {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'click')
startDrag(mockNodeDef)
handleNativeDrop(250, 250)
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
@@ -392,14 +468,12 @@ describe('useNodeDragToCanvas', () => {
})
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
const { startDrag, handleNativeDrop, isDragging, dragMode } =
useNodeDragToCanvas()
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
startDrag(mockNodeDef, { mode: 'native' })
handleNativeDrop(250, 250)
expect(isDragging.value).toBe(false)
expect(dragMode.value).toBe('click')
})
})
@@ -426,31 +500,29 @@ describe('useNodeDragToCanvas', () => {
})
it('should stop propagation when in click-drag mode over canvas', () => {
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
expect(dispatchPointerDown(250, 250)).toHaveBeenCalled()
})
it('should not stop propagation when not dragging', () => {
const { setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
it('should not stop propagation once the drag is cancelled', () => {
const { startDrag, cancelDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
cancelDrag()
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
})
it('should not stop propagation in native drag mode', () => {
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
})
it('should not stop propagation when pointer is outside canvas', () => {
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
expect(dispatchPointerDown(600, 250)).not.toHaveBeenCalled()
@@ -477,10 +549,8 @@ describe('useNodeDragToCanvas', () => {
}
it('should prefer tracked drag position over dragend coordinates', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
fireDrag(250, 250)
// dragend supplies a bad position (the Firefox bug); the tracked one
@@ -494,10 +564,8 @@ describe('useNodeDragToCanvas', () => {
})
it('should ignore drag events with (0, 0)', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
fireDrag(250, 250)
fireDrag(0, 0)
@@ -510,10 +578,8 @@ describe('useNodeDragToCanvas', () => {
})
it('should fall back to dragend coordinates when no drag fired', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
handleNativeDrop(250, 250)
@@ -523,32 +589,14 @@ describe('useNodeDragToCanvas', () => {
})
})
it('should ignore dragover events fired before startDrag', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
fireDrag(250, 250)
startDrag(mockNodeDef, 'native')
handleNativeDrop(300, 300)
expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
clientX: 300,
clientY: 300
})
})
it('should clear tracked position between drags', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
fireDrag(250, 250)
handleNativeDrop(1505, 102)
// Second drag - no drag events, so we should fall back to args.
startDrag(mockNodeDef, 'native')
startDrag(mockNodeDef, { mode: 'native' })
handleNativeDrop(300, 300)
expect(mockConvertEventToCanvasOffset).toHaveBeenLastCalledWith({

View File

@@ -1,23 +1,32 @@
import { ref, shallowRef } from 'vue'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import type { NodeAddSource } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
type DragMode = 'click' | 'native'
type WidgetValues = Record<string, string>
type Position = { x: number; y: number }
interface StartDragOptions {
mode?: DragMode
widgetValues?: WidgetValues
source?: NodeAddSource
}
const isDragging = ref(false)
const draggedNode = shallowRef<ComfyNodeDefImpl | null>(null)
const cursorPosition = ref({ x: 0, y: 0 })
const dragMode = ref<DragMode>('click')
const lastNativeDragPosition = shallowRef<{ x: number; y: number }>()
const lastNativeDragPosition = shallowRef<Position>()
const pendingWidgetValues = shallowRef<WidgetValues>()
const pendingSource = ref<NodeAddSource>('sidebar_drag')
let listenersSetup = false
function updatePosition(e: PointerEvent) {
cursorPosition.value = { x: e.clientX, y: e.clientY }
}
// Firefox dragend can report stale clientX/Y and `drag` can fire with
// (0, 0). dragover on the target reliably reports real client coords.
// https://bugzilla.mozilla.org/show_bug.cgi?id=1773886
@@ -27,11 +36,20 @@ function trackNativeDragPosition(e: DragEvent) {
lastNativeDragPosition.value = { x: e.clientX, y: e.clientY }
}
function cancelDrag() {
isDragging.value = false
draggedNode.value = null
dragMode.value = 'click'
lastNativeDragPosition.value = undefined
function applyWidgetValues(node: LGraphNode, values: WidgetValues) {
for (const [name, value] of Object.entries(values)) {
const widget = node.widgets?.find((w) => w.name === name)
if (!widget) {
console.error(`Widget ${name} not found on node ${node.type}`)
useToastStore().add({
severity: 'warn',
summary: t('g.warning'),
detail: t('assetBrowser.failedToSetModelValue')
})
continue
}
widget.value = value
}
}
function isOverCanvas(clientX: number, clientY: number): boolean {
@@ -59,10 +77,22 @@ function addNodeAtPosition(clientX: number, clientY: number): boolean {
clientX,
clientY
} as PointerEvent)
const node = withNodeAddSource('sidebar_drag', () =>
const node = withNodeAddSource(pendingSource.value, () =>
useLitegraphService().addNodeOnGraph(nodeDef, { pos })
)
if (node) canvas.selectItems([node])
if (!node) {
console.error(`Failed to add node to graph: ${nodeDef.name}`)
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('assetBrowser.failedToCreateNode')
})
return true
}
if (pendingWidgetValues.value)
applyWidgetValues(node, pendingWidgetValues.value)
canvas.selectItems([node])
return true
}
@@ -92,7 +122,6 @@ function setupGlobalListeners() {
if (listenersSetup) return
listenersSetup = true
document.addEventListener('pointermove', updatePosition)
document.addEventListener('pointerdown', blockCommitPointerDown, true)
document.addEventListener('pointerup', endDrag, true)
document.addEventListener('keydown', handleKeydown)
@@ -103,22 +132,37 @@ function cleanupGlobalListeners() {
if (!listenersSetup) return
listenersSetup = false
document.removeEventListener('pointermove', updatePosition)
document.removeEventListener('pointerdown', blockCommitPointerDown, true)
document.removeEventListener('pointerup', endDrag, true)
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('dragover', trackNativeDragPosition)
}
if (isDragging.value && dragMode.value === 'click') {
cancelDrag()
}
function cancelDrag() {
isDragging.value = false
draggedNode.value = null
dragMode.value = 'click'
lastNativeDragPosition.value = undefined
pendingWidgetValues.value = undefined
pendingSource.value = 'sidebar_drag'
cleanupGlobalListeners()
}
export function useNodeDragToCanvas() {
function startDrag(nodeDef: ComfyNodeDefImpl, mode: DragMode = 'click') {
function startDrag(
nodeDef: ComfyNodeDefImpl,
{
mode = 'click',
widgetValues,
source = 'sidebar_drag'
}: StartDragOptions = {}
) {
isDragging.value = true
draggedNode.value = nodeDef
dragMode.value = mode
pendingWidgetValues.value = widgetValues
pendingSource.value = source
setupGlobalListeners()
}
function handleNativeDrop(clientX: number, clientY: number) {
@@ -134,12 +178,9 @@ export function useNodeDragToCanvas() {
return {
isDragging,
draggedNode,
cursorPosition,
dragMode,
pendingWidgetValues,
startDrag,
cancelDrag,
handleNativeDrop,
setupGlobalListeners,
cleanupGlobalListeners
handleNativeDrop
}
}

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