Compare commits

..

101 Commits

Author SHA1 Message Date
Wei Hai
f797bd13db [backport cloud/1.45] feat(auth): Cloudflare Turnstile on email signup (origin-gated) (#13353)
Backport of #12924 to `cloud/1.45`.

## Why

`cloud/1.45` is the currently-deployed release line (test / staging /
prod are pinned at **1.45.15**). At 1.45.15 the Cloudflare Turnstile
signup widget can never render:

- `TurnstileWidget.vue` exists only as an orphan file — it is **not
wired into `SignUpForm`**.
- `useFeatureFlags` has **no `signup_turnstile`** flag.

The fully-wired feature only landed on `cloud/1.46` (via #13161). This
backports it onto the `cloud/1.45` line so Turnstile can render without
rotating the whole release line to 1.46.

## What

Cherry-pick of the #13161 (`cloud/1.46`) backport onto `cloud/1.45`:

- `<TurnstileWidget v-if="turnstileEnabled">` wired into `SignUpForm`
- `signup_turnstile` added to `useFeatureFlags`
- per-environment sitekeys baked in `config/turnstile.ts` (prefers the
remote-config `turnstile_sitekey`, falls back to the build-time
constant)
- optional `turnstile_token` on the create-customer request body

### Conflict resolution

Same single conflict as the 1.46 backport, in `src/stores/authStore.ts`:
`cloud/1.45` routes `createCustomer` through `fetchWithUnifiedRemint`
(the unified-remint 401 retry), which `cloud/1.46` does not. Resolved by
**keeping** the `fetchWithUnifiedRemint` wrapper and adding **only** the
Turnstile change — the optional `turnstile_token` request body. No other
behavioral change.

## Validation

Local, Node 24 (matches CI):

- **123 affected unit tests pass** — `authStore`, `useFeatureFlags`,
`turnstile` config/script, `useTurnstile`, `SignUpForm`,
`TurnstileWidget`.
- `vue-tsc` typecheck clean.

## Notes

The flag ships at `shadow` (renders but does not block submit). Switch
`signup_turnstile` to `enforce` once rendering is verified on the
deployed 1.45 build if you want it to gate signup. After merge, a new
1.45.x build must be promoted through the test → staging → prod release
workflows for the widget to appear.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:56:34 -07:00
Christian Byrne
c7f00a9366 fix(billing): pricing dialog stacks above Settings + stops horizontal scroll (#13317)
Fixes the two pricing-dialog issues flagged by design (Alex/Denys) on
staging/testcloud.

## 1. Pricing renders behind Settings
cloud/1.45 is mid-Reka-migration: **Settings is still PrimeVue** (modal
`z-index: 1800`) while **pricing is Reka** (overlay/content `z-1700`).
So clicking Subscribe/Upgrade-to-Team inside Settings opens pricing
*under* Settings. Pricing is intentionally `modal: false` (its hosted
PrimeVue popover must stay clickable), so it can't just be raised above.
**Fix:** close the `global-settings` dialog when opening the pricing
table.

## 2. Horizontal scrollbar on the dialog
`GlobalDialog` wrapped every dialog body in `flex-1 overflow-auto px-4
py-2` — including **headless** dialogs (pricing) that draw their own
chrome, forcing x-overflow on the full-width table. **Fix:** render
headless dialogs directly (mirrors `main`'s GlobalDialog).

## Scope
2 files, dialog-rendering only. No billing-logic change.

## Verification
Needs a visual check on the preview/staging (z-index + overflow are
visual). Please confirm: open Settings → Subscribe/Upgrade-to-Team →
pricing now opens **on top**, no horizontal scrollbar, hosted popovers
still clickable.

🤖 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-30 02:15:03 -07:00
Comfy Org PR Bot
d2b8230783 [backport cloud/1.45] Enable cloud PostHog pageviews (#13303)
Backport of #13286 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: steven-comfy <steven@comfy.org>
2026-06-30 00:14:25 -07:00
Hunter
bfa534fc0a fix(billing): resolve useWorkspaceUI lazily to break subscription dialog cycle (cloud/1.45) (#13288)
## Summary

Fixes a billing crash on the workspace-billing path
(`teamWorkspacesEnabled`, i.e. `@comfy.org`/`@drip.art` users) on
**test-v2, staging, and ephemeral** (all deploy from `cloud/1.45`):

```
TypeError: Cannot destructure property 'permissions' of 'useWorkspaceUI(...)' as it is undefined.
TypeError: Cannot destructure property 'isActiveSubscription' of 'useBillingContext(...)' as it is undefined.
```

## Root cause — a backport-ordering regression

`useSubscriptionDialog`, `useWorkspaceUI`, and `useBillingContext` form
an initialization cycle:

```
useBillingContext (immediate watch reads activeContext)
  -> getWorkspaceBilling -> useWorkspaceBilling
  -> useSubscriptionDialog   (setup-time: const { permissions } = useWorkspaceUI())
  -> useWorkspaceUIInternal  (setup-time: const { isActiveSubscription } = useBillingContext())
  -> re-enters the half-built useBillingContext -> returns undefined -> crash
```

It is first tripped by `PostHogTelemetryProvider` reading subscription
state at boot.

On `main` this is fixed: `#12761` (FE-768) / `#12953` (FE-966) resolve
`useWorkspaceUI()` lazily inside `showPricingTable`. Those landed on
`cloud/1.45` as `#13190` / `#13202` — but **`#13209` (FE-978 backport)
merged afterward and re-added the setup-time read at line 35**,
reverting the fix on the release branch only.

## Fix

Move `const { permissions } = useWorkspaceUI()` out of composable setup
into `showPricingTable` (lazy), matching `main` and the
`useBillingContext()` reads already lazy in this same file. Adds a
regression test asserting `useWorkspaceUI` is not resolved at composable
setup.

## Validation

Tagged `preview` to spin up an ephemeral env — please verify the
billing/subscription dialog opens without the destructure crash for a
workspace-billing (`@comfy.org`) user.

> Note: local pre-commit/-push hooks were skipped due to a sandbox
corepack cache permission issue; relying on CI for lint/typecheck/test.

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-06-30 00:13:58 -07:00
Terry Jia
8172788e27 [backport cloud/1.45] FE-1150 feat(rightSidePanel): hide 3D viewport widgets from panel (#13206) (#13208)
Backport of https://github.com/Comfy-Org/ComfyUI_frontend/pull/13206 to
cloud/1.45
2026-06-27 04:31:39 -04:00
Dante
35f39de11d [backport cloud/1.45] feat(billing): role-aware run-lock for cancelled/inactive team plans (FE-978) (#12786) (#13209)
Backport of #12786 to `cloud/1.45` — the last billing backport (closes
the FE-978 gap; was only on 1.46).

Cherry-picked squash commit `5a846db6cf`. Original authorship preserved.

**Conflict resolved** in `useSubscriptionDialog.ts`: #12786 predates the
B1 (#12953) Jun-5 unified-pricing-table rework, so its old
personal-vs-team fork structure conflicted. Adapted the member-inactive
gating onto the current unified structure — kept the
`SubscriptionInactiveMemberDialog` block
(`!permissions.value.canManageSubscription` → read-only modal),
discarded the obsolete fork code, kept the unified
`dialogComponentProps`. Dropped the top-level `isFreeTier` (B1 resolves
it lazily via `useBillingContext` in `show()`).

Brings the `RunButtonProperties` telemetry shape +
`SubscriptionInactiveMemberDialog`.
2026-06-27 14:14:42 +09:00
Dante
c567b7eb0f [backport cloud/1.45] feat(billing): single billing path — collapse personal/team dispatch to flag-only (B1 / FE-966) (#12953) (#13202)
Backport of #12953 to `cloud/1.45`.

Cherry-picked squash commit `e3049e7c31ea2bc4e82849173505af53808ba4b1`.
Original authorship preserved.

**Conflict resolved** in `useSubscriptionDialog.ts`: dropped the
top-level `const { permissions } = useWorkspaceUI()` line —
`permissions` is only consumed by the FE-978 (#12786) member-gating
code, which isn't on cloud/1.45 yet, so it would be an unused/unimported
reference here. `isFreeTier` is resolved lazily inside `show()` via
`useBillingContext` (per #12953's own change), so the removed top-level
`useSubscription` destructure isn't needed.

**Stacked** on #12954 (base: `backport-12954-to-cloud-1.45`) — top of
the cloud/1.45 billing facade stack. Merge bottom-up.
2026-06-27 13:45:35 +09:00
Dante
7ed1186fb7 [backport cloud/1.45] feat(billing): inline Invite-your-team block on team-upgrade success (FE-965 / DES-394) (#12954) (#13201)
Backport of #12954 to `cloud/1.45`.

Cherry-picked squash commit `87e84e728038ddbe947587fda19c686ed727cf14`.
Original authorship preserved.

**Telemetry conflict resolved** in 4 files (TelemetryRegistry,
Gtm/PostHog providers, types). Kept only #12954's own additions —
`WorkspaceInviteMetadata`, `trackWorkspaceInviteSent`,
`WORKSPACE_INVITE_SENT` — and preserved cloud/1.45's existing
`trackRunButton(options)` signature. The `trackRunButton(properties:
RunButtonProperties)` refactor and `BEGIN_CHECKOUT` event seen in the
conflict context belong to other not-yet-backported PRs (#12786 etc.)
and were deliberately NOT pulled in.

**Stacked** on #12975 (base: `backport-12975-to-cloud-1.45`). Merge
bottom-up.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-27 13:44:03 +09:00
Dante
14bcc70337 [backport cloud/1.45] feat(billing): team-plan subscribe + checkout/confirm screen redesign (FE-934) (#12975) (#13200)
Backport of #12975 to `cloud/1.45`.

Cherry-picked squash commit `d60260ac3ca4185401d58053f95b59a8cb8eb521`
cleanly (no conflicts, once #12789 is below it). Original authorship
preserved.

Carries stacked child **#13072** (prorated team credit-commit change
preview) — folded into this squash.

Migrates `workspaceApi.subscribe()` to the `(planSlug, options)`
signature; the call-site in `useDowngradeToPersonal.ts` (from #12789) is
updated here — which is why #12789 must sit below this PR.

**Stacked** on #12789 (base: `backport-12789-to-cloud-1.45-stacked`).
Merge bottom-up.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-27 13:42:42 +09:00
Dante
35c1823c73 [backport cloud/1.45] feat(billing): downgrade-to-personal member-removal confirm flow (FE-977) (#12789) (#13199)
Backport of #12789 to `cloud/1.45`, **stacked** version.

Cherry-picked squash commit `a0f4feb111` cleanly. Original authorship
preserved.

**Supersedes the independent bot PR #13156.** That one targets bare
`cloud/1.45`, but #12975 (FE-934) below it migrates the `subscribe()`
signature and updates the call-site inside `useDowngradeToPersonal.ts` —
so #12789 must sit **below #12975** in the stack. This stacked version
provides that ordering. Recommend closing #13156 in favor of this.

Base: `backport-13001-to-cloud-1.45`. Merge bottom-up.
2026-06-27 13:42:01 +09:00
Dante
08a63a2a70 [backport cloud/1.45] feat(billing): deep link to open the pricing table (FE-1104) (#13001) (#13198)
Backport of #13001 to `cloud/1.45`.

Cherry-picked squash commit `f19597ce8120ee540e8a72fa96e380706d15d59a`
cleanly (no conflicts). Original authorship preserved.

Carries stacked child **#13088** (team Stripe checkout deep link) — its
`teamSubscriptionCheckoutUtil.ts` is folded into this squash.

**Stacked** on #13092 (base: `backport-13092-to-cloud-1.45`). Part of
the cloud/1.45 billing facade stack; merge bottom-up.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-27 13:40:51 +09:00
Dante
cd310a99f0 [backport cloud/1.45] fix(billing): restore unified pricing dialog width (Reka renderer regression) (#13092) (#13197)
Backport of #13092 to `cloud/1.45`.

Cherry-picked squash commit `a07854755febd20d851bac2b843ddca781c4c5c5`
cleanly (no conflicts). Original authorship preserved.

**Stacked** on Christian's cloud/1.45 facade stack (base:
`backport-12787-to-cloud-1.45`). Depends on #12666 (UnifiedPricingTable)
being present. Merge bottom-up after the facade stack lands.
2026-06-27 13:40:05 +09:00
Christian Byrne
5f62577125 [backport cloud/1.45] fix(billing): refresh workspace billing status after completed top-up (FE-932) (#12787) (#13192)
Backport of #12787 to `cloud/1.45`.

Part of an ordered, **stacked** backport series for cloud/1.45, merged
**bottom-up**.

**Stacked on:** #13191 (backport of #12782). Base branch =
`backport-12782-to-cloud-1.45`.

Cherry-picked squash commit `c2968422e60cdaf5db3a165c66ab02692a42a021` —
applied cleanly with no conflicts. Original authorship preserved.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-27 13:39:14 +09:00
Christian Byrne
80a4deab52 [backport cloud/1.45] feat(workspace): promote/demote members via Change role menu (FE-770) (#12782) (#13191)
Backport of #12782 to `cloud/1.45`.

Part of an ordered, **stacked** backport series for cloud/1.45, merged
**bottom-up**.

**Stacked on:** #13190 (backport of #12761). Base branch =
`backport-12761-to-cloud-1.45`.

Cherry-picked squash commit `67009dcda244515d97170447bcd3357a7e5d3393`
(clean textual merge). One semantic fixup: the new
`showChangeMemberRoleDialog` referenced `workspaceDialogProps`, a
main-only const not present on cloud/1.45 (where the equivalent is
`workspaceDialogPt`, used by all sibling member dialogs). Repointed the
new dialog to `workspaceDialogPt` to match local convention. Original
authorship preserved.

Note: backport PR #13156 (of #12789) is stacked on top of this branch.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-27 13:25:59 +09:00
Christian Byrne
e2f7f8e0fa [backport cloud/1.45] feat(billing): align workspace Plan & Credits panel to DES-186 (FE-768) (#12761) (#13190)
Backport of #12761 to `cloud/1.45`.

Part of an ordered, **stacked** backport series for cloud/1.45, merged
**bottom-up**.

**Stacked on:** #13189 (backport of #12759). Base branch =
`backport-12759-to-cloud-1.45`.

Cherry-picked squash commit `cfbc378df3318246ebd01a3833bd11ae663d5dfe`.
Conflicts (2 files): `src/main.ts` — accepted the new PrimeVue `zIndex`
config. `useSubscriptionDialog.ts` — the PR's only delta here relocates
a `useWorkspaceUI().permissions` read that feeds a member read-only
dialog block; that block and its component
(`SubscriptionInactiveMemberDialog.vue`) are pre-existing-on-main and
are NOT created by this PR nor present on cloud/1.45, so the dialog-file
hunk is a no-op against this branch and HEAD was kept (consistent with
the #12666 backport). Original authorship preserved.

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-27 13:05:00 +09:00
Christian Byrne
21bb2d6f66 [backport cloud/1.45] feat(workspace): redesign Members invite UI to DES-186 (FE-768) (#12759) (#13189)
Backport of #12759 to `cloud/1.45`.

Part of an ordered, **stacked** backport series for cloud/1.45, merged
**bottom-up**.

**Stacked on:** #13188 (backport of #12734). Base branch =
`backport-12734-to-cloud-1.45`.

Cherry-picked squash commit `988dc719552a27efcf4ebfde25cab705686914b6` —
applied cleanly with no conflicts. Original authorship preserved.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-26 19:38:25 -07:00
Dante
f85a259b0f [backport cloud/1.45] fix(billing): DES review polish on workspace billing UI (#12917) (#13195)
Backport of #12917 to `cloud/1.45`.

Cherry-picked squash commit `543a39a6b05dd11cc10b1ac3adc2ab28a898dc31`.
Original authorship preserved.

Applies 2 of the 3 polish items, which are independent of the
facade/run-lock stack:
- **Remove dead 'Upgrade' badge** in `CurrentUserPopoverWorkspace.vue`
- **Subscribe-to-Run button height** (`SubscribeToRun.vue`, `size=unset`
+ `h-8`)

**Deferred (1 item):** the member 'subscription inactive' dialog border
polish in `useSubscriptionDialog.ts` targets the
`SubscriptionInactiveMemberDialog` block from **FE-978 (#12786)**, which
is not yet on `cloud/1.45`. Resolved the conflict by keeping
cloud/1.45's version (the block doesn't exist here yet). Re-apply that
one-line `root` pt polish (`border-none rounded-none shadow-none`) when
#12786's backport lands.

Co-authored-by: GitHub Action <action@github.com>
2026-06-26 19:36:55 -07:00
Dante
4284d45358 [backport cloud/1.45] fix: guard workspace auth refresh races (#11726) (#13196)
Backport of #11726 to `cloud/1.45`.

Cherry-picked squash commit `ca2ead3c4aade32eb8a137b531ba5266876ebaa3`
cleanly (no conflicts). Original authorship preserved (Benjamin Lu).

Independent backport off `cloud/1.45`; cherry-pick order #4 in the
billing/auth series.

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-06-26 19:36:41 -07:00
Christian Byrne
e1a3aa89eb [backport cloud/1.45] feat(billing): unify credits into a facade-driven CreditsTile (FE-964) (#12734) (#13188)
Backport of #12734 to `cloud/1.45`.

Part of an ordered, **stacked** backport series for cloud/1.45, merged
**bottom-up**.

**Stacked on:** #13187 (backport of #12708). Base branch =
`backport-12708-to-cloud-1.45`.

Cherry-picked squash commit `026b2c47953197006e42cbcef6c60a235cc6970c` —
applied cleanly with no conflicts. Original authorship preserved.

---------

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-27 11:13:00 +09:00
Christian Byrne
5716934842 [backport cloud/1.45] feat: route cloud auth through the single Cloud JWT under unified_cloud_auth (FE-950) - step 3 (#12708) (#13187)
Backport of #12708 to `cloud/1.45`.

Part of an ordered, **stacked** backport series for cloud/1.45, merged
**bottom-up**.

**Stacked on:** #13186 (backport of #12666). Base branch =
`backport-12666-to-cloud-1.45`.

Cherry-picked squash commit `0c89f5a3a7f68b4a0e2948448b20c10c3422a895` —
applied cleanly with no conflicts. Original authorship preserved.

---------

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-27 11:12:28 +09:00
Christian Byrne
eefedaaef5 [backport cloud/1.45] feat(billing): UnifiedPricingTable — one table, personal/team plan toggle (B4 / FE-934) (#12666) (#13186)
Backport of #12666 to `cloud/1.45`.

Part of an ordered, **stacked** backport series for cloud/1.45, merged
**bottom-up**.

**Stacked on:** #13185 (backport of #12643). Base branch =
`backport-12643-to-cloud-1.45`.

Cherry-picked squash commit `49a7b7b558a842992873faa3bd60af005c2d34c0`.
Conflicts resolved: 4 incidental Tailwind class-sort edits to
`apps/website/*` components that cloud/1.45 has deleted were dropped
(`git rm`); `useSubscriptionDialog.ts` rewritten to the PR's
unified-pricing-table logic. The pre-existing-on-main member read-only
dialog block (`SubscriptionInactiveMemberDialog` /
`permissions.canManageSubscription`) is NOT part of this PR's delta and
does not exist on cloud/1.45, so it was intentionally not introduced.
Original authorship preserved.

---------

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-27 11:11:54 +09:00
Dante
d878dc1b6a [backport cloud/1.45] feat(workspace): switcher popover left of profile menu + DES-246 copy (FE-769) (#12763) (#13193)
Backport of #12763 to `cloud/1.45`.

Cherry-picked squash commit `700ff4644f6d1c0f2d6d0bacddbe28654474b5b0`
cleanly (no conflicts). Original authorship preserved.

Independent backport off `cloud/1.45`; cherry-pick order #1 in the
billing/auth series (earliest in main history, ahead of the facade
stack).
2026-06-26 17:41:32 -07:00
Christian Byrne
b18840f7e3 [backport cloud/1.45] fix(billing): repoint direct-bypass billing consumers to the facade (B3) (FE-933) (#12643) (#13185)
Backport of #12643 to `cloud/1.45`.

Part of an ordered, **stacked** backport series for the cloud/1.45
billing/auth work, to be merged **bottom-up**. This is the **bottom** of
the stack.

**Base:** `cloud/1.45` (first in stack).

Cherry-picked squash commit `52d430d1b648e8e4b39b43b39705163b2bcdff49`.
Two content conflicts resolved faithfully (SubscribeButton.vue,
useSubscriptionActions.ts): kept cloud/1.45's `isCloud` guards while
repointing consumers to the billing facade (`tier`, `fetchBalance`) per
the original PR. Original authorship preserved.

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-26 17:40:15 -07:00
Comfy Org PR Bot
2881a2b2e6 [backport cloud/1.45] feat(billing): team-plan CreditSlider component — 5 fixed stops (FE-935) (#13159)
Backport of #12644 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:52:59 -07:00
Comfy Org PR Bot
74f64aec2e [backport cloud/1.45] fix(billing): route subscription/sign-in/credit preconditions to modal, out of error panel (FE-878) (#13163)
Backport of #12785 to `cloud/1.45`

Automatically created by backport workflow.

---------

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:52:34 -07:00
Comfy Org PR Bot
427e6c16e4 [backport cloud/1.45] refactor: extract Cloud-JWT mint + dormant unified refresh lifecycle (FE-950) - step 2 (#13158)
Backport of #12704 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-06-25 19:16:17 -07:00
Comfy Org PR Bot
2982205346 [backport cloud/1.45] feat: register unified_cloud_auth feature flag (FE-950) - step 1 (#13165)
Backport of #12702 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-25 18:27:54 -07:00
Comfy Org PR Bot
af8f753a36 [backport cloud/1.45] feat: wire /api/billing/events into usage history behind teamWorkspacesEnabled (#13151)
Backport of #12955 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: dante01yoon <dante01yoon@naver.com>
2026-06-25 17:10:56 -07:00
Comfy Org PR Bot
35d27b6f56 [backport cloud/1.45] B2 - refactor(billing): complete the billing facade — resubscribe/topup + status fields (FE-904) (#13160)
Backport of #12622 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-26 09:10:10 +09:00
Comfy Org PR Bot
702127e84b [backport cloud/1.45] refactor(billing): unify cancel-status polling into billingOperationStore (B8 / FE-970) (#13157)
Backport of #12788 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-26 09:04:43 +09:00
Comfy Org PR Bot
a62744bf60 [backport cloud/1.45] feat(workspace): creator-only canManageSubscriptionLifecycle permission (FE-770) (#13155)
Backport of #12829 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-26 09:03:35 +09:00
Comfy Org PR Bot
7569d29041 [backport cloud/1.45] fix(billing): truncate long workspace names in the switcher (#13153)
Backport of #12918 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-26 09:03:15 +09:00
Comfy Org PR Bot
f73886cead [backport cloud/1.45] fix(billing): keep successful team subscribe when post-write refresh fails (#13152)
Backport of #12951 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-26 09:02:51 +09:00
Comfy Org PR Bot
2a59c3fe11 [backport cloud/1.45] fix(billing): widen user popover so the credits row keeps both buttons inside (#13150)
Backport of #13052 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-26 09:01:51 +09:00
Comfy Org PR Bot
f7fe413967 [backport cloud/1.45] fix: Remove duplicate app workflow validation (#12539)
Backport of #12208 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-25 16:49:20 -07:00
Comfy Org PR Bot
3ae5ab2e20 [backport cloud/1.45] Add special runtime error messaging (#13148)
Backport of #12466 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-25 16:45:27 -07:00
Comfy Org PR Bot
59b31d53c3 [backport cloud/1.45] feat: send deploy_environment as Comfy-Env header on /releases requests (#12775)
Backport of #12771 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
2026-06-25 14:31:36 -07:00
Comfy Org PR Bot
76c8a49543 [backport cloud/1.45] Adopt jobs-namespace cancel endpoints in the jobs panel (#13066)
Backport of #12863 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Matt Miller <matt@miller-media.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-25 14:29:49 -07:00
Comfy Org PR Bot
3e140b8133 [backport cloud/1.45] [bugfix] Truncate long workspace names in workspace switcher (#13125)
Backport of #12762 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-25 13:37:14 -07:00
jaeone94
71d8e67cb8 [backport cloud/1.45] fix: show cloud models in IC-LoRA Loader Model Only node (FE-838) (#13138)
## Summary

Manual backport of #12488 to cloud/1.45.

The IC-LoRA Loader Model Only node (LTXICLoRALoaderModelOnly) was
missing from MODEL_NODE_MAPPINGS, so its lora_name combo stayed on the
legacy dropdown instead of using the cloud asset browser.

## Changes

- Add LTXICLoRALoaderModelOnly -> lora_name under the loras model
mapping.
- Backport the FE-838 unit regression coverage from the original PR.
- Keep the backport intentionally small; no unrelated newer mappings
from main are included.

## Validation

-
PATH=/Users/jaeone/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin:$PATH
pnpm test:unit src/stores/modelToNodeStore.test.ts
-
PATH=/Users/jaeone/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin:$PATH
pnpm exec oxfmt --check
src/platform/assets/mappings/modelNodeMappings.ts
src/stores/modelToNodeStore.test.ts
- git diff --check

Notes:
- The first local test attempt with shell Node v25 failed during Vitest
environment setup before tests ran; rerunning with the repo-required
Node 24 passed.
- Commit hook also ran staged oxfmt, oxlint, eslint, and pnpm typecheck
successfully.
2026-06-25 13:36:36 -07:00
Comfy Org PR Bot
e490dd349f [backport cloud/1.45] fix(asset-card): read image dimensions from typed metadata field (#13042)
Backport of #12328 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Matt Miller <matt@miller-media.com>
Co-authored-by: Matt Miller <mattmillerai@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-06-25 13:22:51 -07:00
Comfy Org PR Bot
72a7d80b69 [backport cloud/1.45] feat: remove subscript prompt dialog on load (#13025)
Backport of #13012 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-06-25 13:21:20 -07:00
Comfy Org PR Bot
0e6d6386ca [backport cloud/1.45] General execution error messaging (#13119)
Backport of #12448 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-25 13:20:46 -07:00
Comfy Org PR Bot
af4dd831be [backport cloud/1.45] More robust drag cleanup (#13098)
Backport of #13084 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-06-24 12:05:22 -07:00
Comfy Org PR Bot
e562014088 [backport cloud/1.45] fix(auth): show a clear message when sign-up is rejected by the backend (#13101)
Backport of #13070 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Wei Hai <hai.vincent@gmail.com>
2026-06-24 11:43:40 -07:00
Comfy Org PR Bot
9e8727bcaf [backport cloud/1.45] fix(widgetStore): tolerate null/undefined custom widgets from extensions (#12819)
Backport of #12728 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Matt Miller <matt@miller-media.com>
2026-06-19 14:55:17 -07:00
AustinMroz
09b8f18d99 [backport cloud/1.45] Support opus format in assets panel (#12984)
Manual backport of #12956  to `cloud/1.45`

Auto backport failed since `stl` had been added as a 3d type on main.
2026-06-19 14:52:24 -07:00
Comfy Org PR Bot
649f0cc964 [backport cloud/1.45] fix: re-disable ultralytics asset-picker registration (#13022)
Backport of #13020 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
2026-06-19 14:50:58 -07:00
jaeone94
44e32d4484 [backport cloud/1.45] Fix Cloud media input defaults (#12562) (#12959)
## Summary

Backport #12562 to cloud/1.45 so Cloud media loader widgets resolve
defaults from Cloud input assets instead of server object_info options.

## Changes

- **What**: Applies Cloud media input default resolution for LoadImage,
LoadVideo, and LoadAudio.
- **What**: Keeps the cloud/1.45 canonical `AssetItem.hash` field during
manual conflict resolution instead of reintroducing legacy `asset_hash`.
- **What**: Backports the related unit and browser regression coverage.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

Manual conflicts were limited to `useComboWidget.ts` and
`useComboWidget.test.ts`; the browser spec auto-merged. Please verify
the conflict resolution around `getCloudInputAssetValue` and the test
fixtures using `hash`.

Backport of #12562.
2026-06-19 16:48:10 +09:00
Christian Byrne
0ef64699b2 [backport cloud/1.45] Update default workflow (#12968)
Backport of #12804 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-18 16:47:31 -07:00
Comfy Org PR Bot
e2f01a84ba [backport cloud/1.45] fix(cloud): stop bouncing working users to /cloud/survey mid-session (FE-739) (#12964)
Backport of #12621 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-19 08:36:14 +09:00
pythongosssss
63bddcd5d2 [backport cloud/1.45] feat: implement customer.io SDK & telemetry provider (#12919)
Backport of #12878 to `cloud/1.45`.

Cherry-pick of merge commit `36b57f1e831037c511a0ee372e3325a761cff0bc`.

## Original summary
Adds a cloud-only Customer.io telemetry provider that forwards key
frontend lifecycle events and registers the in-app messaging plugin,
enabling low-latency intent-moment campaigns.

## Conflict resolution
- `pnpm-workspace.yaml`: added `@customerio/cdp-analytics-browser`
catalog entry; kept target branch's `@eslint/js` version (^9.39.1 — the
eslint bump was incidental to the PR).
- `pnpm-lock.yaml`: regenerated via `pnpm install`.
- `package.json`: kept target branch versions; only the new
`@customerio/cdp-analytics-browser` dependency added.

Telemetry provider source files, `global.d.ts`, `initTelemetry.ts`,
`remoteConfig/types.ts`, and the telemetry scanner workflow match the
original PR.
2026-06-17 14:00:36 -07:00
Comfy Org PR Bot
20a2e5df6f [backport cloud/1.45] fix: bind replacement node widgets to reused id (#12907)
Backport of #12872 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-17 16:51:42 +09:00
Comfy Org PR Bot
44d6a2754f [backport cloud/1.45] fix: encode large copy payload metadata in chunks (#12911)
Backport of #12847 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-17 16:51:30 +09:00
Comfy Org PR Bot
4db0d8e41b [backport cloud/1.45] Fix undated failed runs in job history grouping (#12903)
Backport of #12879 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-17 16:03:46 +09:00
Comfy Org PR Bot
9f4d058565 [backport cloud/1.45] FE-905 fix(load3d): cache scene capture so unchanged runs hit backend cache (#12681)
Backport of #12627 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-06-16 15:22:42 -07:00
AustinMroz
944b5d4d7f [backport cloud/1.45] Resolve errant executionIds on workflow restore (#12868)
Manual backport of #12659 to `cloud/1.45`
2026-06-16 15:13:16 -07:00
Benjamin Lu
0bdd25f597 [backport cloud/1.45] feat: track funnel telemetry attributes (#12778) (#12822)
Backport of #12778 to `cloud/1.45`.

Automated backport failed with a conflict in
`src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts`
(see
https://github.com/Comfy-Org/ComfyUI_frontend/pull/12778#issuecomment-4694515360).

Resolution: kept the union of both sides — the branch's
`executionError`/`executionSuccess` test fixtures and cases alongside
the PR's updated `shareFlowMetadata` shape and new
`shellLayoutMetadata`/`trackShellLayout` case.

Verified locally: `pnpm typecheck` and telemetry + affected component
unit tests (228 tests) pass.
2026-06-15 14:33:22 -07:00
Comfy Org PR Bot
c5180870be [backport cloud/1.45] fix: stop Add Secret dialog rendering behind Settings modal (FE-939) (#12687)
Backport of #12665 to `cloud/1.45`

Automatically created by backport workflow.

---------

Co-authored-by: Dante <bunggl@naver.com>
2026-06-13 10:04:38 +09:00
Comfy Org PR Bot
459a6e3780 [backport cloud/1.45] fix(oauth): allow reverse-DNS custom-scheme redirects on consent (#12811)
Backport of #12806 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Matt Miller <matt@miller-media.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:54:03 -07:00
Comfy Org PR Bot
0dab4c9b2a [backport cloud/1.45] fix(cloud): render the OAuth consent view in the dark theme (#12805)
Backport of #12655 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Matt Miller <matt@miller-media.com>
2026-06-11 17:42:43 -07:00
Benjamin Lu
6cc93673ff [backport cloud/1.45] Add share id attribution across share and run telemetry (#12773)
## Summary

Backports #12741 to `cloud/1.45` so share id attribution telemetry is
included in the cloud 1.45 release lane.

## Changes

- **What**: Cherry-picked `c190784307a33574fdb082131f73e71f64067797` and
resolved conflicts with existing `cloud/1.45` execution telemetry.
- **Dependencies**: None.

## Review Focus

Confirm the telemetry provider conflict resolution keeps both execution
telemetry and shared workflow attribution telemetry.

## Testing

- `pnpm exec vitest run
src/platform/navigation/preservedQueryManager.test.ts
src/platform/telemetry/providers/cloud/GtmTelemetryProvider.test.ts
src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts
src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts
src/platform/workflow/core/services/workflowService.test.ts
src/platform/workflow/sharing/components/ShareWorkflowDialogContent.test.ts
src/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader.test.ts
src/stores/authStore.test.ts src/stores/executionStore.test.ts`
2026-06-11 08:04:35 -07:00
Deep Mehta
cdbb8cd3bc [backport cloud/1.45] fix: opaque full-bleed favicon.ico (#12753) (#12783)
## Summary

Scoped backport of #12753 (the automated backport failed on conflicts).
Only `public/assets/favicon.ico` is cherry-picked — the file the cloud
build actually serves. The PR's `apps/website/*` changes deploy from
`main` via Vercel and don't apply to this branch; they're what made the
bot's cherry-pick conflict.

## Why

cloud.comfy.org's `/favicon.ico` (now correctly routed by cloud#4184)
still serves the old icon from the pinned `cloud/1.45` build. This lands
the corrected opaque ico on the release branch so the next prod
`frontendVersion` bump picks it up.

## After merge

Needs a `cloud/1.45` build upload + prod `frontendVersion` bump in the
cloud repo (+ manual ArgoCD prod sync) to reach cloud.comfy.org.
2026-06-10 21:28:25 -07:00
Comfy Org PR Bot
3d3198ab21 [backport cloud/1.45] Bumping search ranks (#12756)
Backport of #12750 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-06-10 01:17:37 -07:00
Comfy Org PR Bot
e2d70315b5 [backport cloud/1.45] feat: track search keystrokes across 5 surfaces (#12713)
Backport of #12618 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 15:43:48 -07:00
Comfy Org PR Bot
5d42aae7b2 [backport cloud/1.45] Fix "open tutorial button" not working in templates (#12743)
Backport of #12511 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-06-09 12:54:57 -07:00
Comfy Org PR Bot
337db984a5 [backport cloud/1.45] [bugfix] Use Desktop2 bridge for missing model downloads (#12739)
Backport of #12710 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-10 03:38:41 +09:00
Comfy Org PR Bot
1968bbaa2f [backport cloud/1.45] fix: clear missing model on promoted widget change (#12697)
Backport of #12677 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-08 19:15:00 +09:00
Comfy Org PR Bot
991117a6b5 [backport cloud/1.45] fix: defer node auto-pan until drag starts (#12701)
Backport of #12654 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-08 19:14:28 +09:00
Comfy Org PR Bot
d5f63cf852 [backport cloud/1.45] fix: keep connected advanced inputs visible (#12691)
Backport of #12652 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-08 15:53:47 +09:00
Comfy Org PR Bot
4d90e70298 [backport cloud/1.45] fix(load3d): load Preview3DAdvanced / splat / pointcloud previews from temp/ (#12676)
Backport of #12671 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-06-05 20:34:08 -04:00
Deep Mehta
ef30aaf93f [backport cloud/1.45] fix: cloud favicon — static link + new-brand PWA/app icon (#12537, #12632) (#12674)
## Summary

Backport the cloud.comfy.org favicon fixes to `cloud/1.45` (the release
branch prod actually serves). These landed on `main` but were never
backported, so cloud still shows the old yellow-on-blue mark.

Backports #12537 and #12632.

## Why this is needed

cloud.comfy.org prod serves `cloud/1.45` (per `frontend-version.json` +
the `nginx-frontend` Helm overlay). At the served commit, `index.html`
has no `<link rel="icon">` and `manifest.json` points at the old blue
`comfy-logo-single.svg`, so the browser uses the blue manifest icon as
the tab favicon. `main`-only fixes never reach cloud.

## Changes (cherry-picked, clean)

- `#12537` — static `<link rel="icon" href="/assets/favicon.ico">` in
`index.html` + rounded multi-size `favicon.ico` (16/32/48).
- `#12632` — `manifest.json` now references new-brand dark
`comfy-icon-192.png` / `comfy-icon-512.png` instead of the blue logo.

## After merge

Prod still won't update until the `nginx-frontend` `frontendVersion` pin
(`infrastructure/argocd/.../comfy-cloud-prod-v2/values.yaml` in the
cloud repo) is bumped to a `cloud/1.45` build containing this. That bump
+ browser favicon cache are the last steps.
2026-06-05 15:51:22 -07:00
Comfy Org PR Bot
45dbd37a7b [backport cloud/1.45] fix(load3d): load Preview3DAdvanced output from temp/, allow temp loadFolder (#12669)
Backport of #12661 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-06-05 11:15:27 -04:00
Comfy Org PR Bot
3870b54360 [backport cloud/1.45] feat(load3d): register Preview3DAdvanced extension (#12658)
Backport of #12527 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-06-04 19:56:57 -04:00
Comfy Org PR Bot
383760e728 [backport cloud/1.45] feat(telemetry): capture desktop entry props in cloud build (#12649)
Backport of #12647 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
2026-06-04 13:04:14 -07:00
Comfy Org PR Bot
af8f0b60f5 1.45.15 (#12651)
Patch version increment to 1.45.15

**Base branch:** `cloud/1.45`

Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
2026-06-04 12:49:05 -07:00
AustinMroz
434a1b1af1 [backport cloud/1.45] refactor(assets): read content hash from the canonical hash field (#12650)
Backport of #12638 to `cloud/1.45`

Co-authored-by: Matt Miller <matt@miller-media.com>
2026-06-04 12:48:47 -07:00
Comfy Org PR Bot
bd6e5e2286 [backport cloud/1.45] feat: add app:node_added telemetry event (#12641)
Backport of #12615 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 08:56:04 -07:00
Comfy Org PR Bot
6a78e0b635 [backport cloud/1.45] fix(assets): dedupe outputs by composite key to prevent media asset panel scroll-duplication (#12640)
Backport of #11716 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-04 11:31:02 +09:00
Comfy Org PR Bot
b751750f0b [backport cloud/1.45] feat(telemetry): capture Rewardful referral on checkout attribution (#12625)
Backport of #12311 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: nav-tej <36310614+nav-tej@users.noreply.github.com>
Co-authored-by: glary-bot <glary-bot@comfy.org>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-06-03 11:59:59 -07:00
Comfy Org PR Bot
2fa47fa260 [backport cloud/1.45] feat: add missing_node_packs to app:workflow_imported telemetry (#12616)
Backport of #12613 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 18:47:50 -07:00
AustinMroz
505728cc56 [backport cloud/1.45] Feat/cloud onboarding redesign (#12610)
Backport of #12422 to `cloud/1.45`

Co-authored-by: Maanil Verma <vermaMaanil97@gmail.com>
Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
2026-06-02 17:14:29 -07:00
Comfy Org PR Bot
880af41f34 [backport cloud/1.45] refactor(assets): read content hash via hash field, fall back to asset_hash (#12612)
Backport of #12609 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Matt Miller <matt@miller-media.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-02 16:52:54 -07:00
Terry Jia
085bef657b [backport cloud/1.45] feat: add PreviewGaussianSplat + PreviewPointCloud extensions (#12597)
Backport https://github.com/Comfy-Org/ComfyUI_frontend/pull/12545 to
cloud/1.45

Tested and Verified on local build
<img width="1886" height="1538" alt="image"
src="https://github.com/user-attachments/assets/6f5086e8-05c8-47c8-95cd-8c9bb9ae8a5a"
/>
2026-06-02 13:46:26 -07:00
Comfy Org PR Bot
f849e9be77 [backport cloud/1.45] Pr/12481 - fixed error (#12604)
Backport of #12574 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Steven Tran <94876858+stevenltran@users.noreply.github.com>
Co-authored-by: Nav Singh <nav@comfy.org>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Steven Tran <steventran@Stevens-MacBook-Air.local>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-06-02 11:01:39 -07:00
Comfy Org PR Bot
bd48bf1bbe [backport cloud/1.45] Updated Pr 12480 - fix(telemetry): call posthog.reset(true) on logout to prevent session bleeding (#12606)
Backport of #12599 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Steven Tran <94876858+stevenltran@users.noreply.github.com>
Co-authored-by: Nav Singh <nav@comfy.org>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: nav-tej <36310614+nav-tej@users.noreply.github.com>
Co-authored-by: nav <nav@mac.lan>
Co-authored-by: Steven Tran <steventran@Stevens-MacBook-Air.local>
2026-06-02 11:01:31 -07:00
Comfy Org PR Bot
75fb11785a [backport cloud/1.45] fix: dedupe Bypass context-menu items via state-aware legacy label (FE-720) (#12587)
Backport of #12500 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-02 14:53:17 +09:00
Comfy Org PR Bot
5ff0b33295 [backport cloud/1.45] Track undo state on subgraph conversion (#12585)
Backport of #12575 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-06-01 18:59:00 -07:00
Comfy Org PR Bot
e89778fc89 [backport cloud/1.45] Remove drag node test from interaction.spec.ts (#12589)
Backport of #12579 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-01 18:57:16 -07:00
Comfy Org PR Bot
6f3ef2ed70 [backport cloud/1.45] fix(cloud/oauth): mint session cookie when resuming consent while already signed in (#12577)
Backport of #12571 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Matt Miller <matt@miller-media.com>
2026-06-01 15:34:09 -07:00
Comfy Org PR Bot
cd216d4db8 [backport cloud/1.45] fix(telemetry): harden PostHog init — person_profiles, cookie_domain, before_send (#12573)
Backport of #12479 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: nav-tej <36310614+nav-tej@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miles <miles@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: nav <nav@mac.lan>
Co-authored-by: Miles Ryan <thedatalife@users.noreply.github.com>
2026-06-01 14:53:21 -07:00
Comfy Org PR Bot
bccfc41f5d [backport cloud/1.45] fix: preserve validation errors on execution start (#12548)
Backport of #12493 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-31 19:31:35 +09:00
Comfy Org PR Bot
2bf1eb6e19 [backport cloud/1.45] fix: open model library for desktop model downloads (#12552)
Backport of #12478 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-31 19:31:08 +09:00
Comfy Org PR Bot
d2ad47634a [backport cloud/1.45] Fix node tooltip metadata i18n parsing (#12556)
Backport of #12469 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-31 19:30:57 +09:00
Comfy Org PR Bot
daeae316b1 [backport cloud/1.45] Fix interrupted audio playback from assets panel (#12525)
Backport of #12425 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-29 12:39:16 -07:00
Comfy Org PR Bot
20cf8074a9 [backport cloud/1.45] Fix ghost links on IO remove slot (#12523)
Backport of #12473 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-29 10:38:30 -07:00
Comfy Org PR Bot
c87cb03024 [backport cloud/1.45] Fix restoring values to dynamic combos (#12490)
Backport of #12211 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-27 11:39:14 -07:00
Comfy Org PR Bot
28c4080134 [backport cloud/1.45] Fix mask editor sometimes showing wrong image (#12484)
Backport of #12413 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-27 00:47:08 -07:00
Comfy Org PR Bot
1a6e77e955 [backport cloud/1.45] Fix errant subscription popups with workspaces (#12476)
Backport of #12472 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-26 19:42:39 -07:00
Comfy Org PR Bot
e06d7a7b34 [backport cloud/1.45] fix(widgets): collapse duplicate COLOR widget rendering on Color to RGB Int (FE-842) (#12454)
Backport of #12447 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-05-25 21:52:32 -07:00
Comfy Org PR Bot
c76b7280af [backport cloud/1.45] Fix missing value control on 'Primitive Int' (#12462)
Backport of #12431 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-25 21:22:28 -07:00
487 changed files with 27022 additions and 11809 deletions

View File

@@ -109,3 +109,48 @@ jobs:
exit 1
fi
echo '✅ No PostHog references found'
- name: Scan dist for Customer.io telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for Customer.io references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e 'CustomerIoTelemetryProvider' \
-e '@customerio/cdp-analytics-browser' \
-e 'customerio-gist-web' \
-e '(?i)cdp\.customer\.io' \
-e 'Comfy\.CustomerIo' \
dist; then
echo '❌ ERROR: Customer.io references found in dist assets!'
echo 'Customer.io must be properly tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
echo '2. Call telemetry via useTelemetry() hook'
echo '3. Use conditional dynamic imports behind isCloud checks'
exit 1
fi
echo '✅ No Customer.io references found'
- name: Scan dist for Cloudflare Turnstile sitekey references
run: |
set -euo pipefail
echo '🔍 Scanning for Cloudflare Turnstile sitekeys...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e '0x4AAAAAADnYZPVOpFCL_zeo' \
-e '0x4AAAAAADnYY4_Q0qxHZ5a7' \
-e '1x00000000000000000000AA' \
dist; then
echo '❌ ERROR: Cloudflare Turnstile sitekey found in dist assets!'
echo 'The per-env Turnstile sitekeys are cloud-only and must be tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Gate sitekey selection on the __DISTRIBUTION__ build define, not the runtime isCloud const'
echo '2. See getTurnstileSiteKey() in src/config/turnstile.ts'
exit 1
fi
echo '✅ No Turnstile sitekey references found'

View File

@@ -1,142 +0,0 @@
name: Publish Desktop Bridge Types
on:
workflow_dispatch:
inputs:
version:
description: 'Version to publish (e.g., 0.1.2)'
required: true
type: string
dist_tag:
description: 'npm dist-tag to use'
required: true
default: latest
type: string
ref:
description: 'Git ref to checkout (commit SHA, tag, or branch)'
required: false
type: string
workflow_call:
inputs:
version:
required: true
type: string
dist_tag:
required: false
type: string
default: latest
ref:
required: false
type: string
secrets:
NPM_TOKEN:
required: true
concurrency:
group: publish-desktop-bridge-types-${{ github.workflow }}-${{ inputs.version }}-${{ inputs.dist_tag }}
cancel-in-progress: false
jobs:
publish_desktop_bridge_types:
name: Publish @comfyorg/comfyui-desktop-bridge-types
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Validate inputs
env:
VERSION: ${{ inputs.version }}
shell: bash
run: |
set -euo pipefail
SEMVER_REGEX='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$'
if [[ ! "$VERSION" =~ $SEMVER_REGEX ]]; then
echo "::error title=Invalid version::Version '$VERSION' must follow semantic versioning (x.y.z[-suffix][+build])" >&2
exit 1
fi
- name: Determine ref to checkout
id: resolve_ref
env:
REF: ${{ inputs.ref }}
DEFAULT_REF: ${{ github.ref_name }}
shell: bash
run: |
set -euo pipefail
if [ -z "$REF" ]; then
REF="$DEFAULT_REF"
fi
if ! git check-ref-format --allow-onelevel "$REF"; then
echo "::error title=Invalid ref::Ref '$REF' fails git check-ref-format validation." >&2
exit 1
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ steps.resolve_ref.outputs.ref }}
fetch-depth: 1
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
- name: Verify package
id: pkg
env:
INPUT_VERSION: ${{ inputs.version }}
shell: bash
run: |
set -euo pipefail
PACKAGE_JSON=packages/comfyui-desktop-bridge-types/package.json
NAME=$(node -p "require('./${PACKAGE_JSON}').name")
VERSION=$(node -p "require('./${PACKAGE_JSON}').version")
if [ "$VERSION" != "$INPUT_VERSION" ]; then
echo "::error title=Version mismatch::${PACKAGE_JSON} version $VERSION does not match input $INPUT_VERSION" >&2
exit 1
fi
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Check if version already on npm
id: check_npm
env:
NAME: ${{ steps.pkg.outputs.name }}
VER: ${{ steps.pkg.outputs.version }}
shell: bash
run: |
set -euo pipefail
STATUS=0
OUTPUT=$(npm view "${NAME}@${VER}" --json 2>&1) || STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "::warning title=Already published::${NAME}@${VER} already exists on npm. Skipping publish."
else
if echo "$OUTPUT" | grep -q "E404"; then
echo "exists=false" >> "$GITHUB_OUTPUT"
else
echo "::error title=Registry lookup failed::$OUTPUT" >&2
exit "$STATUS"
fi
fi
- name: Publish package
if: steps.check_npm.outputs.exists == 'false'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
DIST_TAG: ${{ inputs.dist_tag }}
run: pnpm publish --access public --tag "$DIST_TAG" --no-git-checks --ignore-scripts
working-directory: packages/comfyui-desktop-bridge-types

View File

@@ -78,6 +78,21 @@ 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: '@/composables/useFeatureFlags',
replacement:
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
},
{
find: '@/platform/workspace/stores/teamWorkspaceStore',
replacement:
process.cwd() + '/src/storybook/mocks/teamWorkspaceStore.ts'
},
{
find: '@/utils/formatUtil',
replacement:

View File

@@ -104,7 +104,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

@@ -48,7 +48,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>
@@ -77,7 +77,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

@@ -0,0 +1,55 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
const hoisted = vi.hoisted(() => ({
mockInit: vi.fn(),
mockCapture: vi.fn()
}))
vi.mock('posthog-js', () => ({
default: {
init: hoisted.mockInit,
capture: hoisted.mockCapture
}
}))
describe('initPostHog', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('passes a before_send hook to posthog.init that strips PII end-to-end', async () => {
const { initPostHog } = await import('./posthog')
initPostHog()
expect(hoisted.mockInit).toHaveBeenCalledOnce()
const initOptions = hoisted.mockInit.mock.calls[0][1]
expect(initOptions.person_profiles).toBe('identified_only')
expect(typeof initOptions.before_send).toBe('function')
const event = {
properties: {
email: 'a@example.com',
prompt: 'hello',
user_email: 'b@example.com',
$email: 'c@example.com',
method: 'google'
},
$set: { email: 'd@example.com', name: 'keep me' },
$set_once: { $email: 'e@example.com', plan: 'free' }
}
const result = initOptions.before_send(event)
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).not.toHaveProperty('prompt')
expect(result.properties).not.toHaveProperty('user_email')
expect(result.properties).not.toHaveProperty('$email')
expect(result.properties).toHaveProperty('method', 'google')
expect(result.$set).not.toHaveProperty('email')
expect(result.$set).toHaveProperty('name', 'keep me')
expect(result.$set_once).not.toHaveProperty('$email')
expect(result.$set_once).toHaveProperty('plan', 'free')
})
})

View File

@@ -1,5 +1,7 @@
import posthog from 'posthog-js'
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
const POSTHOG_KEY =
import.meta.env.PUBLIC_POSTHOG_KEY ??
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
@@ -18,7 +20,9 @@ export function initPostHog() {
ui_host: POSTHOG_UI_HOST,
capture_pageview: false,
capture_pageleave: true,
person_profiles: 'identified_only'
person_profiles: 'identified_only',
// cookie_domain omitted — see PostHogTelemetryProvider.ts note + posthog-js#3578
before_send: createPostHogBeforeSend()
})
initialized = true
} catch (error) {

View File

@@ -1,10 +1,11 @@
import type { Asset } from '@comfyorg/ingest-types'
function createModelAsset(overrides: Partial<Asset> = {}): Asset {
function createModelAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
return {
id: 'test-model-001',
name: 'model.safetensors',
asset_hash:
'blake3:0000000000000000000000000000000000000000000000000000000000000000',
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000000',
size: 2_147_483_648,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
@@ -16,12 +17,13 @@ function createModelAsset(overrides: Partial<Asset> = {}): Asset {
}
}
function createInputAsset(overrides: Partial<Asset> = {}): Asset {
function createInputAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
return {
id: 'test-input-001',
name: 'input.png',
asset_hash:
'blake3:1111111111111111111111111111111111111111111111111111111111111111',
hash: 'blake3:1111111111111111111111111111111111111111111111111111111111111111',
size: 2_048_576,
mime_type: 'image/png',
tags: ['input'],
@@ -32,12 +34,13 @@ function createInputAsset(overrides: Partial<Asset> = {}): Asset {
}
}
function createOutputAsset(overrides: Partial<Asset> = {}): Asset {
function createOutputAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
return {
id: 'test-output-001',
name: 'output_00001.png',
asset_hash:
'blake3:2222222222222222222222222222222222222222222222222222222222222222',
hash: 'blake3:2222222222222222222222222222222222222222222222222222222222222222',
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],

View File

@@ -0,0 +1,96 @@
import type {
BillingStatusResponse,
Member,
Plan,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
// `/api/features` is the remote-config source: production builds resolve the
// workspaces flag from it (the `ff:` localStorage override is dev-only).
export const WORKSPACE_FEATURE_FLAG: RemoteConfig = {
team_workspaces_enabled: true
}
export const TEAM_WORKSPACE: WorkspaceWithRole = {
id: 'ws-team',
name: 'Team Comfy',
type: 'team',
created_at: '2025-01-01T00:00:00Z',
joined_at: '2025-01-02T00:00:00Z',
role: 'owner',
subscription_tier: 'PRO'
}
export const CREATOR: Member = {
id: 'u-liz',
name: 'Liz',
email: 'liz@test.comfy.org',
joined_at: '2025-01-01T00:00:00Z',
role: 'owner',
is_original_owner: true
}
// Identity must match the CloudAuthHelper mock user so this row counts as
// "(You)".
export const VIEWER: Member = {
id: 'u-me',
name: 'E2E Test User',
email: 'e2e@test.comfy.org',
joined_at: '2025-01-02T00:00:00Z',
role: 'owner',
is_original_owner: false
}
export const MEMBER_JANE: Member = {
id: 'u-jane',
name: 'Jane',
email: 'jane@test.comfy.org',
joined_at: '2025-01-03T00:00:00Z',
role: 'member',
is_original_owner: false
}
export const MEMBER_JOHN: Member = {
id: 'u-john',
name: 'John',
email: 'john@test.comfy.org',
joined_at: '2025-01-04T00:00:00Z',
role: 'member',
is_original_owner: false
}
export const DEFAULT_TEAM_MEMBERS: Member[] = [
CREATOR,
VIEWER,
MEMBER_JANE,
MEMBER_JOHN
]
export const TEAM_BILLING_STATUS: BillingStatusResponse = {
is_active: true,
subscription_status: 'active',
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
plan_slug: 'pro-monthly',
billing_status: 'paid',
has_funds: true,
renewal_date: '2099-02-20T00:00:00Z'
}
// `max_seats > 1` on the current plan is what flips `isOnTeamPlan`, which gates
// the whole role-management UI.
export const TEAM_PRO_PLAN: Plan = {
slug: 'pro-monthly',
tier: 'PRO',
duration: 'MONTHLY',
price_cents: 10000,
credits_cents: 21100,
max_seats: 30,
availability: { available: true },
seat_summary: {
seat_count: 4,
total_cost_cents: 40000,
total_credits_cents: 0
}
}

View File

@@ -0,0 +1,150 @@
import type { Page, Route } from '@playwright/test'
import type { Member } from '@/platform/workspace/api/workspaceApi'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import {
DEFAULT_TEAM_MEMBERS,
TEAM_BILLING_STATUS,
TEAM_PRO_PLAN,
TEAM_WORKSPACE,
WORKSPACE_FEATURE_FLAG
} from '@e2e/fixtures/data/cloudWorkspace'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
interface RoleChangeRequest {
url: string
role: string
}
interface MemberMockState {
members: Member[]
patches: RoleChangeRequest[]
}
const jsonRoute = (body: unknown) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
/**
* Boots the cloud app against fully mocked workspace + billing endpoints so
* member/role specs can drive a raw `page` (the `comfyPage` fixture would try
* to reach the OSS devtools backend during setup).
*
* Returns the mutable mock state: `members` reflects PATCH-applied roles and
* `patches` records every role-change request for assertion.
*/
export class CloudWorkspaceMockHelper {
constructor(private readonly page: Page) {}
async setup(
members: Member[] = DEFAULT_TEAM_MEMBERS
): Promise<MemberMockState> {
const state = await this.mockBoot(members)
await new CloudAuthHelper(this.page).mockAuth()
await this.page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
localStorage.setItem('Comfy.Workspace.LastWorkspaceId', 'ws-team')
})
return state
}
private async mockBoot(members: Member[]): Promise<MemberMockState> {
const state: MemberMockState = {
members: members.map((m) => ({ ...m })),
patches: []
}
const { page } = this
await page.route('**/api/features', (r) =>
r.fulfill(jsonRoute(WORKSPACE_FEATURE_FLAG))
)
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// A non-empty settings payload with TutorialCompleted marks the user as
// returning, so the new-user Templates dialog never auto-opens to block the
// Settings button. Errors tab off suppresses the model-folder 401 toast.
await page.route('**/api/settings', (r) =>
r.fulfill(
jsonRoute({
'Comfy.TutorialCompleted': true,
'Comfy.RightSidePanel.ShowErrorsTab': false
})
)
)
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/api/auth/token', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/workspaces', (r) =>
r.fulfill(jsonRoute({ workspaces: [TEAM_WORKSPACE] }))
)
await page.route('**/api/workspace/members**', (route: Route) => {
const request = route.request()
if (request.method() === 'PATCH') {
const url = request.url()
const id = url.match(/\/api\/workspace\/members\/([^/?]+)/)?.[1]
const { role } = request.postDataJSON() as { role: Member['role'] }
state.patches.push({ url, role })
const member = state.members.find((m) => m.id === id)
if (member) member.role = role
// Echo the updated row like the real BE; the store merges only the role
// locally, so the response body shape is not load-bearing.
return route.fulfill(jsonRoute(member))
}
return route.fulfill(
jsonRoute({
members: state.members,
pagination: { offset: 0, limit: 50, total: state.members.length }
})
)
})
await page.route('**/api/workspace/invites', (r) =>
r.fulfill(jsonRoute({ invites: [] }))
)
await page.route('**/api/billing/status', (r) =>
r.fulfill(jsonRoute(TEAM_BILLING_STATUS))
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(
jsonRoute({
amount_micros: 6000,
currency: 'usd',
effective_balance_micros: 6000,
cloud_credit_balance_micros: 5000,
prepaid_balance_micros: 1000
})
)
)
await page.route('**/api/billing/plans', (r) =>
r.fulfill(
jsonRoute({ current_plan_slug: 'pro-monthly', plans: [TEAM_PRO_PLAN] })
)
)
return state
}
}

View File

@@ -2,11 +2,11 @@ import { readFileSync } from 'fs'
import { test } from '@playwright/test'
import type { AppMode } from '@/composables/useAppMode'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { AppMode } from '@/utils/appMode'
import type { WorkspaceStore } from '@e2e/types/globals'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { assetPath } from '@e2e/fixtures/utils/paths'

View File

@@ -43,10 +43,10 @@ const sharedWorkflowAsset: AssetInfo = {
in_library: false
}
const defaultInputAsset: Asset = {
const defaultInputAsset: Asset & { hash?: string } = {
id: 'default-input-asset',
name: defaultInputFileName,
asset_hash: defaultInputFileName,
hash: defaultInputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
@@ -55,10 +55,10 @@ const defaultInputAsset: Asset = {
last_access_time: '2026-05-01T00:00:00Z'
}
const importedInputAsset: Asset = {
const importedInputAsset: Asset & { hash?: string } = {
id: 'imported-input-asset',
name: sharedWorkflowImportScenario.inputFileName,
asset_hash: sharedWorkflowImportScenario.inputFileName,
hash: sharedWorkflowImportScenario.inputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],

View File

@@ -0,0 +1,34 @@
import type { Page } from '@playwright/test'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
/**
* Minimal valid billing shapes so the billing facade resolves while a
* subscription dialog mounts. Active personal sub with zero balance.
*/
export async function mockBilling(page: Page) {
await page.route('**/api/billing/status', (r) =>
r.fulfill(
jsonRoute({
is_active: true,
has_funds: true,
subscription_status: 'active',
subscription_tier: 'pro',
subscription_duration: 'MONTHLY',
billing_status: 'paid'
})
)
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
)
await page.route('**/api/billing/plans', (r) =>
r.fulfill(jsonRoute({ plans: [] }))
)
await page.route('**/customers/cloud-subscription-status', (r) =>
r.fulfill(jsonRoute({ is_active: false }))
)
await page.route('**/customers/balance', (r) =>
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
)
}

View File

@@ -0,0 +1,64 @@
import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
interface CloudBootOptions {
/** Remote-config payload for `/api/features` (enables the flags under test). */
features: RemoteConfig
/** Body for `/api/settings` (defaults to `{}`). */
settings?: unknown
}
/**
* Stub the core endpoints the cloud app hits on boot so a raw `page` reaches the
* working app without falling through to the OSS devtools backend. Specs layer
* their own feature- or flow-specific routes on top.
*/
export async function mockCloudBoot(
page: Page,
{ features, settings = {} }: CloudBootOptions
) {
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(features)))
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
await page.route('**/api/user', (r) =>
r.fulfill(jsonRoute({ status: 'active' }))
)
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute(settings)))
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
}
/**
* Mock Firebase auth and pre-select the e2e user so the cloud app boots
* signed-in. The signed-in email (`e2e@test.comfy.org`) is what the
* original-owner gate matches against the members self-row.
*/
export async function bootCloud(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
}

View File

@@ -1,45 +0,0 @@
import type { Page } from '@playwright/test'
function flagAttributeFor(testId: string) {
const encoded = Array.from(testId, (ch) =>
ch.charCodeAt(0).toString(16)
).join('')
return `data-flashed-${encoded}`
}
/**
* Flags the first time an element matching `[data-testid="<testId>"]` is
* present and rendered, sampled every frame via `requestAnimationFrame` from
* page load. Catches a dialog that mounts and unmounts within a few frames,
* which `toBeHidden()` (final state only) cannot.
*
* Must be called before navigation (e.g. before `comfyPage.setup()`).
*/
export async function trackElementFlash(
page: Page,
testId: string
): Promise<{ hasFlashed: () => Promise<boolean> }> {
const flagAttribute = flagAttributeFor(testId)
await page.addInitScript(
({ id, attribute }: { id: string; attribute: string }) => {
const sample = () => {
const el = document.querySelector(`[data-testid="${CSS.escape(id)}"]`)
if (el instanceof HTMLElement) {
const rect = el.getBoundingClientRect()
if (rect.width > 0 && rect.height > 0) {
document.documentElement.setAttribute(attribute, 'true')
}
}
requestAnimationFrame(sample)
}
requestAnimationFrame(sample)
},
{ id: testId, attribute: flagAttribute }
)
return {
hasFlashed: async () =>
(await page.locator('html').getAttribute(flagAttribute)) === 'true'
}
}

View File

@@ -0,0 +1,12 @@
/**
* Build a 200 JSON body for `route.fulfill()`. Generic so callers can type the
* payload (e.g. `jsonRoute({ ... } satisfies RemoteConfig)`) and catch contract
* drift against the real API shape.
*/
export function jsonRoute<T>(body: T) {
return {
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
}
}

View File

@@ -0,0 +1,68 @@
import type { Page } from '@playwright/test'
import type {
Member,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
export function workspace(
type: 'personal' | 'team',
role: 'owner' | 'member'
): WorkspaceWithRole {
return {
id: `ws-${type}`,
name: type === 'team' ? 'My Team' : 'Personal Workspace',
type,
role,
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z'
}
}
export function member(
overrides: Partial<Member> & Pick<Member, 'email' | 'role'>
): Member {
return {
id: `user-${overrides.email}`,
name: overrides.email,
joined_at: '2026-01-01T00:00:00Z',
is_original_owner: false,
...overrides
}
}
/**
* Stub the workspace resolution + members list so the cloud app boots into the
* given workspace with the given roster (drives the original-owner gate).
*/
export async function mockWorkspace(
page: Page,
ws: WorkspaceWithRole,
members: Member[]
) {
await page.route('**/api/workspaces', async (route) => {
if (route.request().method() !== 'GET') return route.fallback()
await route.fulfill(jsonRoute({ workspaces: [ws] }))
})
await page.route('**/api/auth/token', (r) =>
r.fulfill(
jsonRoute({
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: { id: ws.id, name: ws.name, type: ws.type },
role: ws.role,
permissions: []
})
)
)
await page.route('**/api/workspace/members**', (r) =>
r.fulfill(
jsonRoute({
members,
pagination: { offset: 0, limit: 50, total: members.length }
})
)
)
}

View File

@@ -0,0 +1,194 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
BillingBalanceResponse,
BillingStatusResponse
} from '@/platform/workspace/api/workspaceApi'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
/**
* Billing facade consumers — FE-933 (B3) regression.
*
* The repointed surfaces (avatar popover tier badge / balance, free-tier
* dialog renewal date) must keep rendering from `useBillingContext`. The facade
* selects its backend by flag: `team_workspaces_enabled: false` routes through
* the legacy `/customers/*` endpoints, while `true` routes a personal workspace
* through the workspace `/api/billing/*` endpoints. Both shapes are mocked here.
* Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
* against fully mocked endpoints — same pattern as creditsTile.spec.ts.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
const jsonRoute = (body: unknown) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
// The workspace `/api/billing/status` shape mirrors the legacy subscription
// status; map the fields so a single test fixture drives both backends.
const toWorkspaceStatus = (
s: CloudSubscriptionStatusResponse
): BillingStatusResponse => ({
is_active: s.is_active ?? false,
subscription_tier: s.subscription_tier ?? undefined,
subscription_duration: s.subscription_duration ?? undefined,
renewal_date: s.renewal_date ?? undefined,
cancel_at: s.end_date ?? undefined,
has_funds: s.has_fund ?? true
})
const mockBalance: BillingBalanceResponse = {
amount_micros: 6000, // -> 12,660 credits
currency: 'usd',
effective_balance_micros: 6000
}
async function mockCloudBoot(
page: Page,
subscriptionStatus: CloudSubscriptionStatusResponse,
remoteConfig: RemoteConfig = { team_workspaces_enabled: false }
) {
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(remoteConfig)))
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// TutorialCompleted suppresses the new-user template browser, whose modal
// overlay would otherwise intercept clicks on the topbar.
await page.route('**/api/settings', (r) =>
r.fulfill(jsonRoute({ 'Comfy.TutorialCompleted': true }))
)
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace.
await page.route('**/api/workspaces', (r) =>
r.fulfill(
jsonRoute({
workspaces: [
{
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
}
]
})
)
)
// Legacy backend (team_workspaces_enabled: false).
await page.route('**/customers/cloud-subscription-status', (r) =>
r.fulfill(jsonRoute(subscriptionStatus))
)
await page.route('**/customers/balance', (r) =>
r.fulfill(jsonRoute(mockBalance))
)
// Workspace backend (team_workspaces_enabled: true) — a personal workspace
// now routes through `/api/billing/*`.
await page.route('**/api/billing/status', (r) =>
r.fulfill(jsonRoute(toWorkspaceStatus(subscriptionStatus)))
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(jsonRoute(mockBalance))
)
await page.route('**/api/billing/plans', (r) =>
r.fulfill(jsonRoute({ plans: [] }))
)
}
async function bootApp(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
}
test.describe('Billing facade consumers (FE-933)', { tag: '@cloud' }, () => {
test('avatar popover renders tier badge and balance from the facade', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page, {
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
renewal_date: '2099-02-20T10:00:00Z',
end_date: null
})
await bootApp(page)
await page.getByRole('button', { name: 'Current user' }).click()
const popover = page.locator('.current-user-popover')
await expect(popover).toBeVisible()
await expect(popover.getByText('Pro', { exact: true })).toBeVisible()
await expect(popover.getByText('12,660')).toBeVisible()
await expect(popover.getByTestId('add-credits-button')).toBeVisible()
})
test('free-tier dialog shows the renewal date from the facade', async ({
page
}) => {
test.setTimeout(60_000)
// Boots with team workspaces enabled (production shape); the facade routes a
// personal workspace through the workspace `/api/billing/*` endpoints. With
// subscription gating on, an inactive FREE user gets the "Subscribe to run"
// button, which opens the free-tier dialog on click. (refreshRemoteConfig
// overwrites window.__CONFIG__ from /api/features, so the flags must come
// from the features mock, not an init script.)
await mockCloudBoot(
page,
{
is_active: false,
subscription_tier: 'FREE',
subscription_duration: 'MONTHLY',
// 10:00Z keeps the en-US calendar date stable across CI timezones.
renewal_date: '2099-02-20T10:00:00Z',
end_date: null
},
{ team_workspaces_enabled: true, subscription_required: true }
)
await bootApp(page)
await page.getByTestId('subscribe-to-run-button').click()
// T5: the dialog must source the date from facade renewalDate — when this
// line read the legacy store it silently vanished for team users.
await expect(
page.getByText('Your credits refresh on Feb 20, 2099.')
).toBeVisible()
})
})

View File

@@ -0,0 +1,86 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
/**
* getSurveyCompletedStatus fails safe: a transient 401 on `/` must not bounce a
* working user to /cloud/survey, while a genuine 404 (survey never submitted)
* must still route a not-completed user there. Drives a raw `page` so the cloud
* app boots against fully mocked endpoints (`comfyPage` would reach the OSS
* devtools backend during setup).
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// `/api/features` is the remote-config source: production builds resolve
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
// dev-only). Enable the survey so the gate is actually live.
const BOOT_FEATURES = {
onboarding_survey_enabled: true
} satisfies RemoteConfig
// Genuine "not completed": the cloud backend returns 404 for a survey key that
// was never stored. This is the response that must still route to the survey.
async function mockSurveyNotCompleted(page: Page) {
await page.route('**/api/settings/onboarding_survey', (r) =>
r.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ code: 'NOT_FOUND', message: 'Setting not found' })
})
)
}
// Transient auth failure: a stale workspace token makes the authenticated
// survey check 401 — the hiccup that used to bounce working users.
async function mockSurveyTransient401(page: Page) {
await page.route('**/api/settings/onboarding_survey', (r) =>
r.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({
code: 'UNAUTHORIZED',
message: 'User authentication required'
})
})
)
}
test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
test('a transient 401 on the survey check does not bounce a working user to the survey', async ({
page
}) => {
test.slow()
await mockCloudBoot(page, { features: BOOT_FEATURES })
await mockSurveyTransient401(page)
await bootCloud(page)
await page.goto(APP_URL)
// The full app boots — CloudSurveyView is a standalone onboarding view, so
// reaching the extension manager proves we landed on the working app and
// the transient 401 was treated as "completed", not a bounce.
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await expect(page).not.toHaveURL(/\/cloud\/survey/)
})
test('a not-completed (404) user landing on / is routed to the survey', async ({
page
}) => {
test.slow()
await mockCloudBoot(page, { features: BOOT_FEATURES })
await mockSurveyNotCompleted(page)
await bootCloud(page)
await page.goto(APP_URL)
await expect(page).toHaveURL(/\/cloud\/survey/, { timeout: 45_000 })
})
})

View File

@@ -0,0 +1,181 @@
import { expect } from '@playwright/test'
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
BillingStatusResponse,
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
}
// With team workspaces enabled, the facade routes a personal workspace through
// `/api/billing/*`. The cancelled-but-active state maps to `is_active: true`
// with `subscription_status: 'canceled'`; a paid tier keeps "Add credits"
// visible (free tier would swap it for "Upgrade to add credits").
const mockBillingStatus: BillingStatusResponse = {
is_active: true,
subscription_status: 'canceled',
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
has_funds: true,
cancel_at: FUTURE_DATE,
renewal_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)
})
)
// Flag-on (team workspaces enabled) routes a personal workspace through the
// workspace billing endpoints, so the popover sources its data from here.
await page.route('**/api/billing/status', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockBillingStatus)
})
)
await page.route('**/api/billing/balance', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockBalance)
})
)
await page.route('**/api/billing/plans', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ plans: [] })
})
)
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

@@ -0,0 +1,264 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceApi'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
// devtools backend during setup.
/**
* Credits tile (Settings ▸ Workspace ▸ Plan & Credits) — DES-247 / FE-964.
*
* The credits tile only lives inside the authenticated cloud app, which the
* shared `comfyPage` fixture can't boot (it expects the OSS devtools backend).
* Instead this drives a raw page: mock Firebase auth + every boot endpoint so
* the cloud app initializes against fully stubbed data. With team workspaces
* enabled the facade routes a personal workspace through the workspace
* `/api/billing/*` endpoints (mocked with an active Pro subscription); the
* legacy `/customers/*` shapes are mocked too for the flag-off path. The tile
* should then render its total / progress bar / monthly+additional breakdown /
* add-credits.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
const jsonRoute = (body: unknown) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
// Legacy `/customers/balance` and workspace `/api/billing/balance` share the
// same response shape, so one body fulfills both endpoints.
const balanceRoute = (balance: {
amount: number
monthly: number
prepaid: number
}) =>
jsonRoute({
amount_micros: balance.amount,
currency: 'usd',
effective_balance_micros: balance.amount,
cloud_credit_balance_micros: balance.monthly,
prepaid_balance_micros: balance.prepaid
})
// 6000 -> 12,660 total; 5000 -> 10,550 monthly remaining; 1000 -> 2,110 extra.
const DEFAULT_BALANCE = { amount: 6000, monthly: 5000, prepaid: 1000 }
const mockBillingStatus: BillingStatusResponse = {
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
renewal_date: '2099-02-20T12:00:00Z',
has_funds: true
}
async function mockCloudBoot(page: Page) {
// Frontend-origin boot endpoints (proxied to the backend in production).
// `/api/features` is the remote-config source: production builds resolve
// `teamWorkspacesEnabled` from it (the `ff:` localStorage override is
// dev-only), and the flag gates the Workspace settings panel.
await page.route('**/api/features', (r) =>
r.fulfill(
jsonRoute({ team_workspaces_enabled: true } satisfies RemoteConfig)
)
)
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
// Include the mock user so the multi-user select screen auto-selects it
// (paired with the `Comfy.userId` localStorage seed below).
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// Non-empty settings with a completed tutorial keep the cloud app from
// booting as a new user, whose Workflow Templates dialog would otherwise
// auto-open and intercept the Settings click behind its modal backdrop.
await page.route('**/api/settings', (r) =>
r.fulfill(jsonRoute({ 'Comfy.TutorialCompleted': true }))
)
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace.
await page.route('**/api/workspaces', (r) =>
r.fulfill(
jsonRoute({
workspaces: [
{
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
}
]
})
)
)
// Legacy billing (flag-off path, api.comfy.org/customers/*).
await page.route('**/customers/cloud-subscription-status', (r) =>
r.fulfill(
jsonRoute({
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
renewal_date: '2099-02-20T12:00:00Z',
end_date: null
})
)
)
await page.route('**/customers/balance', (r) =>
r.fulfill(balanceRoute(DEFAULT_BALANCE))
)
// Workspace billing (flag-on path) — a personal workspace now routes through
// `/api/billing/*`.
await page.route('**/api/billing/status', (r) =>
r.fulfill(jsonRoute(mockBillingStatus))
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(balanceRoute(DEFAULT_BALANCE))
)
await page.route('**/api/billing/plans', (r) =>
r.fulfill(jsonRoute({ plans: [] }))
)
}
async function mockBalance(
page: Page,
balance: { amount: number; monthly: number; prepaid: number }
) {
await page.unroute('**/customers/balance')
await page.unroute('**/api/billing/balance')
await page.route('**/customers/balance', (r) =>
r.fulfill(balanceRoute(balance))
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(balanceRoute(balance))
)
}
/** Boots the mocked cloud app and opens Settings ▸ Workspace ▸ Plan & Credits. */
async function openPlanAndCredits(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
// Pre-select the mock user to skip the user-select screen.
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
// Open Settings ▸ Workspace.
await page
.getByRole('button', { name: /^Settings/ })
.first()
.click()
const dialog = page.getByTestId('settings-dialog')
await expect(dialog).toBeVisible()
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
return dialog.getByRole('main')
}
test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
test('renders the unified tile with breakdown and add-credits', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
const content = await openPlanAndCredits(page)
// Total + remaining suffix (Pro monthly allowance = 21,100; remaining
// 10,550 -> used 10,550).
await expect(content.getByText('Total credits')).toBeVisible()
await expect(content.getByText('12,660')).toBeVisible()
// Monthly usage bar header + used / left-of-total labels.
await expect(content.getByText('Monthly', { exact: true })).toBeVisible()
await expect(content.getByText(/Refills Feb/)).toBeVisible()
await expect(content.getByText('10,550 used')).toBeVisible()
await expect(content.getByText('10,550 left of 21,100')).toBeVisible()
// Additional credits row + subtitle.
await expect(content.getByText('Additional credits')).toBeVisible()
await expect(content.getByText('2,110')).toBeVisible()
await expect(content.getByText('Used after monthly runs out')).toBeVisible()
// Permission-gated add-credits action (personal owner can top up).
await expect(
content.getByRole('button', { name: 'Add credits' })
).toBeVisible()
// Narrow container (DES-247 responsive variants): drop the used/remaining
// labels and the breakdown subtitle, compact the monthly summary numbers.
await page.setViewportSize({ width: 360, height: 800 })
await expect(content.getByText('10,550 used')).toBeHidden()
await expect(content.getByText('remaining', { exact: true })).toBeHidden()
await expect(content.getByText('Used after monthly runs out')).toBeHidden()
await expect(content.getByText('10,550 left of 21,100')).toBeHidden()
await expect(content.getByText('11K left of 21K')).toBeVisible()
})
test('renders the depleted-credit empty states', async ({ page }) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
// Monthly allowance fully spent; additional credits keep generation going.
await mockBalance(page, { amount: 1000, monthly: 0, prepaid: 1000 })
const content = await openPlanAndCredits(page)
// 0-monthly state: depletion notice + IN USE badge on additional credits.
await expect(
content.getByText('Monthly credits are used up. Refills Feb 20')
).toBeVisible()
await expect(
content.getByText("You're now spending additional credits.")
).toBeVisible()
await expect(content.getByText('In use')).toBeVisible()
await expect(content.getByText('0 left of 21,100')).toBeVisible()
// Drain the remaining additional credits and refresh the tile: the
// out-of-credits notice takes over and the badge drops.
await mockBalance(page, { amount: 0, monthly: 0, prepaid: 0 })
await content.getByRole('button', { name: 'Refresh credits' }).click()
await expect(
content.getByText("You're out of credits. Credits refill Feb 20")
).toBeVisible()
await expect(
content.getByText('Add more credits to continue generating.')
).toBeVisible()
await expect(content.getByText('In use')).toBeHidden()
await expect(
content.getByRole('button', { name: 'Add credits' })
).toBeVisible()
})
})

View File

@@ -0,0 +1,264 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import type { Member } from '@/platform/workspace/api/workspaceApi'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
CREATOR,
MEMBER_JANE,
MEMBER_JOHN,
VIEWER
} from '@e2e/fixtures/data/cloudWorkspace'
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
// devtools backend during setup.
/**
* Member role change (Settings ▸ Workspace ▸ Members) — Figma 2993-15512.
*
* The viewer is a promoted owner (not the workspace creator), so the spec can
* distinguish the creator guard from the self guard: the creator row and the
* viewer's own row hide the row menu, every other row exposes
* "Change role " (Owner / Member) plus "Remove member". Promoting a member
* sends PATCH /api/workspace/members/:id {role}, flips the Role column,
* re-sorts the row under the creator, and the promoted owner stays demotable.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
async function openMembersTab(page: Page): Promise<Locator> {
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await page
.getByRole('button', { name: /^Settings/ })
.first()
.click()
const dialog = page.getByTestId('settings-dialog')
await expect(dialog).toBeVisible()
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
const content = dialog.getByRole('main')
await content.getByRole('tab', { name: /Members/ }).click()
await expect(content.getByText('4 of 30 members')).toBeVisible()
return content
}
function memberRow(content: Locator, email: string): Locator {
return content
.locator('div.grid')
.filter({ has: content.page().getByText(email, { exact: true }) })
}
function menuButton(row: Locator): Locator {
return row.getByRole('button', { name: 'More Options' })
}
// Reka submenus open on real pointer travel or keyboard; Playwright's
// synthetic hover doesn't trigger the pointermove handler, so drive the
// subtrigger with ArrowRight instead.
async function openChangeRoleSubmenu(page: Page) {
const trigger = page.getByRole('menuitem', { name: 'Change role' })
await expect(trigger).toBeVisible()
await trigger.press('ArrowRight')
await expect(
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
).toBeVisible()
}
test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
test.describe.configure({ timeout: 60_000 })
test('row menus respect creator and self guards', async ({ page }) => {
await new CloudWorkspaceMockHelper(page).setup()
const content = await openMembersTab(page)
// US8/US9 — no row actions on the creator row (Liz) nor on the viewer's
// own row; the two plain members each expose a menu.
await expect(
menuButton(memberRow(content, MEMBER_JOHN.email))
).toBeVisible()
await expect(
menuButton(memberRow(content, MEMBER_JANE.email))
).toBeVisible()
await expect(menuButton(memberRow(content, CREATOR.email))).toHaveCount(0)
await expect(menuButton(memberRow(content, VIEWER.email))).toHaveCount(0)
// US1/US12 — the row menu exposes Change role and the FE-768 remove flow.
await menuButton(memberRow(content, MEMBER_JANE.email)).click()
await expect(
page.getByRole('menuitem', { name: 'Change role' })
).toBeVisible()
await page.getByRole('menuitem', { name: 'Remove member' }).click()
await expect(page.getByText('Remove this member?')).toBeVisible()
})
test('selecting the current role is a no-op', async ({ page }) => {
const state = await new CloudWorkspaceMockHelper(page).setup()
const content = await openMembersTab(page)
const janeRow = memberRow(content, MEMBER_JANE.email)
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
// The current role is a checked radio item so assistive tech can announce
// which role is active.
await expect(
page.getByRole('menuitemradio', { name: 'Member', exact: true })
).toHaveAttribute('aria-checked', 'true')
await expect(
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
).toHaveAttribute('aria-checked', 'false')
await page
.getByRole('menuitemradio', { name: 'Member', exact: true })
.click()
await expect(page.getByRole('heading', { name: /an owner\?/ })).toHaveCount(
0
)
expect(state.patches).toHaveLength(0)
})
test('promote dialog shows the Figma copy and cancelling keeps the role', async ({
page
}) => {
const state = await new CloudWorkspaceMockHelper(page).setup()
const content = await openMembersTab(page)
const janeRow = memberRow(content, MEMBER_JANE.email)
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Owner', exact: true })
.click()
await expect(
page.getByRole('heading', { name: 'Make Jane an owner?' })
).toBeVisible()
await expect(page.getByText("They'll be able to:")).toBeVisible()
await expect(page.getByText('Add additional credits')).toBeVisible()
await expect(
page.getByText('Manage members, payment methods, and workspace settings')
).toBeVisible()
await expect(
page.getByText(
'Promote and demote other owners (except the workspace creator).'
)
).toBeVisible()
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(
page.getByRole('heading', { name: 'Make Jane an owner?' })
).toHaveCount(0)
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
expect(state.patches).toHaveLength(0)
})
test('promoting a member re-sorts the row under the creator and stays demotable', async ({
page
}) => {
const state = await new CloudWorkspaceMockHelper(page).setup()
const content = await openMembersTab(page)
const emails = content.getByText(/@test\.comfy\.org/)
await expect(emails).toHaveText([
CREATOR.email,
VIEWER.email,
MEMBER_JOHN.email,
MEMBER_JANE.email
])
const janeRow = memberRow(content, MEMBER_JANE.email)
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Owner', exact: true })
.click()
await page.getByRole('button', { name: 'Make owner' }).click()
await expect(page.getByText('Role updated')).toBeVisible()
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
await expect(emails).toHaveText([
CREATOR.email,
VIEWER.email,
MEMBER_JANE.email,
MEMBER_JOHN.email
])
expect(state.patches).toEqual([
{
url: expect.stringContaining('/api/workspace/members/u-jane'),
role: 'owner'
}
])
// The promoted owner keeps its row menu (still demotable).
await expect(menuButton(janeRow)).toBeVisible()
})
test('demoting an owner returns them to member', async ({ page }) => {
const ownerJane: Member = { ...MEMBER_JANE, role: 'owner' }
const state = await new CloudWorkspaceMockHelper(page).setup([
CREATOR,
VIEWER,
ownerJane,
MEMBER_JOHN
])
const content = await openMembersTab(page)
const janeRow = memberRow(content, MEMBER_JANE.email)
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Member', exact: true })
.click()
await expect(
page.getByRole('heading', { name: 'Demote Jane to member?' })
).toBeVisible()
await expect(page.getByText("They'll lose admin access.")).toBeVisible()
await page.getByRole('button', { name: 'Demote to member' }).click()
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
expect(state.patches).toEqual([
{
url: expect.stringContaining('/api/workspace/members/u-jane'),
role: 'member'
}
])
})
test('failed role change keeps the dialog open with an error toast', async ({
page
}) => {
await new CloudWorkspaceMockHelper(page).setup()
// Override the member route so PATCH fails after boot succeeds.
await page.route('**/api/workspace/members/**', (route) =>
route.request().method() === 'PATCH'
? route.fulfill({ status: 500, body: '{}' })
: route.fallback()
)
const content = await openMembersTab(page)
const janeRow = memberRow(content, MEMBER_JANE.email)
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Owner', exact: true })
.click()
await page.getByRole('button', { name: 'Make owner' }).click()
// US10 — error toast, dialog stays open, role unchanged.
await expect(page.getByText('Failed to update role')).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Make Jane an owner?' })
).toBeVisible()
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
})
})

View File

@@ -0,0 +1,128 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
Member,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockBilling } from '@e2e/fixtures/utils/cloudBillingMocks'
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
import {
member,
mockWorkspace,
workspace
} from '@e2e/fixtures/utils/workspaceMocks'
/**
* The `?pricing=` deep link opens the pricing table on app load, gated to the
* original owner (canManageSubscriptionLifecycle). Drives a raw `page` so the
* cloud app boots against fully mocked endpoints, like the survey-gate spec.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// CloudAuthHelper.mockAuth() signs in as this email; the original-owner gate
// matches it against the members self-row.
const SELF_EMAIL = 'e2e@test.comfy.org'
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
// Disable the experimental Asset API: with it on (cloud default) the unmocked
// asset endpoints 403 and workflow restore throws uncaught, aborting the
// GraphCanvas onMounted chain before the deep-link loader.
const BOOT_SETTINGS = { 'Comfy.Assets.UseAssetAPI': false }
// The deep-link loader runs at the tail of GraphCanvas onMounted, so the boot
// chain must not throw before it: a missing settings subpath, prompt exec_info,
// or queue status each abort that chain.
async function mockGraphBootExtras(page: Page) {
// Boot only reads these; fall back on any write so an unexpected POST/PUT
// surfaces instead of being masked by a blanket 200.
await page.route('**/api/settings/**', (route) => {
if (route.request().method() !== 'GET') return route.fallback()
return route.fulfill(jsonRoute({}))
})
await page.route('**/api/prompt', (route) => {
if (route.request().method() !== 'GET') return route.fallback()
return route.fulfill(jsonRoute({ exec_info: { queue_remaining: 0 } }))
})
await page.route('**/api/queue', (route) => {
if (route.request().method() !== 'GET') return route.fallback()
return route.fulfill(jsonRoute({ queue_running: [], queue_pending: [] }))
})
}
async function setupCloudApp(
page: Page,
ws: WorkspaceWithRole,
members: Member[]
) {
await mockCloudBoot(page, {
features: BOOT_FEATURES,
settings: BOOT_SETTINGS
})
await mockGraphBootExtras(page)
await mockBilling(page)
await mockWorkspace(page, ws, members)
await bootCloud(page)
}
const pricingHeading = (page: Page) =>
page.getByRole('heading', { name: 'Choose a Plan' })
test.describe('Pricing table deep link', { tag: '@cloud' }, () => {
test('opens the pricing table for a personal owner', async ({ page }) => {
test.slow()
await setupCloudApp(page, workspace('personal', 'owner'), [])
await page.goto(`${APP_URL}/?pricing=1`)
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
await expect(page).not.toHaveURL(/[?&]pricing=/)
})
test('opens on the Team tab for ?pricing=team', async ({ page }) => {
test.slow()
await setupCloudApp(page, workspace('personal', 'owner'), [])
await page.goto(`${APP_URL}/?pricing=team`)
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
await expect(
page.getByRole('button', { name: 'For Teams' })
).toHaveAttribute('aria-pressed', 'true')
})
test('opens for a team original owner', async ({ page }) => {
test.slow()
await setupCloudApp(page, workspace('team', 'owner'), [
member({ email: SELF_EMAIL, role: 'owner', is_original_owner: true })
])
await page.goto(`${APP_URL}/?pricing=1`)
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
})
test('is a silent no-op for a team member', async ({ page }) => {
test.slow()
await setupCloudApp(page, workspace('team', 'member'), [
member({
email: 'creator@test.comfy.org',
role: 'owner',
is_original_owner: true
}),
member({ email: SELF_EMAIL, role: 'member' })
])
await page.goto(`${APP_URL}/?pricing=1`)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await expect(page).not.toHaveURL(/[?&]pricing=/)
await expect(pricingHeading(page)).toBeHidden()
})
})

View File

@@ -12,11 +12,10 @@ const WORKFLOW = 'missing/nested_subgraph_installed_model'
const OUTER_SUBGRAPH_NODE_ID = '205'
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
const LOTUS_DIFFUSION_MODEL: Asset = {
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
id: 'test-lotus-depth-d-v1-1',
name: LOTUS_MODEL_NAME,
asset_hash:
'blake3:0000000000000000000000000000000000000000000000000000000000000203',
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000203',
size: 1_024,
mime_type: 'application/octet-stream',
tags: ['models', 'diffusion_models'],

View File

@@ -23,11 +23,31 @@ const plainVideoFileName = 'plain_video.mp4'
const graphDropPosition = { x: 500, y: 300 }
const missingMediaUploadObservationMs = 1_000
const missingMediaUploadPollMs = 100
const emptyMediaLoaderNodes = [
{
nodeType: 'LoadImage',
widgetName: 'image',
serverOnlyOption: 'server-only-image.png',
position: { x: 150, y: 150 }
},
{
nodeType: 'LoadVideo',
widgetName: 'file',
serverOnlyOption: 'server-only-video.mp4',
position: { x: 450, y: 150 }
},
{
nodeType: 'LoadAudio',
widgetName: 'audio',
serverOnlyOption: 'server-only-audio.wav',
position: { x: 750, y: 150 }
}
]
const cloudOutputAsset: Asset = {
const cloudOutputAsset: Asset & { hash?: string } = {
id: 'test-output-hash-001',
name: 'ComfyUI_00001_.png',
asset_hash: outputHash,
hash: outputHash,
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],
@@ -36,10 +56,10 @@ const cloudOutputAsset: Asset = {
last_access_time: '2026-05-01T00:00:00Z'
}
const cloudUploadedVideoAsset: Asset = {
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
id: 'test-uploaded-video-001',
name: plainVideoFileName,
asset_hash: plainVideoFileName,
hash: plainVideoFileName,
size: 1_024,
mime_type: 'video/mp4',
tags: ['input'],
@@ -50,10 +70,10 @@ const cloudUploadedVideoAsset: Asset = {
// The Cloud test app starts with a default LoadImage node. Keep that baseline
// input resolvable so this spec only observes the media it creates.
const cloudDefaultGraphInputAsset: Asset = {
const cloudDefaultGraphInputAsset: Asset & { hash?: string } = {
id: 'test-default-input-001',
name: '00000000000000000000000Aexample.png',
asset_hash: '00000000000000000000000Aexample.png',
hash: '00000000000000000000000Aexample.png',
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
@@ -66,12 +86,168 @@ interface CloudUploadAssetState {
isUploadedAssetAvailable: boolean
}
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset])
type ObjectInfoResponse = Record<
string,
{ input?: { required?: Record<string, unknown> } }
>
function setComboInputOptions(
objectInfo: ObjectInfoResponse,
nodeType: string,
inputName: string,
values: string[]
) {
const nodeInfo = objectInfo[nodeType]
if (!nodeInfo) {
throw new Error(`Missing object_info entry for ${nodeType}`)
}
const requiredInputs = nodeInfo.input?.required
if (!requiredInputs) {
throw new Error(`Missing required inputs for ${nodeType}`)
}
const input = requiredInputs[inputName]
if (!Array.isArray(input)) {
throw new Error(`Expected ${nodeType}.${inputName} to be a combo input`)
}
const [valuesOrType, options] = input
const optionsObject =
options && typeof options === 'object' && !Array.isArray(options)
if (Array.isArray(valuesOrType)) {
input[0] = values
} else if (valuesOrType !== 'COMBO') {
throw new Error(`Expected ${nodeType}.${inputName} to have combo options`)
}
if (optionsObject) {
Object.assign(options, { options: values })
} else if (!Array.isArray(valuesOrType)) {
throw new Error(
`Expected ${nodeType}.${inputName} to have options metadata`
)
}
}
async function routeCloudBootstrapApis(page: Page) {
await page.route('**/api/settings**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
})
await page.route('**/api/userdata**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([])
})
})
await page.route('**/i18n', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
})
await page.route('**/customers/cloud-subscription-status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ is_active: true })
})
})
}
async function routeSetupObjectInfo(
page: Page,
customize?: (objectInfo: ObjectInfoResponse) => void
) {
const setupApiUrl =
process.env.PLAYWRIGHT_SETUP_API_URL ?? 'http://127.0.0.1:8188'
const objectInfoUrl = new URL('/object_info', setupApiUrl).toString()
const objectInfoRouteHandler = async (route: Route) => {
try {
const response = await fetch(objectInfoUrl, {
signal: AbortSignal.timeout(5_000)
})
if (!response.ok) {
await route.fulfill({
status: response.status,
contentType: response.headers.get('content-type') ?? 'text/plain',
body: await response.text()
})
return
}
const objectInfo = (await response.json()) as ObjectInfoResponse
customize?.(objectInfo)
await route.fulfill({
status: response.status,
contentType: 'application/json',
body: JSON.stringify(objectInfo)
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await route.fulfill({
status: 502,
contentType: 'application/json',
body: JSON.stringify({
error: `Failed to fetch setup object_info from ${objectInfoUrl}: ${message}`
})
})
}
}
await page.route('**/object_info', objectInfoRouteHandler)
return async () =>
await page.unroute('**/object_info', objectInfoRouteHandler)
}
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page)
try {
await use(page)
} finally {
await unrouteObjectInfo()
}
}
})
const cloudEmptyMediaInputsTest = createCloudAssetsFixture([]).extend({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page, (objectInfo) => {
for (const node of emptyMediaLoaderNodes) {
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
node.serverOnlyOption
])
}
})
try {
await use(page)
} finally {
await unrouteObjectInfo()
}
}
})
const cloudUploadAssetStateByPage = new WeakMap<Page, CloudUploadAssetState>()
const cloudUploadRaceTest = comfyPageFixture.extend<{
markUploadedCloudAssetAvailable: () => void
}>({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page)
const state: CloudUploadAssetState = {
isUploadedAssetAvailable: false
}
@@ -106,9 +282,13 @@ const cloudUploadRaceTest = comfyPageFixture.extend<{
}
await page.route(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
await use(page)
await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
cloudUploadAssetStateByPage.delete(page)
try {
await use(page)
} finally {
await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
await unrouteObjectInfo()
cloudUploadAssetStateByPage.delete(page)
}
},
markUploadedCloudAssetAvailable: async ({ page }, use) => {
await use(() => {
@@ -139,7 +319,41 @@ async function expectNoErrorsTab(comfyPage: ComfyPage) {
).toBeHidden()
}
async function delayNextUpload(comfyPage: ComfyPage) {
async function closeTemplatesDialogIfOpen(comfyPage: ComfyPage) {
const templatesDialog = comfyPage.page.getByRole('dialog').filter({
has: comfyPage.templates.content
})
const closeButton = templatesDialog.getByRole('button', {
name: 'Close dialog'
})
await closeButton
.waitFor({ state: 'visible', timeout: 1_000 })
.catch(() => undefined)
if (await closeButton.isVisible()) {
await closeButton.click()
await expect(templatesDialog).toBeHidden()
}
}
async function getMediaLoaderWidgetValues(comfyPage: ComfyPage) {
return await comfyPage.page.evaluate((nodes) => {
return nodes.map(({ nodeType, widgetName }) => {
const node = window.app!.graph.nodes.find(
(graphNode) => graphNode.type === nodeType
)
const widget = node?.widgets?.find(
(candidate) => candidate.name === widgetName
)
return widget?.value ?? null
})
}, emptyMediaLoaderNodes)
}
async function delayNextUpload(
comfyPage: ComfyPage,
uploadResult?: { name: string; subfolder: string; type: 'input' }
) {
let releaseUpload!: () => void
let resolveUploadStarted!: () => void
const uploadStarted = new Promise<void>((resolve) => {
@@ -152,6 +366,14 @@ async function delayNextUpload(comfyPage: ComfyPage) {
const uploadRouteHandler = async (route: Route) => {
resolveUploadStarted()
await release
if (uploadResult) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(uploadResult)
})
return
}
await route.continue()
}
@@ -295,12 +517,51 @@ ossTest.describe(
}
)
cloudEmptyMediaInputsTest.describe(
'Errors tab - Cloud empty media loader inputs',
{ tag: '@cloud' },
() => {
cloudEmptyMediaInputsTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
await closeTemplatesDialogIfOpen(comfyPage)
})
cloudEmptyMediaInputsTest(
'does not surface missing inputs after adding LoadImage, LoadVideo, and LoadAudio nodes with no cloud input assets',
async ({ cloudAssetRequests, comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
for (const node of emptyMediaLoaderNodes) {
await comfyPage.nodeOps.addNode(
node.nodeType,
undefined,
node.position
)
}
await expect
.poll(() =>
cloudAssetRequests.some((url) =>
assetRequestIncludesTag(url, 'input')
)
)
.toBe(true)
await expect
.poll(() => getMediaLoaderWidgetValues(comfyPage))
.toEqual(['', '', ''])
await expectNoErrorsTab(comfyPage)
}
)
}
)
cloudOutputTest.describe(
'Errors tab - Cloud missing media runtime sources',
{ tag: '@cloud' },
() => {
cloudOutputTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
await closeTemplatesDialogIfOpen(comfyPage)
})
cloudOutputTest(
@@ -329,13 +590,18 @@ cloudUploadRaceTest.describe(
() => {
cloudUploadRaceTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
await closeTemplatesDialogIfOpen(comfyPage)
})
cloudUploadRaceTest(
'does not surface missing media while dropped video upload is in progress',
async ({ comfyFiles, comfyPage, markUploadedCloudAssetAvailable }) => {
await comfyPage.nodeOps.clearGraph()
const delayedUpload = await delayNextUpload(comfyPage)
const delayedUpload = await delayNextUpload(comfyPage, {
name: plainVideoFileName,
subfolder: '',
type: 'input'
})
await comfyPage.dragDrop.dragAndDropFile(plainVideoFileName, {
dropPosition: graphDropPosition

View File

@@ -15,10 +15,6 @@ import { createMixedMediaJobs } from '@e2e/fixtures/helpers/AssetsHelper'
// fixtures — Playwright runs auto fixtures before the `comfyPage` fixture's
// internal `setup()`, so the page first-loads with mocks already in place.
// See cloud-asset-default.spec.ts for the same pattern.
//
// Use `waitForAssets()` not `waitForAssets(MIXED_JOBS.length)`: VirtualGrid can
// virtualize the 3D card out of the initial render (#11635). Filtering reads the
// full store, so the per-filter count assertions still cover the behavior.
const MIXED_JOBS = createMixedMediaJobs(['images', 'video', 'audio', '3D'])
@@ -117,7 +113,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
@@ -140,7 +136,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')
@@ -157,7 +153,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('video')
@@ -171,7 +167,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('audio')
@@ -183,7 +179,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
test('Selecting only "3D" hides non-3D assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('3d')
@@ -197,7 +193,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')
@@ -215,7 +211,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')

View File

@@ -2,9 +2,9 @@ import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { WorkflowTemplates } from '@/platform/workflow/templates/types/template'
import { getWav } from '@e2e/fixtures/components/AudioPreview'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { trackElementFlash } from '@e2e/fixtures/utils/flashDetector'
async function checkTemplateFileExists(
page: Page,
@@ -451,33 +451,57 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
)
}
)
})
test.describe(
'Templates deeplink (new user)',
{ tag: ['@slow', '@workflow'] },
() => {
test('templates dialog never flashes when first-time user opens a template link', async ({
comfyPage
}) => {
const templatesFlash = await trackElementFlash(
comfyPage.page,
TestIds.templates.content
)
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
await comfyPage.setup({
clearStorage: true,
url: '/?template=default'
test('Can open associated tutorial', async ({ comfyPage }) => {
const tutorialUrl = 'https://comfyanonymous.github.io/ComfyUI_examples/'
await comfyPage.page.route('**/templates/index.json', async (route) => {
const response = [
{
moduleName: 'default',
title: 'Test Templates',
type: 'image',
templates: [
{
name: 'template-with-tutorial',
title: 'Template with a tutorial',
mediaType: 'audio',
mediaSubtype: 'wav',
description: 'This template has a tutorial',
tutorialUrl
}
]
}
]
await route.fulfill({
status: 200,
body: JSON.stringify(response),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBeGreaterThan(0)
expect(await templatesFlash.hasFlashed()).toBe(false)
await expect(comfyPage.templates.content).toBeHidden()
})
}
)
await comfyPage.page.route('**/templates/**.wav', async (route) => {
await route.fulfill({
status: 200,
body: getWav(),
headers: {
'Content-Type': 'image/x-wav',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
const card = comfyPage.page.getByTestId(
'template-workflow-template-with-tutorial'
)
await card.hover()
const tutorialButton = card.getByRole('button', { name: 'See a tutorial' })
await expect(tutorialButton).toBeVisible()
const popupPromise = comfyPage.page.waitForEvent('popup', { timeout: 0 })
await tutorialButton.click()
const popup = await popupPromise
expect(popup.url()).toEqual(tutorialUrl)
})
})

View File

@@ -98,4 +98,43 @@ test.describe('Workspace switcher', { tag: '@cloud' }, () => {
expect(box).not.toBeNull()
expect(box!.height).toBeLessThan(SINGLE_LINE_MAX_HEIGHT_PX)
})
test('opens the switcher to the left of the profile menu without overlap', async ({
comfyPage
}) => {
const page = comfyPage.page
await comfyPage.toast.closeToasts()
await page.getByRole('button', { name: 'Current user' }).click()
await page.getByTestId('workspace-switcher-trigger').click()
const panel = page.getByTestId('workspace-switcher-panel')
await expect(panel).toBeVisible()
const profileMenu = page.locator('.current-user-popover')
const panelBox = await panel.boundingBox()
const profileBox = await profileMenu.boundingBox()
expect(panelBox).not.toBeNull()
expect(profileBox).not.toBeNull()
expect(panelBox!.x + panelBox!.width).toBeLessThanOrEqual(profileBox!.x)
})
test('opens the create-workspace dialog with DES-246 copy', async ({
comfyPage
}) => {
const page = comfyPage.page
await comfyPage.toast.closeToasts()
await page.getByRole('button', { name: 'Current user' }).click()
await page.getByTestId('workspace-switcher-trigger').click()
await page.getByText('Create a workspace').click()
await expect(
page.getByText(
'Workspaces keep your projects and files organized. Subscribe to a Team plan to invite members.'
)
).toBeVisible()
await expect(page.getByPlaceholder('Ex: Comfy Org')).toBeVisible()
})
})

19
global.d.ts vendored
View File

@@ -11,6 +11,18 @@ interface ImpactQueueFunction {
a?: unknown[][]
}
interface RewardfulGlobal {
referral?: string
affiliate?: { id?: string; token?: string; name?: string }
campaign?: { id?: string; name?: string }
}
interface RewardfulQueueFunction {
(method: 'ready', callback: () => void): void
(...args: unknown[]): void
q?: unknown[][]
}
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
interface GtagGetFieldValueMap {
@@ -37,6 +49,11 @@ interface Window {
posthog_project_token?: string
posthog_api_host?: string
posthog_config?: Record<string, unknown>
customer_io?: {
write_key?: string
site_id?: string
user_id?: string
}
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number
@@ -63,6 +80,8 @@ interface Window {
gtag?: GtagFunction
ire_o?: string
ire?: ImpactQueueFunction
rewardful?: RewardfulQueueFunction
Rewardful?: RewardfulGlobal
}
interface Navigator {

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<title>ComfyUI</title>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"

View File

@@ -5,9 +5,16 @@
"start_url": "/",
"icons": [
{
"src": "/assets/images/comfy-logo-single.svg",
"sizes": "any",
"type": "image/svg+xml"
"src": "/assets/images/comfy-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/assets/images/comfy-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"display": "standalone"

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.45.21",
"version": "1.45.15",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -60,7 +60,6 @@
"dependencies": {
"@alloc/quick-lru": "catalog:",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-desktop-bridge-types": "workspace:*",
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/fbx-exporter-three": "^1.0.1",
@@ -68,6 +67,7 @@
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@customerio/cdp-analytics-browser": "catalog:",
"@formkit/auto-animate": "catalog:",
"@iconify/json": "catalog:",
"@primeuix/forms": "catalog:",

View File

@@ -1,91 +0,0 @@
export interface ComfyDownloadProgress {
url: string
filename: string
directory?: string
progress: number
receivedBytes?: number
totalBytes?: number
speedBytesPerSec?: number
etaSeconds?: number
status:
| 'pending'
| 'downloading'
| 'paused'
| 'completed'
| 'error'
| 'cancelled'
error?: string
isImage?: boolean
}
export interface TerminalRestore {
buffer: string[]
size: { cols: number; rows: number }
exited: boolean
}
export interface LogsRestore {
installationId: string
buffer: string[]
}
export interface LogsOutputMsg {
installationId: string
text: string
}
export type ComfyDesktop2TelemetryValue = string | number | boolean | null
export type ComfyDesktop2TelemetryProperties = Record<
string,
ComfyDesktop2TelemetryValue | ComfyDesktop2TelemetryValue[]
>
export interface ComfyDesktop2TerminalBridge {
subscribe(installationId?: string): Promise<TerminalRestore>
unsubscribe(installationId?: string): Promise<void>
write(data: string, installationId?: string): Promise<void>
resize(cols: number, rows: number, installationId?: string): Promise<void>
restart(installationId?: string): Promise<TerminalRestore>
openPopout(): Promise<void>
onOutput(callback: (data: string) => void): () => void
onExited(callback: () => void): () => void
}
export interface ComfyDesktop2LogsBridge {
subscribe(installationId?: string): Promise<LogsRestore>
unsubscribe(installationId?: string): Promise<void>
openPopout(): Promise<void>
onOutput(callback: (msg: LogsOutputMsg) => void): () => void
}
export interface ComfyDesktop2TelemetryBridge {
capture(event: string, properties?: ComfyDesktop2TelemetryProperties): void
}
export interface ComfyDesktop2Bridge {
isRemote(): boolean
downloadModel?: (
url: string,
filename: string,
directory: string
) => Promise<boolean>
downloadAsset?: (
url: string,
filename: string,
authToken?: string
) => Promise<boolean>
pauseDownload?: (url: string) => Promise<boolean>
resumeDownload?: (url: string) => Promise<boolean>
cancelDownload?: (url: string) => Promise<boolean>
onDownloadProgress?: (
callback: (data: ComfyDownloadProgress) => void
) => () => void
reportTheme?: (bg: string, text: string) => void
Terminal?: ComfyDesktop2TerminalBridge
Logs?: ComfyDesktop2LogsBridge
Telemetry?: ComfyDesktop2TelemetryBridge
}
export type ComfyDesktop2BridgeImplementation = {
[K in keyof ComfyDesktop2Bridge]-?: NonNullable<ComfyDesktop2Bridge[K]>
}

View File

@@ -1 +0,0 @@
export * from './comfyDesktopBridge.js'

View File

@@ -1 +0,0 @@
export {}

View File

@@ -1,27 +0,0 @@
{
"name": "@comfyorg/comfyui-desktop-bridge-types",
"version": "0.1.2",
"description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge",
"homepage": "https://comfy.org",
"license": "MIT",
"author": {
"name": "Comfy Org",
"email": "support@comfy.org",
"url": "https://www.comfy.org"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Comfy-Org/ComfyUI_frontend.git"
},
"files": [
"comfyDesktopBridge.d.ts",
"index.d.ts",
"index.js"
],
"type": "module",
"main": "./index.js",
"types": "./index.d.ts",
"publishConfig": {
"access": "public"
}
}

View File

@@ -16,7 +16,7 @@
@plugin "./lucideStrokePlugin.js";
/* Safelist dynamic comfy icons for node library folders */
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,bytedance-mono,comfy-logo,credits,elevenlabs,extensions-blocks,file-output,gemini,gemini-mono,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
@@ -25,6 +25,7 @@
@theme {
--shadow-interface: var(--interface-panel-box-shadow);
--shadow-inset-highlight: inset 0 1px 0 0 rgb(from white r g b / 0.1);
--text-2xs: 0.625rem;
--text-2xs--line-height: calc(1 / 0.625);
@@ -54,6 +55,8 @@
--color-gold-500: #fdab34;
--color-gold-600: #fd9903;
--color-credit: #fabc25;
--color-coral-500: #f75951;
--color-coral-600: #e04e48;
--color-coral-700: #b33a3a;
@@ -65,6 +68,9 @@
--color-ocean-600: #2f687a;
--color-ocean-900: #253236;
--color-primary-comfy-ink: #211927;
--color-primary-comfy-canvas: #c2bfb9;
--color-danger-100: #c02323;
--color-danger-200: #d62952;
@@ -234,6 +240,8 @@
--secondary-background: var(--color-smoke-200);
--secondary-background-hover: var(--color-smoke-400);
--secondary-background-selected: var(--color-smoke-600);
--tertiary-background: var(--color-smoke-400);
--tertiary-background-hover: var(--color-smoke-500);
--base-background: var(--color-white);
--primary-background: var(--color-azure-400);
--primary-background-hover: var(--color-cobalt-800);
@@ -382,6 +390,8 @@
--secondary-background: var(--color-charcoal-600);
--secondary-background-hover: var(--color-charcoal-400);
--secondary-background-selected: var(--color-charcoal-200);
--tertiary-background: var(--color-charcoal-400);
--tertiary-background-hover: var(--color-charcoal-300);
--base-background: var(--color-charcoal-800);
--primary-background: var(--color-azure-600);
--primary-background-hover: var(--color-azure-400);
@@ -554,6 +564,8 @@
--color-secondary-background: var(--secondary-background);
--color-secondary-background-hover: var(--secondary-background-hover);
--color-secondary-background-selected: var(--secondary-background-selected);
--color-tertiary-background: var(--tertiary-background);
--color-tertiary-background-hover: var(--tertiary-background-hover);
--color-primary-background: var(--primary-background);
--color-primary-background-hover: var(--primary-background-hover);
--color-destructive-background: var(--destructive-background);

View File

@@ -0,0 +1,6 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M324.094 389.858L284.667 379.567V191.5L326.871 180.816C350.01 174.941 369.446 170.154 370.371 170.339C371.112 170.339 371.667 222.027 371.667 285.326V400.334L367.594 400.15C365.189 400.15 345.566 395.361 324.094 389.835V389.857V389.858Z"/>
<path d="M138.667 343.325C138.667 279.602 139.229 227.339 140.166 227.339C140.914 227.154 160.573 231.998 184.164 237.913L226.667 248.65L226.292 342.975L225.73 437.278L187.535 447.107C166.565 452.463 146.906 457.47 144.097 458.029L138.667 459.334V343.325Z"/>
<path d="M423.667 248.299C423.667 38.7081 423.853 27.4506 427.037 28.3797C428.722 28.9368 445.386 33.1843 463.921 37.8029C482.458 42.6075 500.807 47.2031 504.739 48.1312L511.667 49.9884L511.293 248.67L510.731 447.539L472.722 457.148C451.939 462.486 432.279 467.291 429.284 468.057L423.667 469.334V248.299Z"/>
<path d="M-0.333038 248.845C-0.333038 140.208 0.222275 51.334 1.14852 51.334C1.88822 51.334 21.3242 56.1412 44.4631 61.8769L86.667 72.583V248.66C86.667 345.267 86.296 424.55 85.9262 424.55C85.3709 424.55 65.7494 429.544 42.4262 435.466L-0.333038 446.334V248.823V248.844V248.845Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -0,0 +1,6 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.854 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -7,7 +7,8 @@
"type": "module",
"exports": {
"./formatUtil": "./src/formatUtil.ts",
"./networkUtil": "./src/networkUtil.ts"
"./networkUtil": "./src/networkUtil.ts",
"./piiUtil": "./src/piiUtil.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"

View File

@@ -112,7 +112,6 @@ describe('formatUtil', () => {
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
expect(getMediaTypeFromFilename('print.stl')).toBe('3D')
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
expect(getMediaTypeFromFilename('scan.ply')).toBe('3D')
})

View File

@@ -591,15 +591,7 @@ const IMAGE_EXTENSIONS = [
] as const
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac', 'opus'] as const
const THREE_D_EXTENSIONS = [
'obj',
'fbx',
'gltf',
'glb',
'stl',
'usdz',
'ply'
] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz', 'ply'] as const
const TEXT_EXTENSIONS = [
'txt',
'md',

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { createPostHogBeforeSend } from './piiUtil'
describe('createPostHogBeforeSend', () => {
const beforeSend = createPostHogBeforeSend()
it('returns null for null input', () => {
expect(beforeSend(null)).toBeNull()
})
it('strips all PII keys from properties, $set, and $set_once', () => {
const event = {
properties: {
email: 'a@example.com',
prompt: 'hello',
user_email: 'b@example.com',
$email: 'c@example.com',
method: 'google'
},
$set: {
email: 'd@example.com',
user_email: 'e@example.com',
$email: 'f@example.com',
name: 'keep me'
},
$set_once: {
email: 'g@example.com',
plan: 'free'
}
}
const result = beforeSend(event)!
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).not.toHaveProperty('prompt')
expect(result.properties).not.toHaveProperty('user_email')
expect(result.properties).not.toHaveProperty('$email')
expect(result.properties).toHaveProperty('method', 'google')
expect(result.$set).not.toHaveProperty('email')
expect(result.$set).not.toHaveProperty('user_email')
expect(result.$set).not.toHaveProperty('$email')
expect(result.$set).toHaveProperty('name', 'keep me')
expect(result.$set_once).not.toHaveProperty('email')
expect(result.$set_once).toHaveProperty('plan', 'free')
})
it('handles missing property bags gracefully', () => {
const event = { properties: { email: 'a@example.com', safe: true } }
const result = beforeSend(event)!
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).toHaveProperty('safe', true)
expect(result.$set).toBeUndefined()
expect(result.$set_once).toBeUndefined()
})
})

View File

@@ -0,0 +1,35 @@
const PII_KEYS = ['email', 'prompt', 'user_email', '$email'] as const
function stripPiiKeys(obj?: Record<string, unknown>): void {
if (!obj) return
for (const key of PII_KEYS) {
delete obj[key]
}
}
/**
* PostHog before_send hook that strips PII from all three property bags
* an event can carry: properties, $set, and $set_once.
*
* posthog.identify(id, { email }) lands in $set, not properties, so all
* three bags must be sanitized.
*
* Ref: posthog.com/tutorials/web-redact-properties
*/
interface PostHogEventLike {
properties?: Record<string, unknown>
$set?: Record<string, unknown>
$set_once?: Record<string, unknown>
}
export function createPostHogBeforeSend() {
return function beforeSend<E extends PostHogEventLike>(
event: E | null
): E | null {
if (!event) return null
stripPiiKeys(event.properties)
stripPiiKeys(event.$set)
stripPiiKeys(event.$set_once)
return event
}
}

134
pnpm-lock.yaml generated
View File

@@ -21,6 +21,9 @@ catalogs:
'@comfyorg/comfyui-electron-types':
specifier: 0.6.2
version: 0.6.2
'@customerio/cdp-analytics-browser':
specifier: ^0.5.3
version: 0.5.4
'@eslint/js':
specifier: ^9.39.1
version: 9.39.1
@@ -415,9 +418,6 @@ importers:
'@atlaskit/pragmatic-drag-and-drop':
specifier: ^1.3.1
version: 1.3.1
'@comfyorg/comfyui-desktop-bridge-types':
specifier: workspace:*
version: link:packages/comfyui-desktop-bridge-types
'@comfyorg/comfyui-electron-types':
specifier: 'catalog:'
version: 0.6.2
@@ -439,6 +439,9 @@ importers:
'@comfyorg/tailwind-utils':
specifier: workspace:*
version: link:packages/tailwind-utils
'@customerio/cdp-analytics-browser':
specifier: 'catalog:'
version: 0.5.4
'@formkit/auto-animate':
specifier: 'catalog:'
version: 0.9.0
@@ -989,8 +992,6 @@ importers:
specifier: 'catalog:'
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
packages/comfyui-desktop-bridge-types: {}
packages/design-system:
dependencies:
'@iconify-json/lucide':
@@ -1424,6 +1425,15 @@ packages:
peerDependencies:
postcss-selector-parser: ^7.0.0
'@customerio/cdp-analytics-browser@0.5.4':
resolution: {integrity: sha512-F2G+7fTXu7f+zXqXLA4ri8bEuwoxtB64Pjh2Mt/vvuZfT/PlpvP1bz550s7SPH3qzeSuOeVKny27Xc795cZCJg==}
'@customerio/cdp-analytics-core@0.5.4':
resolution: {integrity: sha512-JQcqfKHkXuCgrXHHUofxHVBF/0wpeEwmWItbSv8Kt1mVMcMKcVWBULc2HTbYDJvaad4tW+66A7JApL+lD1hZJQ==}
'@customerio/jist@0.1.8':
resolution: {integrity: sha512-MPiAm5rxu6+wQiEPwY+nV/5i7y67vJ0TvQpeQrOuATzWC45kgpu4YAJm+RlrpDOq35CK1C3utlPG/wI1F6ycXg==}
'@cyberalien/svg-utils@1.1.1':
resolution: {integrity: sha512-i05Cnpzeezf3eJAXLx7aFirTYYoq5D1XUItp1XsjqkerNJh//6BG9sOYHbiO7v0KYMvJAx3kosrZaRcNlQPdsA==}
@@ -2360,6 +2370,14 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@lukeed/csprng@1.1.0':
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
'@lukeed/uuid@2.0.1':
resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==}
engines: {node: '>=8'}
'@mdx-js/react@3.1.1':
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
peerDependencies:
@@ -3259,6 +3277,18 @@ packages:
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@segment/analytics.js-video-plugins@0.2.1':
resolution: {integrity: sha512-lZwCyEXT4aaHBLNK433okEKdxGAuyrVmop4BpQqQSJuRz0DglPZgd9B/XjiiWs1UyOankg2aNYMN3VcS8t4eSQ==}
'@segment/facade@3.4.10':
resolution: {integrity: sha512-xVQBbB/lNvk/u8+ey0kC/+g8pT3l0gCT8O2y9Z+StMMn3KAFAQ9w8xfgef67tJybktOKKU7pQGRPolRM1i1pdA==}
'@segment/isodate-traverse@1.1.1':
resolution: {integrity: sha512-+G6e1SgAUkcq0EDMi+SRLfT48TNlLPF3QnSgFGVs0V9F3o3fq/woQ2rHFlW20W0yy5NnCUH0QGU3Am2rZy/E3w==}
'@segment/isodate@1.0.3':
resolution: {integrity: sha512-BtanDuvJqnACFkeeYje7pWULVv8RgZaqKHWwGFnL/g/TH/CcZjkIVTfGDp/MAxmilYHUkrX70SqwnYSTNEaN7A==}
'@sentry-internal/browser-utils@10.32.1':
resolution: {integrity: sha512-sjLLep1es3rTkbtAdTtdpc/a6g7v7bK5YJiZJsUigoJ4NTiFeMI5uIDCxbH/tjJ1q23YE1LzVn7T96I+qBRjHA==}
engines: {node: '>=18'}
@@ -5047,6 +5077,9 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
customerio-gist-web@3.23.3:
resolution: {integrity: sha512-m0kxuTMajDTmHhmTsH+4nbgoxDMVYE0U/sZGJRp6jug5F4uS/TdiNuZ+ACv+eyizA/wh744xY1MQbfXzDmgL2g==}
cva@1.0.0-beta.4:
resolution: {integrity: sha512-F/JS9hScapq4DBVQXcK85l9U91M6ePeXoBMSp7vypzShoefUBxjQTo3g3935PUHgQd+IW77DjbPRIxugy4/GCQ==}
peerDependencies:
@@ -6280,6 +6313,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
js-cookie@3.0.1:
resolution: {integrity: sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==}
engines: {node: '>=12'}
js-cookie@3.0.5:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'}
@@ -6920,6 +6957,9 @@ packages:
resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==}
engines: {node: '>= 10'}
new-date@1.0.3:
resolution: {integrity: sha512-0fsVvQPbo2I18DT2zVHpezmeeNYV2JaJSrseiHLc17GNOxJzUdx5mvSigPu8LtIfZSij5i1wXnXFspEs2CD6hA==}
nlcst-to-string@4.0.0:
resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==}
@@ -6973,6 +7013,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
obj-case@0.2.1:
resolution: {integrity: sha512-PquYBBTy+Y6Ob/O2574XHhDtHJlV1cJHMCgW+rDRc9J5hhmRelJB3k5dTK/3cVmFVtzvAKuENeuLpoyTzMzkOg==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -7813,6 +7856,9 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
spark-md5@3.0.2:
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
@@ -8226,6 +8272,12 @@ packages:
unescape-js@1.1.4:
resolution: {integrity: sha512-42SD8NOQEhdYntEiUQdYq/1V/YHwr1HLwlHuTJB5InVVdOSbgI6xu8jK5q65yIzuFCfczzyDF/7hbGzVbyCw0g==}
unfetch@3.1.2:
resolution: {integrity: sha512-L0qrK7ZeAudGiKYw6nzFjnJ2D5WHblUBwmHIqtPS6oKUd+Hcpk7/hKsSmcHsTlpd1TbTNsiRBUKRq3bHLNIqIw==}
unfetch@4.2.0:
resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==}
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
@@ -8415,6 +8467,10 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
uuid@14.0.0:
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
hasBin: true
valibot@1.2.0:
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
peerDependencies:
@@ -9546,6 +9602,30 @@ snapshots:
dependencies:
postcss-selector-parser: 7.1.1
'@customerio/cdp-analytics-browser@0.5.4':
dependencies:
'@customerio/cdp-analytics-core': 0.5.4
'@lukeed/uuid': 2.0.1
'@segment/analytics.js-video-plugins': 0.2.1
'@segment/facade': 3.4.10
customerio-gist-web: 3.23.3
dset: 3.1.4
js-cookie: 3.0.1
node-fetch: 2.7.0
spark-md5: 3.0.2
tslib: 2.8.1
unfetch: 4.2.0
transitivePeerDependencies:
- encoding
'@customerio/cdp-analytics-core@0.5.4':
dependencies:
'@lukeed/uuid': 2.0.1
dset: 3.1.4
tslib: 2.8.1
'@customerio/jist@0.1.8': {}
'@cyberalien/svg-utils@1.1.1':
dependencies:
'@iconify/types': 2.0.0
@@ -10505,6 +10585,12 @@ snapshots:
- ws
- zod
'@lukeed/csprng@1.1.0': {}
'@lukeed/uuid@2.0.1':
dependencies:
'@lukeed/csprng': 1.1.0
'@mdx-js/react@3.1.1(@types/react@19.1.9)(react@19.2.4)':
dependencies:
'@types/mdx': 2.0.13
@@ -11134,6 +11220,23 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {}
'@segment/analytics.js-video-plugins@0.2.1':
dependencies:
unfetch: 3.1.2
'@segment/facade@3.4.10':
dependencies:
'@segment/isodate-traverse': 1.1.1
inherits: 2.0.4
new-date: 1.0.3
obj-case: 0.2.1
'@segment/isodate-traverse@1.1.1':
dependencies:
'@segment/isodate': 1.0.3
'@segment/isodate@1.0.3': {}
'@sentry-internal/browser-utils@10.32.1':
dependencies:
'@sentry/core': 10.32.1
@@ -13222,6 +13325,11 @@ snapshots:
csstype@3.2.3: {}
customerio-gist-web@3.23.3:
dependencies:
'@customerio/jist': 0.1.8
uuid: 14.0.0
cva@1.0.0-beta.4(typescript@5.9.3):
dependencies:
clsx: 2.1.1
@@ -14617,6 +14725,8 @@ snapshots:
js-cookie: 3.0.5
nopt: 7.2.1
js-cookie@3.0.1: {}
js-cookie@3.0.5: {}
js-stringify@1.0.2: {}
@@ -15428,6 +15538,10 @@ snapshots:
neotraverse@0.6.18: {}
new-date@1.0.3:
dependencies:
'@segment/isodate': 1.0.3
nlcst-to-string@4.0.0:
dependencies:
'@types/nlcst': 2.0.3
@@ -15475,6 +15589,8 @@ snapshots:
pathe: 2.0.3
tinyexec: 1.0.4
obj-case@0.2.1: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -16604,6 +16720,8 @@ snapshots:
space-separated-tokens@2.0.2: {}
spark-md5@3.0.2: {}
speakingurl@14.0.1: {}
sprintf-js@1.0.3: {}
@@ -17027,6 +17145,10 @@ snapshots:
dependencies:
string.fromcodepoint: 0.2.1
unfetch@3.1.2: {}
unfetch@4.2.0: {}
unicorn-magic@0.3.0: {}
unified@11.0.5:
@@ -17219,6 +17341,8 @@ snapshots:
uuid@11.1.0: {}
uuid@14.0.0: {}
valibot@1.2.0(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3

View File

@@ -13,6 +13,7 @@ catalog:
'@astrojs/sitemap': ^3.7.1
'@astrojs/vue': ^5.0.0
'@comfyorg/comfyui-electron-types': 0.6.2
'@customerio/cdp-analytics-browser': ^0.5.3
'@eslint/js': ^9.39.1
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -2,12 +2,6 @@ import fs from 'fs'
import path from 'path'
const mainPackage = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
const desktopBridgeTypesPackage = JSON.parse(
fs.readFileSync(
'./packages/comfyui-desktop-bridge-types/package.json',
'utf8'
)
)
// Create the types-only package.json
const typesPackage = {
@@ -22,9 +16,7 @@ const typesPackage = {
homepage: mainPackage.homepage,
description: `TypeScript definitions for ${mainPackage.name}`,
license: mainPackage.license,
dependencies: {
'@comfyorg/comfyui-desktop-bridge-types': desktopBridgeTypesPackage.version
},
dependencies: {},
peerDependencies: {
vue: mainPackage.dependencies.vue,
zod: mainPackage.dependencies.zod
@@ -42,3 +34,5 @@ fs.writeFileSync(
path.join(distDir, 'package.json'),
JSON.stringify(typesPackage, null, 2)
)
console.log('Types package.json have been prepared in the dist directory')

View File

@@ -87,6 +87,14 @@ vi.mock('@/scripts/app', () => ({
}
}))
const mockTrackUiButtonClicked = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackUiButtonClicked: mockTrackUiButtonClicked
})
}))
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
@@ -110,6 +118,9 @@ function createWrapper({
activeJobsShort: '{count} active | {count} active',
clearQueueTooltip: 'Clear queue'
}
},
rightSidePanel: {
togglePanel: 'Toggle properties panel'
}
}
}
@@ -266,6 +277,19 @@ describe('TopMenuSection', () => {
expect(screen.queryByTestId('active-jobs-indicator')).toBeNull()
})
it('tracks right side panel opens', async () => {
const { user } = createWrapper()
await user.click(
screen.getByRole('button', { name: 'Toggle properties panel' })
)
expect(mockTrackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'right_side_panel_opened',
element_group: 'top_menu'
})
})
it('hides queue progress overlay when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)

View File

@@ -78,7 +78,7 @@
variant="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
@click="openRightSidePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
@@ -148,6 +148,7 @@ import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
@@ -282,6 +283,14 @@ const rightSidePanelTooltipConfig = computed(() =>
buildTooltipConfig(t('rightSidePanel.togglePanel'))
)
function openRightSidePanel() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'right_side_panel_opened',
element_group: 'top_menu'
})
rightSidePanelStore.togglePanel()
}
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
const hasLegacyContent = ref(false)

View File

@@ -222,7 +222,8 @@ watch(visible, async (newVisible) => {
*/
useEventListener(dragHandleRef, 'mousedown', () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'actionbar_run_handle_drag_start'
button_id: 'actionbar_run_handle_drag_start',
element_group: 'actionbar'
})
})

View File

@@ -0,0 +1,69 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue'
const mockIsActiveSubscription = ref(true)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: mockIsActiveSubscription
})
}))
vi.mock('@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue', () => ({
default: {
name: 'ComfyQueueButton',
template: '<div data-testid="queue-button" />'
}
}))
vi.mock('@/platform/cloud/subscription/components/SubscribeToRun.vue', () => ({
default: {
name: 'SubscribeToRun',
template: '<div data-testid="subscribe-to-run-button" />'
}
}))
function renderWrapper() {
return render(CloudRunButtonWrapper)
}
describe('CloudRunButtonWrapper', () => {
beforeEach(() => {
mockIsActiveSubscription.value = true
})
it('renders the runnable queue button when the subscription is active', () => {
renderWrapper()
expect(screen.getByTestId('queue-button')).toBeInTheDocument()
expect(
screen.queryByTestId('subscribe-to-run-button')
).not.toBeInTheDocument()
})
it('locks the run button when the subscription is inactive', () => {
mockIsActiveSubscription.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
expect(screen.queryByTestId('queue-button')).not.toBeInTheDocument()
})
it('unlocks the run button once the subscription becomes active again', async () => {
mockIsActiveSubscription.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
mockIsActiveSubscription.value = true
await nextTick()
expect(screen.getByTestId('queue-button')).toBeInTheDocument()
expect(
screen.queryByTestId('subscribe-to-run-button')
).not.toBeInTheDocument()
})
})

View File

@@ -131,7 +131,8 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
tooltip: t('menu.onChangeTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_on_change_selected'
button_id: 'queue_mode_option_run_on_change_selected',
element_group: 'queue'
})
queueMode.value = 'change'
}
@@ -145,7 +146,8 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
tooltip: t('menu.instantTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_instant_selected'
button_id: 'queue_mode_option_run_instant_selected',
element_group: 'queue'
})
queueMode.value = 'instant-idle'
}
@@ -237,7 +239,8 @@ const queuePrompt = async (e: Event) => {
if (batchCount.value > 1) {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_multiple_batches_submitted'
button_id: 'queue_run_multiple_batches_submitted',
element_group: 'queue'
})
}

View File

@@ -1,224 +0,0 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import WidgetBoundingBoxes from './WidgetBoundingBoxes.vue'
import boundingBoxes from '@/locales/en/main.json'
import type { BoundingBox } from '@/types/boundingBoxes'
const { appState } = vi.hoisted(() => ({ appState: { node: null as unknown } }))
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: { getNodeById: () => appState.node } } }
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
boundingBoxes: boundingBoxes.boundingBoxes,
palette: { swatchTitle: 'Edit', addColor: 'Add' }
}
}
})
const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
x: 51,
y: 51,
width: 256,
height: 256,
metadata: { type: 'obj', text: '', desc: '', palette: ['#ff0000'] },
...over
})
const fakeCtx = {
measureText: (s: string) => ({ width: s.length * 7 }),
setTransform: () => {},
clearRect: () => {},
fillRect: () => {},
strokeRect: () => {},
fillText: () => {},
drawImage: () => {},
save: () => {},
restore: () => {},
beginPath: () => {},
rect: () => {},
clip: () => {},
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 0
} as unknown as CanvasRenderingContext2D
function prepCanvas(canvas: HTMLCanvasElement) {
Object.defineProperty(canvas, 'clientWidth', {
value: 100,
configurable: true
})
Object.defineProperty(canvas, 'clientHeight', {
value: 100,
configurable: true
})
canvas.getContext = (() =>
fakeCtx) as unknown as HTMLCanvasElement['getContext']
canvas.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON: () => ({})
}) as DOMRect
canvas.setPointerCapture = () => {}
canvas.releasePointerCapture = () => {}
}
function renderWidget(modelValue: BoundingBox[]) {
const result = render(WidgetBoundingBoxes, {
props: { nodeId: '1', modelValue },
global: { plugins: [i18n] }
})
const canvas = screen.getByTestId('bounding-boxes').querySelector('canvas')!
prepCanvas(canvas)
return { ...result, canvas }
}
const lastBoxes = (emitted: () => Record<string, unknown[][]>) => {
const calls = emitted()['update:modelValue']
return calls[calls.length - 1][0] as BoundingBox[]
}
beforeEach(() => {
setActivePinia(createPinia())
appState.node = {
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 }
],
findInputSlot: () => -1,
getInputNode: () => null
}
vi.stubGlobal('requestAnimationFrame', () => 1)
vi.stubGlobal('cancelAnimationFrame', () => {})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('WidgetBoundingBoxes', () => {
it('renders the canvas and editor shell', () => {
renderWidget([])
expect(
screen.getByTestId('bounding-boxes').querySelector('canvas')
).not.toBeNull()
})
it('shows the region editor panel when a region is active', () => {
renderWidget([box()])
expect(screen.getByText('obj')).toBeTruthy()
expect(screen.getByText('text')).toBeTruthy()
})
it('reveals the text field after switching the region to text', async () => {
renderWidget([box()])
expect(
screen.queryByPlaceholderText('text to render (verbatim)')
).toBeNull()
await userEvent.click(screen.getByText('text'))
expect(
screen.getByPlaceholderText('text to render (verbatim)')
).toBeTruthy()
})
it('clears all regions via the clear button', async () => {
const { emitted } = renderWidget([box()])
await userEvent.click(screen.getByText('Clear all'))
expect(lastBoxes(emitted)).toEqual([])
})
it('draws a region through canvas pointer events', async () => {
const { canvas, emitted } = renderWidget([])
await fireEvent.pointerDown(canvas, {
button: 0,
clientX: 10,
clientY: 10,
pointerId: 1
})
await fireEvent.pointerMove(canvas, {
clientX: 60,
clientY: 60,
pointerId: 1
})
await fireEvent.pointerUp(canvas, {
clientX: 60,
clientY: 60,
pointerId: 1
})
expect(lastBoxes(emitted)).toHaveLength(1)
})
it('tracks focus and blur on the canvas', async () => {
const { canvas } = renderWidget([box()])
await fireEvent.focus(canvas)
await fireEvent.blur(canvas)
expect(canvas).toBeTruthy()
})
it('opens an inline editor on double click', async () => {
const { canvas, container } = renderWidget([box()])
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
expect(container.querySelector('textarea')).not.toBeNull()
})
it('syncs description edits back to the model', async () => {
const { emitted } = renderWidget([box()])
await fireEvent.update(
screen.getByPlaceholderText('description of this region'),
'a caption'
)
expect(lastBoxes(emitted)[0].metadata.desc).toBe('a caption')
})
it('edits the text field once the region is a text region', async () => {
const { emitted } = renderWidget([box()])
await userEvent.click(screen.getByText('text'))
await fireEvent.update(
screen.getByPlaceholderText('text to render (verbatim)'),
'hello'
)
expect(lastBoxes(emitted)[0].metadata.text).toBe('hello')
})
it('deletes the active region with the Delete key', async () => {
const { canvas, emitted } = renderWidget([box()])
await fireEvent.keyDown(canvas, { key: 'Delete' })
expect(lastBoxes(emitted)).toEqual([])
})
it('clears hover state on pointer leave', async () => {
const { canvas } = renderWidget([
box({ x: 10, y: 10, width: 256, height: 256 })
])
await fireEvent.pointerMove(canvas, { clientX: 15, clientY: 15 })
await fireEvent.pointerLeave(canvas)
expect(canvas).toBeTruthy()
})
it('commits the inline editor on blur', async () => {
const { canvas, container, emitted } = renderWidget([box()])
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
const editor = container.querySelector('textarea')!
await fireEvent.update(editor, 'committed')
await fireEvent.blur(editor)
expect(lastBoxes(emitted)[0].metadata.desc).toBe('committed')
})
})

View File

@@ -1,181 +0,0 @@
<template>
<div
class="widget-expands flex size-full flex-col gap-1 select-none"
data-testid="bounding-boxes"
@pointerdown.stop
>
<div
ref="canvasContainer"
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
:style="canvasStyle"
>
<canvas
ref="canvasEl"
tabindex="0"
class="absolute inset-0 size-full rounded-sm outline-none"
:style="{ cursor: canvasCursor }"
@pointerdown="onPointerDown"
@pointermove="onCanvasPointerMove"
@pointerup="onDocPointerUp"
@pointercancel="onDocPointerUp"
@pointerleave="onPointerLeave"
@lostpointercapture="onDocPointerUp"
@dblclick="onDoubleClick"
@keydown="onCanvasKeyDown"
@focus="focused = true"
@blur="focused = false"
/>
<textarea
v-if="inlineEditor"
ref="inlineEditorEl"
v-model="inlineEditor.value"
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
:style="inlineEditor.style"
data-capture-wheel="true"
@keydown.stop="onInlineKeyDown"
@blur="commitInlineEditor"
/>
</div>
<div
v-if="activeRegion"
class="flex flex-col gap-2 rounded-sm bg-node-component-surface p-2 text-xs"
>
<div
class="flex h-8 items-center gap-1 rounded-sm bg-component-node-widget-background p-1"
>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
activeRegion.type === 'obj'
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="setActiveType('obj')"
>
{{ $t('boundingBoxes.typeObj') }}
</Button>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
activeRegion.type === 'text'
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="setActiveType('text')"
>
{{ $t('boundingBoxes.typeText') }}
</Button>
</div>
<div
v-if="activeRegion.type === 'text'"
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
>
<span
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
>
{{ $t('boundingBoxes.textLabel') }}
</span>
<Textarea
v-model="activeRegion.text"
:placeholder="$t('boundingBoxes.textPlaceholder')"
class="min-h-14 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
data-capture-wheel="true"
@update:model-value="syncState"
/>
</div>
<div
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
>
<span
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
>
{{ $t('boundingBoxes.descLabel') }}
</span>
<Textarea
v-model="activeRegion.desc"
:placeholder="$t('boundingBoxes.descPlaceholder')"
class="min-h-20 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
data-capture-wheel="true"
@update:model-value="syncState"
/>
</div>
<div class="flex items-center gap-2">
<span class="shrink-0 truncate text-sm text-muted-foreground">
{{ $t('boundingBoxes.colors') }}
</span>
<PaletteSwatchRow
v-model="activeRegion.palette"
:max="maxColors"
@update:model-value="syncState"
/>
</div>
</div>
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
{{ $t('boundingBoxes.clickRegionToEdit') }}
</div>
<Button
variant="secondary"
size="md"
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
@click="clearAll"
>
<i class="icon-[lucide--undo-2]" />
{{ $t('boundingBoxes.clearAll') }}
</Button>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import PaletteSwatchRow from '@/components/palette/PaletteSwatchRow.vue'
import Button from '@/components/ui/button/Button.vue'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
const { nodeId } = defineProps<{ nodeId: string }>()
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
const canvasContainer = useTemplateRef<HTMLDivElement>('canvasContainer')
const inlineEditorEl = useTemplateRef<HTMLTextAreaElement>('inlineEditorEl')
const {
canvasStyle,
canvasCursor,
focused,
activeRegion,
hasRegions,
inlineEditor,
maxColors,
onPointerDown,
onCanvasPointerMove,
onDocPointerUp,
onPointerLeave,
onDoubleClick,
onCanvasKeyDown,
onInlineKeyDown,
commitInlineEditor,
setActiveType,
clearAll,
syncState
} = useBoundingBoxes(nodeId, {
canvasEl,
canvasContainer,
inlineEditorEl,
modelValue
})
</script>

View File

@@ -88,7 +88,8 @@ const home = computed(() => ({
isBlueprint: isBlueprint.value,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_root_selected'
button_id: 'breadcrumb_subgraph_root_selected',
element_group: 'breadcrumb'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
@@ -103,7 +104,8 @@ const items = computed(() => {
key: `subgraph-${subgraph.id}`,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_item_selected'
button_id: 'breadcrumb_subgraph_item_selected',
element_group: 'breadcrumb'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')

View File

@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { AppMode } from '@/utils/appMode'
import type { AppMode } from '@/composables/useAppMode'
import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'

View File

@@ -11,6 +11,8 @@ import {
import { useI18n } from 'vue-i18n'
import { toValue } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { t } = useI18n()
defineOptions({
@@ -50,11 +52,27 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
</DropdownMenuSub>
<DropdownMenuItem
v-else
:class="itemClass"
v-tooltip="
item.tooltip ? { value: String(item.tooltip), showDelay: 0 } : undefined
"
:class="
cn(
itemClass,
String(item.class ?? ''),
Boolean(item.tooltip) && toValue(item.disabled) && 'pointer-events-auto'
)
"
v-bind="
'checked' in item
? { role: 'menuitemradio', 'aria-checked': Boolean(item.checked) }
: {}
"
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<i class="size-5 shrink-0" :class="item.icon" />
<!-- Items declaring an icon key (even empty) keep the slot so labels align
within icon-bearing menus; icon-less menus render labels flush-left. -->
<i v-if="'icon' in item" class="size-5 shrink-0" :class="item.icon" />
<div class="mr-auto truncate" v-text="item.label" />
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
<div

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ZIndex } from '@primeuix/utils/zindex'
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuArrow,
@@ -7,13 +8,16 @@ import {
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { computed, toValue } from 'vue'
import { computed, ref, toValue } from 'vue'
import DropdownItem from '@/components/common/DropdownItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
import type { ButtonVariants } from '../ui/button/button.variants'
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
const MODAL_BASE_Z_INDEX = 1700
defineOptions({
inheritAttrs: false
})
@@ -41,10 +45,20 @@ const contentClass = computed(() =>
contentProp
)
)
// Body-portaled content keeps its static z-1700 unless a dialog that joined
// @primeuix's auto-incrementing 'modal' counter is open above it; then lift
// past that dialog so the menu isn't hidden behind it.
const open = ref(false)
const contentStyle = computed(() => {
if (!open.value) return undefined
const topZIndex = ZIndex.getCurrent('modal')
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
})
</script>
<template>
<DropdownMenuRoot>
<DropdownMenuRoot v-model:open="open">
<DropdownMenuTrigger as-child>
<slot name="button">
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
@@ -60,6 +74,7 @@ const contentClass = computed(() =>
:collision-padding="10"
v-bind="$attrs"
:class="contentClass"
:style="contentStyle"
>
<slot :item-class>
<DropdownItem

View File

@@ -0,0 +1,56 @@
import { ZIndex } from '@primeuix/utils/zindex'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import DropdownMenu from './DropdownMenu.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderMenu() {
return render(DropdownMenu, {
props: { entries: [{ label: 'Item A' }] },
global: { plugins: [i18n], directives: { tooltip: {} } }
})
}
let openModal: HTMLElement | undefined
afterEach(() => {
if (openModal) {
ZIndex.clear(openModal)
openModal = undefined
}
})
describe('DropdownMenu z-index', () => {
it('opens above a dialog registered with the modal z-index counter', async () => {
openModal = document.createElement('div')
ZIndex.set('modal', openModal, 1700)
const dialogZ = Number(openModal.style.zIndex)
const user = userEvent.setup()
renderMenu()
await user.click(screen.getByRole('button'))
const menu = await screen.findByRole('menu')
expect(Number(menu.style.zIndex)).toBeGreaterThan(dialogZ)
})
it('leaves the static z-index untouched when no dialog is open', async () => {
const user = userEvent.setup()
renderMenu()
await user.click(screen.getByRole('button'))
const menu = await screen.findByRole('menu')
expect(menu.style.zIndex).toBe('')
expect(menu.className).toContain('z-1700')
})
})

View File

@@ -40,7 +40,8 @@ function handleOpen(open: boolean) {
if (open) {
markAsSeen()
useTelemetry()?.trackUiButtonClicked({
button_id: source
button_id: source,
element_group: 'workflow_actions'
})
}
}

View File

@@ -190,7 +190,7 @@
variant="ghost"
rounded="lg"
:data-testid="`template-workflow-${template.name}`"
class="hover:bg-base-background"
class="group/card hover:bg-base-background"
@mouseenter="hoveredTemplate = template.name"
@mouseleave="hoveredTemplate = null"
@click="onLoadWorkflow(template)"
@@ -316,11 +316,11 @@
class="flex flex-col-reverse justify-center"
>
<Button
v-if="hoveredTemplate === template.name"
v-tooltip.bottom="$t('g.seeTutorial')"
v-bind="$attrs"
:aria-label="$t('g.seeTutorial')"
variant="inverted"
size="icon"
class="not-group-hover/card:opacity-0"
@click.stop="openTutorial(template)"
>
<i class="icon-[lucide--info] size-4" />

View File

@@ -25,28 +25,42 @@
"
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
>
<DialogHeader v-if="!item.dialogComponentProps.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<DialogTitle v-else :id="item.key">
{{ item.title || ' ' }}
</DialogTitle>
<DialogClose v-if="item.dialogComponentProps.closable !== false" />
</DialogHeader>
<div class="flex-1 overflow-auto px-4 py-2">
<!-- Headless dialogs (e.g. the pricing table) draw their own chrome;
render them directly to avoid the px-4/py-2 + overflow-auto wrapper
that otherwise forces a horizontal scrollbar on full-width content. -->
<template v-if="item.dialogComponentProps.headless">
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</div>
<DialogFooter v-if="item.footerComponent">
<component :is="item.footerComponent" v-bind="item.footerProps" />
</DialogFooter>
</template>
<template v-else>
<DialogHeader>
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<DialogTitle v-else :id="item.key">
{{ item.title || ' ' }}
</DialogTitle>
<DialogClose
v-if="item.dialogComponentProps.closable !== false"
/>
</DialogHeader>
<div class="flex-1 overflow-auto px-4 py-2">
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</div>
<DialogFooter v-if="item.footerComponent">
<component :is="item.footerComponent" v-bind="item.footerProps" />
</DialogFooter>
</template>
</DialogContent>
</DialogPortal>
</Dialog>

View File

@@ -101,7 +101,8 @@ const reportOpen = ref(false)
*/
const showReport = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_show_report_clicked'
button_id: 'error_dialog_show_report_clicked',
element_group: 'error_dialog'
})
reportOpen.value = true
}

View File

@@ -37,7 +37,7 @@
<Message v-if="userIsInChina" severity="warn" class="mb-4">
{{ t('auth.signup.regionRestrictionChina') }}
</Message>
<SignUpForm v-else @submit="signUpWithEmail" />
<SignUpForm v-else ref="signUpForm" @submit="signUpWithEmail" />
</template>
<!-- Divider -->
@@ -206,9 +206,21 @@ const signInWithEmail = async (values: SignInData) => {
}
}
const signUpWithEmail = async (values: SignUpData) => {
if (await authActions.signUpWithEmail(values.email, values.password)) {
const signUpForm = ref<InstanceType<typeof SignUpForm> | null>(null)
const signUpWithEmail = async (values: SignUpData, turnstileToken?: string) => {
if (
await authActions.signUpWithEmail(
values.email,
values.password,
turnstileToken
)
) {
onSuccess()
} else {
// Signup failed while the form is still mounted: re-arm the single-use
// Turnstile token so the next attempt sends a fresh one.
signUpForm.value?.resetTurnstile()
}
}

View File

@@ -25,7 +25,8 @@ const queryString = computed(() => props.errorMessage + ' is:issue')
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_find_existing_issues_clicked'
button_id: 'error_dialog_find_existing_issues_clicked',
element_group: 'error_dialog'
})
const query = encodeURIComponent(queryString.value)
const url = `https://github.com/${props.repoOwner}/${props.repoName}/issues?q=${query}`

View File

@@ -0,0 +1,97 @@
<template>
<div class="credits-container flex h-full flex-col gap-4">
<div>
<h2 class="mb-2 text-2xl font-bold">
{{ $t('credits.credits') }}
</h2>
<Divider />
</div>
<CreditsTile />
<div class="flex items-center justify-between">
<h3 class="m-0">{{ $t('credits.activity') }}</h3>
<Button variant="muted-textonly" @click="handleCreditsHistoryClick">
<i class="pi pi-arrow-up-right" />
{{ $t('credits.invoiceHistory') }}
</Button>
</div>
<UsageLogsTable ref="usageLogsTableRef" />
<div class="flex flex-row gap-2">
<Button variant="muted-textonly" @click="handleFaqClick">
<i class="pi pi-question-circle" />
{{ $t('credits.faqs') }}
</Button>
<Button variant="muted-textonly" @click="handleOpenPartnerNodesInfo">
<i class="pi pi-question-circle" />
{{ $t('subscription.partnerNodesCredits') }}
</Button>
<Button variant="muted-textonly" @click="handleMessageSupport">
<i class="pi pi-comments" />
{{ $t('credits.messageSupport') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import { ref, watch } from 'vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
import { useTelemetry } from '@/platform/telemetry'
import { useAuthStore } from '@/stores/authStore'
import { useCommandStore } from '@/stores/commandStore'
const { buildDocsUrl, docsPaths } = useExternalLink()
const authStore = useAuthStore()
const authActions = useAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
watch(
() => authStore.lastBalanceUpdateTime,
(newTime, oldTime) => {
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
void usageLogsTableRef.value.refresh()
}
}
)
const handleCreditsHistoryClick = async () => {
await authActions.accessBillingPortal()
}
const handleMessageSupport = async () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'credits_panel'
})
await commandStore.execute('Comfy.ContactSupport')
}
const handleFaqClick = () => {
window.open(
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
'_blank',
'noopener,noreferrer'
)
}
const handleOpenPartnerNodesInfo = () => {
window.open(
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
'_blank',
'noopener,noreferrer'
)
}
</script>

View File

@@ -1,195 +0,0 @@
<template>
<div class="credits-container h-full">
<!-- Legacy Design -->
<div class="flex h-full flex-col">
<h2 class="mb-2 text-2xl font-bold">
{{ $t('credits.credits') }}
</h2>
<Divider />
<div class="flex flex-col gap-2">
<h3 class="text-sm font-medium text-muted">
{{ $t('credits.yourCreditBalance') }}
</h3>
<div class="flex items-center justify-between">
<UserCredit text-class="text-3xl font-bold" />
<Skeleton v-if="loading" width="2rem" height="2rem" />
<Button
v-else-if="isActiveSubscription"
:loading="loading"
@click="handlePurchaseCreditsClick"
>
{{ $t('credits.purchaseCredits') }}
</Button>
</div>
<div class="flex flex-row items-center">
<Skeleton
v-if="balanceLoading"
width="12rem"
height="1rem"
class="text-xs"
/>
<div v-else-if="formattedLastUpdateTime" class="text-xs text-muted">
{{ $t('credits.lastUpdated') }}: {{ formattedLastUpdateTime }}
</div>
<Button
variant="muted-textonly"
size="icon-sm"
:aria-label="$t('g.refresh')"
@click="() => authActions.fetchBalance()"
>
<i class="pi pi-refresh" />
</Button>
</div>
</div>
<div class="flex items-center justify-between">
<h3>{{ $t('credits.activity') }}</h3>
<Button
variant="muted-textonly"
:loading="loading"
@click="handleCreditsHistoryClick"
>
<i class="pi pi-arrow-up-right" />
{{ $t('credits.invoiceHistory') }}
</Button>
</div>
<template v-if="creditHistory.length > 0">
<div class="grow">
<DataTable :value="creditHistory" :show-headers="false">
<Column field="title" :header="$t('g.name')">
<template #body="{ data }">
<div class="text-sm font-medium">{{ data.title }}</div>
<div class="text-xs text-muted">{{ data.timestamp }}</div>
</template>
</Column>
<Column field="amount" :header="$t('g.amount')">
<template #body="{ data }">
<div
:class="[
'text-center text-base font-medium',
data.isPositive ? 'text-sky-500' : 'text-red-400'
]"
>
{{ data.isPositive ? '+' : '-' }}${{
formatMetronomeCurrency(data.amount, 'usd')
}}
</div>
</template>
</Column>
</DataTable>
</div>
</template>
<Divider />
<UsageLogsTable ref="usageLogsTableRef" />
<div class="flex flex-row gap-2">
<Button variant="muted-textonly" @click="handleFaqClick">
<i class="pi pi-question-circle" />
{{ $t('credits.faqs') }}
</Button>
<Button variant="muted-textonly" @click="handleOpenPartnerNodesInfo">
<i class="pi pi-question-circle" />
{{ $t('subscription.partnerNodesCredits') }}
</Button>
<Button variant="muted-textonly" @click="handleMessageSupport">
<i class="pi pi-comments" />
{{ $t('credits.messageSupport') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Divider from 'primevue/divider'
import Skeleton from 'primevue/skeleton'
import { computed, ref, watch } from 'vue'
import UserCredit from '@/components/common/UserCredit.vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useAuthStore } from '@/stores/authStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
interface CreditHistoryItemData {
title: string
timestamp: string
amount: number
isPositive: boolean
}
const { buildDocsUrl, docsPaths } = useExternalLink()
const dialogService = useDialogService()
const authStore = useAuthStore()
const authActions = useAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { isActiveSubscription } = useBillingContext()
const loading = computed(() => authStore.loading)
const balanceLoading = computed(() => authStore.isFetchingBalance)
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
const formattedLastUpdateTime = computed(() =>
authStore.lastBalanceUpdateTime
? authStore.lastBalanceUpdateTime.toLocaleString()
: ''
)
watch(
() => authStore.lastBalanceUpdateTime,
(newTime, oldTime) => {
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
usageLogsTableRef.value.refresh()
}
}
)
const handlePurchaseCreditsClick = () => {
// Track purchase credits entry from Settings > Credits panel
useTelemetry()?.trackAddApiCreditButtonClicked()
dialogService.showTopUpCreditsDialog()
}
const handleCreditsHistoryClick = async () => {
await authActions.accessBillingPortal()
}
const handleMessageSupport = async () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'credits_panel'
})
await commandStore.execute('Comfy.ContactSupport')
}
const handleFaqClick = () => {
window.open(
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
'_blank'
)
}
const handleOpenPartnerNodesInfo = () => {
window.open(
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
'_blank'
)
}
const creditHistory = ref<CreditHistoryItemData[]>([])
</script>

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

@@ -1,12 +1,13 @@
import { Form, FormField } from '@primevue/forms'
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import Button from '@/components/ui/button/Button.vue'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import PrimeVue from 'primevue/config'
import ProgressSpinner from 'primevue/progressspinner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { defineComponent, h, nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
@@ -36,34 +37,116 @@ vi.mock('@/stores/authStore', () => ({
}))
}))
const mockTurnstileEnabled = ref(false)
const mockTurnstileEnforced = ref(false)
const mockReset = vi.fn()
let emitTurnstileToken: ((token: string) => void) | undefined
vi.mock('@/composables/auth/useTurnstile', () => ({
useTurnstile: () => ({
enabled: mockTurnstileEnabled,
enforced: mockTurnstileEnforced
})
}))
// Stub the real widget (which loads the external Turnstile script) with one that
// exposes a spyable reset() and lets a test drive the v-model token the way a
// solved challenge would.
vi.mock('./TurnstileWidget.vue', async () => {
const { defineComponent: defineMock } = await import('vue')
return {
default: defineMock({
name: 'TurnstileWidget',
emits: ['update:token'],
setup(_, { expose, emit }) {
expose({ reset: mockReset })
emitTurnstileToken = (token: string) => emit('update:token', token)
return () => null
}
})
}
})
const signUpButton = enMessages.auth.signup.signUpButton
function globalOptions() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return {
plugins: [PrimeVue, i18n],
components: {
Form,
FormField,
Button,
InputText,
Password,
ProgressSpinner
}
}
}
describe('SignUpForm', () => {
beforeEach(() => {
mockLoadingRef.value = false
mockTurnstileEnabled.value = false
mockTurnstileEnforced.value = false
mockReset.mockClear()
emitTurnstileToken = undefined
})
afterEach(() => {
vi.restoreAllMocks()
})
function renderComponent() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return render(SignUpForm, {
global: {
plugins: [PrimeVue, i18n],
components: {
Form,
FormField,
Button,
InputText,
Password,
ProgressSpinner
}
function renderComponent(props: Record<string, unknown> = {}) {
const user = userEvent.setup()
const utils = render(SignUpForm, { global: globalOptions(), props })
return { ...utils, user }
}
/** Render through a host that keeps a ref, so the parent-facing exposed
* `resetTurnstile()` can be invoked the way SignInContent would. */
function renderWithRef() {
const formRef = ref<{ resetTurnstile: () => void } | null>(null)
const Host = defineComponent({
setup() {
return () => h(SignUpForm, { ref: formRef })
}
})
const utils = render(Host, { global: globalOptions() })
return {
...utils,
form: () => {
if (!formRef.value) throw new Error('form not mounted')
return formRef.value
}
}
}
const expectedValues = {
email: 'new@example.com',
password: 'Password1!',
confirmPassword: 'Password1!'
}
async function fillValidSignup(user: ReturnType<typeof userEvent.setup>) {
await user.type(
screen.getByPlaceholderText(enMessages.auth.signup.emailPlaceholder),
expectedValues.email
)
await user.type(
screen.getByPlaceholderText(enMessages.auth.signup.passwordPlaceholder),
expectedValues.password
)
await user.type(
screen.getByPlaceholderText(
enMessages.auth.login.confirmPasswordPlaceholder
),
expectedValues.confirmPassword
)
}
describe('Password manager autofill attributes', () => {
@@ -107,4 +190,97 @@ describe('SignUpForm', () => {
)
})
})
describe('Turnstile single-use token reset', () => {
it('exposes resetTurnstile() that resets the rendered widget', async () => {
mockTurnstileEnabled.value = true
const { form } = renderWithRef()
await nextTick()
form().resetTurnstile()
expect(mockReset).toHaveBeenCalledOnce()
})
it('does not reset the widget on the initial render', async () => {
mockTurnstileEnabled.value = true
renderWithRef()
await nextTick()
expect(mockReset).not.toHaveBeenCalled()
})
})
describe('Turnstile token hygiene', () => {
it('clears the stale token when Turnstile becomes disabled', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const { user } = renderComponent()
await fillValidSignup(user)
emitTurnstileToken!('stale-token')
await nextTick()
expect(
screen.getByRole('button', { name: signUpButton })
).not.toBeDisabled()
mockTurnstileEnabled.value = false
await nextTick()
// re-enable: the stale token must have been cleared so submit is blocked again
mockTurnstileEnabled.value = true
await nextTick()
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
})
})
describe('Turnstile submit gating', () => {
it('disables the submit button in enforce mode until a token is present', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
renderComponent()
await nextTick()
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
})
it('does not emit submit in enforce mode while the token is empty', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
await user.click(screen.getByRole('button', { name: signUpButton }))
expect(onSubmit).not.toHaveBeenCalled()
})
it('emits submit with the token in enforce mode once the challenge is solved', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
emitTurnstileToken!('token-xyz')
await nextTick()
await user.click(screen.getByRole('button', { name: signUpButton }))
expect(onSubmit).toHaveBeenCalledWith(expectedValues, 'token-xyz')
})
it('emits submit without a token in shadow mode (never blocks)', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = false
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
await user.click(screen.getByRole('button', { name: signUpButton }))
expect(onSubmit).toHaveBeenCalledWith(expectedValues, undefined)
})
})
})

View File

@@ -29,13 +29,34 @@
<PasswordFields />
<TurnstileWidget
v-if="turnstileEnabled"
ref="turnstileWidget"
v-model:token="turnstileToken"
/>
<small
v-show="submitBlockedByTurnstile"
id="comfy-org-sign-up-turnstile-hint"
role="status"
aria-live="polite"
class="opacity-80"
>
{{ t('auth.turnstile.submitBlockedHint') }}
</small>
<!-- Submit Button -->
<ProgressSpinner v-if="loading" class="mx-auto size-8" />
<Button
v-else
type="submit"
class="mt-4 h-10 font-medium"
:disabled="!$form.valid"
:disabled="!$form.valid || submitBlockedByTurnstile"
:aria-describedby="
submitBlockedByTurnstile
? 'comfy-org-sign-up-turnstile-hint'
: undefined
"
>
{{ t('auth.signup.signUpButton') }}
</Button>
@@ -49,27 +70,58 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
import { useThrottleFn } from '@vueuse/core'
import InputText from 'primevue/inputtext'
import ProgressSpinner from 'primevue/progressspinner'
import { computed } from 'vue'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTurnstile } from '@/composables/auth/useTurnstile'
import { signUpSchema } from '@/schemas/signInSchema'
import type { SignUpData } from '@/schemas/signInSchema'
import { useAuthStore } from '@/stores/authStore'
import PasswordFields from './PasswordFields.vue'
import TurnstileWidget from './TurnstileWidget.vue'
const { t } = useI18n()
const authStore = useAuthStore()
const loading = computed(() => authStore.loading)
const { enabled: turnstileEnabled, enforced: turnstileEnforced } =
useTurnstile()
const turnstileToken = ref('')
const turnstileWidget =
useTemplateRef<InstanceType<typeof TurnstileWidget>>('turnstileWidget')
const submitBlockedByTurnstile = computed(
() => turnstileEnforced.value && !turnstileToken.value
)
watch(turnstileEnabled, (on) => {
if (!on) turnstileToken.value = ''
})
const emit = defineEmits<{
submit: [values: SignUpData]
submit: [values: SignUpData, turnstileToken?: string]
}>()
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignUpData)
if (event.valid && !submitBlockedByTurnstile.value) {
emit(
'submit',
event.values as SignUpData,
turnstileToken.value || undefined
)
}
}, 1_500)
// Turnstile tokens are single-use. The parent calls this after a FAILED signup
// (the form can't observe the submit outcome itself) to discard the spent token
// and request a fresh challenge. Driving it from the actual result — instead of
// watching the store-global loading flag — keeps an unrelated auth action from
// wiping a freshly-solved token, and avoids resetting a widget that is about to
// unmount on success.
function resetTurnstile() {
turnstileWidget.value?.reset()
}
defineExpose({ resetTurnstile })
</script>

View File

@@ -0,0 +1,264 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { render } from '@testing-library/vue'
import type { TurnstileRenderOptions } from '@/composables/auth/turnstileScript'
import TurnstileWidget from './TurnstileWidget.vue'
const { mockLoadTurnstile, mockGetSiteKey, mockLightTheme } = vi.hoisted(
() => ({
mockLoadTurnstile: vi.fn(),
mockGetSiteKey: vi.fn(() => 'site-key'),
mockLightTheme: { value: true }
})
)
vi.mock('@/composables/auth/turnstileScript', () => ({
loadTurnstile: mockLoadTurnstile
}))
vi.mock('@/config/turnstile', () => ({
getTurnstileSiteKey: mockGetSiteKey
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
completedActivePalette: {
get light_theme() {
return mockLightTheme.value
}
}
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
auth: {
turnstile: {
expired: 'Challenge expired',
failed: 'Verification failed'
}
}
}
}
})
/** A controllable Cloudflare Turnstile global whose render() captures options. */
function fakeTurnstile() {
let captured: TurnstileRenderOptions | undefined
const api = {
render: vi.fn((_el: unknown, options: TurnstileRenderOptions) => {
captured = options
return 'widget-id'
}),
reset: vi.fn(),
remove: vi.fn()
}
return { api, options: () => captured }
}
/** Drain the onMounted async (loadTurnstile) plus any follow-up microtasks. */
const flush = async () => {
await Promise.resolve()
await new Promise((resolve) => setTimeout(resolve))
}
const renderWidget = () =>
render(TurnstileWidget, { global: { plugins: [i18n] } })
/**
* Render TurnstileWidget through a thin host that keeps a ref to it, so the
* exposed `reset()` method can be invoked the way a parent (SignUpForm) would.
*/
const renderWidgetWithExpose = () => {
const widgetRef = ref<{ reset: () => void } | null>(null)
const Host = defineComponent({
setup(_, { emit }) {
return () =>
h(TurnstileWidget, {
ref: widgetRef,
'onUpdate:token': (value: string) => emit('update:token', value)
})
}
})
const utils = render(Host, { global: { plugins: [i18n] } })
return {
...utils,
getCurrentInstance: () => {
if (!widgetRef.value) throw new Error('widget not mounted')
return widgetRef.value
}
}
}
describe('TurnstileWidget', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSiteKey.mockReturnValue('site-key')
mockLightTheme.value = true
delete window.turnstile
})
afterEach(() => {
delete window.turnstile
})
it('renders the widget with the configured sitekey and light theme', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
renderWidget()
await flush()
expect(mockLoadTurnstile).toHaveBeenCalledOnce()
expect(api.render).toHaveBeenCalledOnce()
expect(options()?.sitekey).toBe('site-key')
expect(options()?.theme).toBe('light')
})
it('uses the dark theme when the active palette is not light', async () => {
mockLightTheme.value = false
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
renderWidget()
await flush()
expect(options()?.theme).toBe('dark')
})
it('emits the solved token via v-model and shows no error', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted, container } = renderWidget()
await flush()
options()!.callback!('token-abc')
await flush()
expect(emitted()['update:token'].at(-1)).toEqual(['token-abc'])
expect(container.textContent).not.toContain('Challenge expired')
expect(container.textContent).not.toContain('Verification failed')
})
it('clears the token and surfaces the expired message on expiry', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted, container } = renderWidget()
await flush()
options()!.callback!('token-abc')
options()!['expired-callback']!()
await flush()
expect(emitted()['update:token'].at(-1)).toEqual([''])
expect(container.textContent).toContain('Challenge expired')
})
it('clears the token and surfaces the failure message on widget error', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted, container } = renderWidget()
await flush()
options()!.callback!('token-abc')
options()!['error-callback']!()
await flush()
expect(emitted()['update:token'].at(-1)).toEqual([''])
expect(container.textContent).toContain('Verification failed')
})
it('resets the widget on a challenge error to fetch a fresh challenge', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
renderWidget()
await flush()
options()!['error-callback']!()
await flush()
expect(api.reset).toHaveBeenCalledWith('widget-id')
})
it('shows the failure message when the Turnstile script fails to load', async () => {
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
const { container } = renderWidget()
await flush()
expect(container.textContent).toContain('Verification failed')
})
it('reset() clears the token model and resets the rendered widget', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
const { emitted, getCurrentInstance } = renderWidgetWithExpose()
await flush()
options()!.callback!('token-abc')
await flush()
expect(emitted()['update:token'].at(-1)).toEqual(['token-abc'])
getCurrentInstance().reset()
await flush()
expect(api.reset).toHaveBeenCalledWith('widget-id')
expect(emitted()['update:token'].at(-1)).toEqual([''])
})
it('reset() clears a stale error so it does not linger over a fresh challenge', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
const { container, getCurrentInstance } = renderWidgetWithExpose()
await flush()
options()!['error-callback']!()
await flush()
expect(container.textContent).toContain('Verification failed')
getCurrentInstance().reset()
await flush()
expect(container.textContent).not.toContain('Verification failed')
})
it('reset() clears the token even when the widget never rendered', async () => {
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
const { emitted, getCurrentInstance } = renderWidgetWithExpose()
await flush()
getCurrentInstance().reset()
await flush()
// No widget id was captured, so window.turnstile.reset is never called,
// but the token model is still cleared.
expect(emitted()['update:token']?.at(-1) ?? ['']).toEqual([''])
})
it('removes the widget on unmount when one was rendered', async () => {
const { api } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
const { unmount } = renderWidget()
await flush()
unmount()
expect(api.remove).toHaveBeenCalledWith('widget-id')
})
})

View File

@@ -0,0 +1,92 @@
<template>
<div class="flex flex-col gap-2">
<div ref="containerRef"></div>
<small
v-if="errorMessage"
role="alert"
aria-live="assertive"
class="text-red-500"
>{{ errorMessage }}</small
>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { loadTurnstile } from '@/composables/auth/turnstileScript'
import { getTurnstileSiteKey } from '@/config/turnstile'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
const token = defineModel<string>('token', { default: '' })
const { t } = useI18n()
const colorPaletteStore = useColorPaletteStore()
const containerRef = ref<HTMLDivElement>()
const errorMessage = ref('')
let widgetId: string | undefined
const clearToken = () => {
token.value = ''
}
/**
* Fetch a fresh challenge and clear the current token.
*
* Turnstile tokens are single-use, so after a token is consumed by a submit
* attempt that did not succeed, the spent token must be discarded and a new
* challenge requested. Clearing the model re-blocks submission until the user
* solves the fresh challenge; clearing the error drops any stale failure text
* so it can't linger over the new challenge.
*/
const reset = () => {
clearToken()
errorMessage.value = ''
if (widgetId && window.turnstile) {
window.turnstile.reset(widgetId)
}
}
defineExpose({ reset })
onMounted(async () => {
try {
const turnstile = await loadTurnstile()
if (!containerRef.value) return
const theme = colorPaletteStore.completedActivePalette.light_theme
? 'light'
: 'dark'
widgetId = turnstile.render(containerRef.value, {
sitekey: getTurnstileSiteKey(),
theme,
callback: (newToken: string) => {
errorMessage.value = ''
token.value = newToken
},
'expired-callback': () => {
clearToken()
errorMessage.value = t('auth.turnstile.expired')
},
'error-callback': () => {
clearToken()
console.warn('Turnstile challenge failed')
errorMessage.value = t('auth.turnstile.failed')
if (widgetId && window.turnstile) window.turnstile.reset(widgetId)
}
})
} catch (error) {
console.warn('Turnstile failed to load', error)
errorMessage.value = t('auth.turnstile.failed')
}
})
onBeforeUnmount(() => {
if (widgetId && window.turnstile) {
window.turnstile.remove(widgetId)
}
})
</script>

View File

@@ -1,7 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor } from '@testing-library/vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
@@ -44,23 +45,28 @@ const mockSubscription = vi.hoisted(() => ({
value: null as { endDate: string | null } | null
}))
const mockCancelSubscription = vi.hoisted(() => vi.fn())
const mockFetchStatus = vi.hoisted(() => vi.fn())
const mockCloseDialog = vi.hoisted(() => vi.fn())
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
cancelSubscription: vi.fn(),
fetchStatus: vi.fn(),
cancelSubscription: mockCancelSubscription,
fetchStatus: mockFetchStatus,
subscription: mockSubscription
}))
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: vi.fn(() => ({
closeDialog: vi.fn()
closeDialog: mockCloseDialog
}))
}))
vi.mock('primevue/usetoast', () => ({
useToast: vi.fn(() => ({
add: vi.fn()
add: mockToastAdd
}))
}))
@@ -86,6 +92,54 @@ function renderComponent(props: { cancelAt?: string } = {}) {
}
describe('CancelSubscriptionDialogContent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('cancel flow', () => {
it('shows an error toast and keeps the dialog open when cancellation fails', async () => {
mockSubscription.value = null
mockCancelSubscription.mockRejectedValueOnce(
new Error('Subscription cancellation timed out')
)
renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /^cancel subscription$/i })
)
await waitFor(() =>
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: 'Subscription cancellation timed out'
})
)
)
expect(mockCloseDialog).not.toHaveBeenCalled()
})
it('closes the dialog and shows a success toast when cancellation succeeds', async () => {
mockSubscription.value = null
mockCancelSubscription.mockResolvedValueOnce(undefined)
renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /^cancel subscription$/i })
)
await waitFor(() =>
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'cancel-subscription'
})
)
expect(mockFetchStatus).toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
})
describe('formattedEndDate fallbacks', () => {
it('uses the localized fallback when no cancel timestamp is available', () => {
mockSubscription.value = { endDate: null }

View File

@@ -188,10 +188,7 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'
import { isCloud } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
import { useUrlActionLoaders } from '@/composables/useUrlActionLoaders'
const { t } = useI18n()
const emit = defineEmits<{
@@ -450,10 +447,7 @@ useEventListener(
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
const { flags } = useFeatureFlags()
// Set up URL loaders during setup phase so useRoute/useRouter work correctly
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
const createWorkspaceUrlLoader = isCloud ? useCreateWorkspaceUrlLoader() : null
const { runUrlActionLoaders } = useUrlActionLoaders()
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()
@@ -561,23 +555,8 @@ onMounted(async () => {
() => canvasStore.updateSelectedItems()
)
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
// WorkspaceAuthGate ensures flag state is resolved before GraphCanvas mounts
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {
await inviteUrlLoader.loadInviteFromUrl()
}
// Open create workspace dialog from URL if present (e.g., ?create_workspace=1)
if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) {
try {
await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl()
} catch (error) {
console.error(
'[GraphCanvas] Failed to load create workspace from URL:',
error
)
}
}
// Run query-param deep-link loaders (?invite, ?create_workspace, ?pricing)
await runUrlActionLoaders()
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
const { useReleaseStore } =

View File

@@ -218,7 +218,8 @@ onMounted(() => {
*/
const onMinimapToggleClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_menu_minimap_toggle_clicked'
button_id: 'graph_menu_minimap_toggle_clicked',
element_group: 'graph_menu'
})
void commandStore.execute('Comfy.Canvas.ToggleMinimap')
}
@@ -228,7 +229,8 @@ const onMinimapToggleClick = () => {
*/
const onLinkVisibilityToggleClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_menu_hide_links_toggle_clicked'
button_id: 'graph_menu_hide_links_toggle_clicked',
element_group: 'graph_menu'
})
void commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')
}

View File

@@ -65,7 +65,8 @@ describe('InfoButton', () => {
expect(openNodeInfoMock).toHaveBeenCalled()
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
button_id: 'selection_toolbox_node_info_opened'
button_id: 'selection_toolbox_node_info_opened',
element_group: 'selection_toolbox'
})
})

View File

@@ -24,7 +24,8 @@ const onInfoClick = () => {
if (!openNodeInfo()) return
useTelemetry()?.trackUiButtonClicked({
button_id: 'selection_toolbox_node_info_opened'
button_id: 'selection_toolbox_node_info_opened',
element_group: 'selection_toolbox'
})
}
</script>

View File

@@ -1,126 +0,0 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import HdrViewerContent from './HdrViewerContent.vue'
vi.mock('@/base/common/downloadUtil', () => ({ downloadFile: vi.fn() }))
const holder = vi.hoisted(() => ({ viewer: undefined as unknown }))
vi.mock('@/composables/useHdrViewer', () => ({
useHdrViewer: () => holder.viewer,
CHANNEL_MODES: ['rgb', 'r', 'g', 'b', 'a', 'luminance']
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { loading: 'Loading', downloadImage: 'Download' },
hdrViewer: {
failedToLoad: 'Failed',
exposure: 'Exposure',
normalizeExposure: 'Auto exposure',
channel: 'Channel',
channels: {
rgb: 'RGB',
r: 'R',
g: 'G',
b: 'B',
a: 'Alpha',
luminance: 'Luminance'
},
sourceGamut: 'Source gamut',
dither: 'Dither',
clipWarnings: 'Clip warnings',
fitView: 'Fit',
histogram: 'Histogram',
resolution: 'Resolution',
min: 'Min',
max: 'Max',
mean: 'Mean',
stdDev: 'Std dev',
nan: 'NaN',
inf: 'Inf'
}
}
}
})
function makeViewer(overrides: Record<string, unknown> = {}) {
return {
exposureStops: ref(0),
dither: ref(true),
clipWarnings: ref(false),
gamut: ref('sRGB'),
channel: ref('r'),
loading: ref(false),
error: ref(null),
dimensions: ref('512 x 512'),
stats: ref({
min: 0,
max: 4,
mean: 0.5,
stdDev: 0.2,
nanCount: 2,
infCount: 1
}),
histogram: ref(new Uint32Array([1, 2, 3, 4])),
pixel: ref({ x: 1, y: 2, r: 0.1, g: 0.2, b: 0.3, a: 1 }),
mount: vi.fn(),
dispose: vi.fn(),
fitView: vi.fn(),
normalizeExposure: vi.fn(),
...overrides
}
}
function renderViewer() {
return render(HdrViewerContent, {
props: { imageUrl: '/api/view?filename=out.exr' },
global: { plugins: [i18n], stubs: { Button: true } }
})
}
describe('HdrViewerContent', () => {
beforeEach(() => {
holder.viewer = makeViewer()
})
it('renders the full statistics set including NaN/Inf', () => {
renderViewer()
for (const label of [
'Resolution',
'Min',
'Max',
'Mean',
'Std dev',
'NaN',
'Inf'
]) {
screen.getByText(label)
}
})
it('shows the pixel readout when a pixel is hovered', () => {
renderViewer()
expect(screen.getByTestId('hdr-pixel-readout')).toBeInTheDocument()
})
it('colors the histogram according to the selected channel', () => {
holder.viewer = makeViewer({ channel: ref('g') })
const { container } = renderViewer()
const path = container.querySelector('svg path')
expect(path?.getAttribute('class')).toContain('text-green-500')
})
it('renders an option for each channel mode', () => {
renderViewer()
expect(
screen.getByRole('option', { name: 'Luminance' })
).toBeInTheDocument()
})
})

View File

@@ -1,258 +0,0 @@
<template>
<div class="flex size-full bg-base-background">
<div class="relative flex-1">
<div
ref="containerRef"
class="absolute size-full"
data-testid="hdr-viewer-canvas"
/>
<div
v-if="viewer.loading.value"
class="absolute inset-0 flex items-center justify-center text-base-foreground"
>
{{ $t('g.loading') }}...
</div>
<div
v-else-if="viewer.error.value"
role="alert"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-base-foreground"
>
<i class="icon-[lucide--image-off] size-12" />
<p class="text-sm">{{ $t('hdrViewer.failedToLoad') }}</p>
</div>
<div
v-if="viewer.pixel.value"
class="absolute top-2 left-2 rounded-sm bg-base-background/80 px-2 py-1 font-mono text-xs text-base-foreground"
data-testid="hdr-pixel-readout"
>
<div>{{ viewer.pixel.value.x }}, {{ viewer.pixel.value.y }}</div>
<div>
{{ formatNum(viewer.pixel.value.r) }}
{{ formatNum(viewer.pixel.value.g) }}
{{ formatNum(viewer.pixel.value.b) }}
<template v-if="viewer.pixel.value.a !== null">
{{ formatNum(viewer.pixel.value.a) }}
</template>
</div>
</div>
</div>
<div class="flex w-72 flex-col" data-testid="hdr-viewer-sidebar">
<div class="flex-1 overflow-y-auto p-4">
<div class="space-y-2">
<div class="space-y-4 p-2">
<div class="flex flex-col gap-2">
<label>{{ $t('hdrViewer.exposure') }}: {{ exposureLabel }}</label>
<input
v-model.number="viewer.exposureStops.value"
type="range"
min="-10"
max="10"
step="0.1"
class="w-full"
:aria-label="$t('hdrViewer.exposure')"
/>
</div>
<Button
variant="secondary"
class="w-full"
@click="viewer.normalizeExposure"
>
{{ $t('hdrViewer.normalizeExposure') }}
</Button>
</div>
<div class="space-y-4 p-2">
<div class="flex flex-col gap-2">
<label>{{ $t('hdrViewer.channel') }}</label>
<select
v-model="viewer.channel.value"
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
:aria-label="$t('hdrViewer.channel')"
>
<option v-for="mode in channelModes" :key="mode" :value="mode">
{{ channelLabels[mode] }}
</option>
</select>
</div>
<div class="flex flex-col gap-2">
<label>{{ $t('hdrViewer.sourceGamut') }}</label>
<select
v-model="viewer.gamut.value"
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
:aria-label="$t('hdrViewer.sourceGamut')"
>
<option v-for="name in gamutNames" :key="name" :value="name">
{{ name }}
</option>
</select>
</div>
</div>
<div class="space-y-4 p-2">
<div class="flex items-center gap-2">
<input
id="hdr-dither"
v-model="viewer.dither.value"
type="checkbox"
class="size-4 cursor-pointer accent-node-component-surface-highlight"
/>
<label for="hdr-dither" class="cursor-pointer">
{{ $t('hdrViewer.dither') }}
</label>
</div>
<div class="flex items-center gap-2">
<input
id="hdr-clip"
v-model="viewer.clipWarnings.value"
type="checkbox"
class="size-4 cursor-pointer accent-node-component-surface-highlight"
/>
<label for="hdr-clip" class="cursor-pointer">
{{ $t('hdrViewer.clipWarnings') }}
</label>
</div>
</div>
<div v-if="histogramPath" class="space-y-2 p-2">
<label>{{ $t('hdrViewer.histogram') }}</label>
<svg
viewBox="0 0 1 1"
preserveAspectRatio="none"
class="bg-base-component-surface aspect-3/2 w-full rounded-sm"
>
<path
:d="histogramPath"
:class="histogramColorClass"
fill="currentColor"
fill-opacity="0.5"
stroke="none"
/>
</svg>
</div>
<div
v-if="viewer.stats.value"
class="space-y-1 p-2 text-xs tabular-nums"
>
<div v-if="viewer.dimensions.value" class="flex justify-between">
<span>{{ $t('hdrViewer.resolution') }}</span>
<span>{{ viewer.dimensions.value }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.min') }}</span>
<span>{{ formatNum(viewer.stats.value.min) }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.max') }}</span>
<span>{{ formatNum(viewer.stats.value.max) }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.mean') }}</span>
<span>{{ formatNum(viewer.stats.value.mean) }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.stdDev') }}</span>
<span>{{ formatNum(viewer.stats.value.stdDev) }}</span>
</div>
<div
v-if="viewer.stats.value.nanCount"
class="flex justify-between text-error"
>
<span>{{ $t('hdrViewer.nan') }}</span>
<span>{{ viewer.stats.value.nanCount }}</span>
</div>
<div
v-if="viewer.stats.value.infCount"
class="flex justify-between text-error"
>
<span>{{ $t('hdrViewer.inf') }}</span>
<span>{{ viewer.stats.value.infCount }}</span>
</div>
</div>
</div>
</div>
<div class="p-4">
<div class="flex gap-2">
<Button variant="secondary" class="flex-1" @click="viewer.fitView">
{{ $t('hdrViewer.fitView') }}
</Button>
<Button variant="secondary" class="flex-1" @click="handleDownload">
{{ $t('g.downloadImage') }}
</Button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import Button from '@/components/ui/button/Button.vue'
import type { ChannelMode } from '@/composables/useHdrViewer'
import { CHANNEL_MODES, useHdrViewer } from '@/composables/useHdrViewer'
import { GAMUT_NAMES } from '@/renderer/hdr/colorGamut'
import { toFullResolutionUrl } from '@/utils/hdrFormatUtil'
import { histogramToPath } from '@/utils/histogramUtil'
const { imageUrl } = defineProps<{ imageUrl: string }>()
const { t } = useI18n()
const viewer = useHdrViewer()
const gamutNames = GAMUT_NAMES
const channelModes = CHANNEL_MODES
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
const exposureLabel = computed(() => {
const value = viewer.exposureStops.value
return `${value > 0 ? '+' : ''}${value.toFixed(1)}`
})
const histogramPath = computed(() =>
viewer.histogram.value ? histogramToPath(viewer.histogram.value) : ''
)
const histogramColorClass = computed(() => {
switch (viewer.channel.value) {
case 'r':
return 'text-red-500'
case 'g':
return 'text-green-500'
case 'b':
return 'text-blue-500'
default:
return 'text-base-foreground'
}
})
const channelLabels = computed<Record<ChannelMode, string>>(() => ({
rgb: t('hdrViewer.channels.rgb'),
r: t('hdrViewer.channels.r'),
g: t('hdrViewer.channels.g'),
b: t('hdrViewer.channels.b'),
a: t('hdrViewer.channels.a'),
luminance: t('hdrViewer.channels.luminance')
}))
function formatNum(value: number): string {
if (!Number.isFinite(value)) return String(value)
return Math.abs(value) >= 1000 || (value !== 0 && Math.abs(value) < 0.001)
? value.toExponential(3)
: value.toFixed(4)
}
function handleDownload() {
downloadFile(toFullResolutionUrl(imageUrl))
}
onMounted(() => {
if (containerRef.value) void viewer.mount(containerRef.value, imageUrl)
})
</script>

View File

@@ -23,11 +23,8 @@
:can-use-gizmo="canUseGizmo"
:can-use-lighting="canUseLighting"
:can-export="canExport"
:can-use-hdri="canUseHdri"
:can-use-background-image="canUseBackgroundImage"
:material-modes="materialModes"
:has-skeleton="hasSkeleton"
:source-format="sourceFormat"
@update-background-image="handleBackgroundImageUpdate"
@export-model="handleExportModel"
@update-hdri-file="handleHDRIFileUpdate"
@@ -89,7 +86,7 @@
/>
<RecordingControls
v-if="canUseRecording && !isPreview"
v-if="!isPreview"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
@@ -120,18 +117,9 @@ import { resolveNode } from '@/utils/litegraphUtil'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const {
widget,
nodeId,
canUseRecording = true,
canUseHdri = true,
canUseBackgroundImage = true
} = defineProps<{
const props = defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
canUseRecording?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
}>()
function isComponentWidget(
@@ -142,11 +130,11 @@ function isComponentWidget(
const node = ref<LGraphNode | null>(null)
if (isComponentWidget(widget)) {
node.value = widget.node
} else if (nodeId) {
if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
onMounted(() => {
node.value = resolveNode(nodeId) ?? null
node.value = resolveNode(props.nodeId!) ?? null
})
}
@@ -167,7 +155,6 @@ const {
canExport,
materialModes,
hasSkeleton,
sourceFormat,
hasRecording,
recordingDuration,
animations,

View File

@@ -1,47 +0,0 @@
import { render } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
const lastProps = ref<Record<string, unknown> | null>(null)
vi.mock('@/components/load3d/Load3D.vue', () => ({
default: defineComponent({
name: 'Load3D',
props: {
widget: { type: null, required: false, default: undefined },
nodeId: { type: null, required: false, default: undefined },
canUseRecording: { type: Boolean, default: true },
canUseHdri: { type: Boolean, default: true },
canUseBackgroundImage: { type: Boolean, default: true }
},
setup(props: Record<string, unknown>) {
lastProps.value = { ...props }
return () => h('div', { 'data-testid': 'load3d-stub' })
}
})
}))
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
describe('Load3DAdvanced', () => {
it('renders the inner Load3D with all expressive features disabled', () => {
const MOCK_NODE = { id: 'node', type: 'Load3DAdvanced' }
render(Load3DAdvanced, {
props: {
widget: { node: MOCK_NODE } as never
}
})
expect(lastProps.value).toMatchObject({
canUseRecording: false,
canUseHdri: false,
canUseBackgroundImage: false
})
})
it('forwards widget and nodeId to the inner Load3D', () => {
const widget = { node: { id: 'a', type: 'Load3DAdvanced' } }
render(Load3DAdvanced, { props: { widget: widget as never, nodeId: 'a' } })
expect(lastProps.value?.widget).toEqual(widget)
expect(lastProps.value?.nodeId).toBe('a')
})
})

View File

@@ -1,21 +0,0 @@
<template>
<Load3D
:widget="widget"
:node-id="nodeId"
:can-use-recording="false"
:can-use-hdri="false"
:can-use-background-image="false"
/>
</template>
<script setup lang="ts">
import Load3D from '@/components/load3d/Load3D.vue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
}>()
</script>

View File

@@ -52,7 +52,6 @@
v-model:background-image="sceneConfig!.backgroundImage"
v-model:background-render-mode="sceneConfig!.backgroundRenderMode"
v-model:fov="cameraConfig!.fov"
:show-background-image="canUseBackgroundImage"
:hdri-active="
!!lightConfig?.hdri?.hdriPath && !!lightConfig?.hdri?.enabled
"
@@ -82,7 +81,6 @@
/>
<HDRIControls
v-if="canUseHdri"
v-model:hdri-config="lightConfig!.hdri"
:has-background-image="!!sceneConfig?.backgroundImage"
@update-hdri-file="handleHDRIFileUpdate"
@@ -91,7 +89,6 @@
<ExportControls
v-if="showExportControls"
:source-format="sourceFormat"
@export-model="handleExportModel"
/>
@@ -132,20 +129,14 @@ const {
canUseGizmo = true,
canUseLighting = true,
canExport = true,
canUseHdri = true,
canUseBackgroundImage = true,
materialModes = ['original', 'normal', 'wireframe'],
hasSkeleton = false,
sourceFormat = null
hasSkeleton = false
} = defineProps<{
canUseGizmo?: boolean
canUseLighting?: boolean
canExport?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
sourceFormat?: string | null
}>()
const sceneConfig = defineModel<SceneConfig>('sceneConfig')

View File

@@ -59,7 +59,6 @@ function buildViewerStub() {
canUseGizmo: ref(true),
canUseLighting: ref(true),
canExport: ref(true),
sourceFormat: ref<string | null>(null),
materialModes: ref(['original', 'normal', 'wireframe']),
animations: ref<Array<{ name: string; index: number }>>([]),
playing: ref(false),

View File

@@ -82,10 +82,7 @@
</div>
<div v-if="viewer.canExport.value" class="space-y-4 p-2">
<ExportControls
:source-format="viewer.sourceFormat.value"
@export-model="viewer.exportModel"
/>
<ExportControls @export-model="viewer.exportModel" />
</div>
</div>
</div>

View File

@@ -13,12 +13,9 @@ const i18n = createI18n({
}
})
function renderComponent(
onExportModel?: (format: string) => void,
sourceFormat: string | null = null
) {
function renderComponent(onExportModel?: (format: string) => void) {
const utils = render(ExportControls, {
props: { onExportModel, sourceFormat },
props: { onExportModel },
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
@@ -66,23 +63,6 @@ describe('ExportControls', () => {
).not.toBeInTheDocument()
})
it('offers only the source format for direct-export files (e.g. ply)', async () => {
const onExportModel = vi.fn()
const { user } = renderComponent(onExportModel, 'ply')
await user.click(screen.getByRole('button', { name: 'Export model' }))
expect(screen.getByRole('button', { name: 'PLY' })).toBeVisible()
for (const label of ['GLB', 'OBJ', 'STL', 'FBX']) {
expect(
screen.queryByRole('button', { name: label })
).not.toBeInTheDocument()
}
await user.click(screen.getByRole('button', { name: 'PLY' }))
expect(onExportModel).toHaveBeenCalledWith('ply')
})
it('hides the popup when a click happens outside the trigger', async () => {
const { user } = renderComponent()

View File

@@ -35,14 +35,9 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
const { sourceFormat = null } = defineProps<{
sourceFormat?: string | null
}>()
const emit = defineEmits<{
(e: 'exportModel', format: string): void
@@ -50,7 +45,12 @@ const emit = defineEmits<{
const showExportFormats = ref(false)
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
const exportFormats = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' },
{ label: 'FBX', value: 'fbx' }
]
function toggleExportFormats() {
showExportFormats.value = !showExportFormats.value

View File

@@ -37,7 +37,7 @@
</Button>
</div>
<div v-if="showBackgroundImage && !hasBackgroundImage">
<div v-if="!hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.uploadBackgroundImage'),
@@ -61,7 +61,7 @@
</div>
</template>
<div v-if="showBackgroundImage && hasBackgroundImage">
<div v-if="hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.panoramaMode'),
@@ -83,16 +83,12 @@
</div>
<PopupSlider
v-if="
showBackgroundImage &&
hasBackgroundImage &&
backgroundRenderMode === 'panorama'
"
v-if="hasBackgroundImage && backgroundRenderMode === 'panorama'"
v-model="fov"
:tooltip-text="$t('load3d.fov')"
/>
<div v-if="showBackgroundImage && hasBackgroundImage">
<div v-if="hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.removeBackgroundImage'),
@@ -118,9 +114,8 @@ import Button from '@/components/ui/button/Button.vue'
import type { BackgroundRenderModeType } from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
const { hdriActive = false, showBackgroundImage = true } = defineProps<{
const { hdriActive = false } = defineProps<{
hdriActive?: boolean
showBackgroundImage?: boolean
}>()
const emit = defineEmits<{

View File

@@ -72,12 +72,9 @@ const i18n = createI18n({
messages: { en: { load3d: { export: 'Export' } } }
})
function renderComponent(
onExportModel?: (format: string) => void,
sourceFormat: string | null = null
) {
function renderComponent(onExportModel?: (format: string) => void) {
const utils = render(ViewerExportControls, {
props: { onExportModel, sourceFormat },
props: { onExportModel },
global: { plugins: [i18n] }
})
return { ...utils, user: userEvent.setup() }
@@ -117,32 +114,4 @@ describe('ViewerExportControls', () => {
expect(onExportModel).toHaveBeenCalledWith('glb')
})
it('offers only the source format for direct-export files (e.g. spz)', async () => {
const onExportModel = vi.fn()
const { user } = renderComponent(onExportModel, 'spz')
const select = screen.getByRole('combobox') as HTMLSelectElement
expect(Array.from(select.options).map((o) => o.value)).toEqual(['spz'])
await user.click(screen.getByRole('button', { name: 'Export' }))
expect(onExportModel).toHaveBeenCalledWith('spz')
})
it('repairs the selected format when sourceFormat switches to a direct-export type', async () => {
const onExportModel = vi.fn()
const { user, rerender } = renderComponent(onExportModel, null)
const select = screen.getByRole('combobox') as HTMLSelectElement
expect(select.value).toBe('obj')
await rerender({ onExportModel, sourceFormat: 'ply' })
expect(Array.from(select.options).map((o) => o.value)).toEqual(['ply'])
await user.click(screen.getByRole('button', { name: 'Export' }))
expect(onExportModel).toHaveBeenCalledWith('ply')
})
})

View File

@@ -26,7 +26,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
@@ -34,30 +34,20 @@ import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
const { sourceFormat = null } = defineProps<{
sourceFormat?: string | null
}>()
const emit = defineEmits<{
(e: 'exportModel', format: string): void
}>()
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
const exportFormats = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' },
{ label: 'FBX', value: 'fbx' }
]
const exportFormat = ref('obj')
watch(
exportFormats,
(formats) => {
if (!formats.some((fmt) => fmt.value === exportFormat.value)) {
exportFormat.value = formats[0]?.value ?? ''
}
},
{ immediate: true }
)
const exportModel = (format: string) => {
emit('exportModel', format)
}

View File

@@ -1,70 +0,0 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import PaletteSwatchRow from './PaletteSwatchRow.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
})
function renderRow(modelValue: string[], max = 5) {
return render(PaletteSwatchRow, {
props: { modelValue, max },
global: { plugins: [i18n] }
})
}
const lastEmit = (emitted: () => Record<string, unknown[][]>) => {
const calls = emitted()['update:modelValue']
return calls[calls.length - 1][0]
}
describe('PaletteSwatchRow', () => {
it('renders one swatch per color', () => {
const { container } = renderRow(['#ff0000', '#00ff00'])
expect(container.querySelectorAll('[data-index]')).toHaveLength(2)
})
it('appends a color when the add button is clicked', async () => {
const { emitted } = renderRow(['#ff0000'])
await userEvent.click(screen.getByRole('button'))
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
})
it('removes a color on right click', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.contextMenu(container.querySelector('[data-index="0"]')!)
expect(lastEmit(emitted)).toEqual(['#00ff00'])
})
it('hides the add button once the max is reached', () => {
renderRow(['#a', '#b'], 2)
expect(screen.queryByRole('button')).toBeNull()
})
it('writes a picked color back through the hidden color input', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.click(container.querySelector('[data-index="1"]')!)
const input = container.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#0000ff'
await fireEvent.input(input)
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
})
it('starts a drag on pointer down without emitting', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.pointerDown(container.querySelector('[data-index="0"]')!, {
button: 0,
clientX: 5,
clientY: 5
})
expect(emitted()['update:modelValue']).toBeUndefined()
})
})

View File

@@ -1,48 +0,0 @@
<template>
<div ref="container" class="flex flex-wrap items-center gap-1">
<div
v-for="(hex, i) in modelValue"
:key="`${i}-${hex}`"
:data-index="i"
:data-hex="hex"
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
:style="{ background: hex }"
:title="t('palette.swatchTitle')"
@click="openPicker(i, $event)"
@contextmenu.prevent.stop="remove(i)"
@pointerdown="onPointerDown(i, $event)"
/>
<button
v-if="modelValue.length < max"
type="button"
class="h-5 rounded-sm border border-component-node-border bg-component-node-widget-background px-2 text-xs leading-none"
:title="t('palette.addColor')"
@click="addColor"
>
+
</button>
<input
ref="picker"
type="color"
class="pointer-events-none absolute size-0 opacity-0"
@input="onPickerInput"
/>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
const { max = 5 } = defineProps<{ max?: number }>()
const modelValue = defineModel<string[]>({ required: true })
const { t } = useI18n()
const container = useTemplateRef<HTMLDivElement>('container')
const picker = useTemplateRef<HTMLInputElement>('picker')
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
usePaletteSwatchRow({ modelValue, container, picker })
</script>

View File

@@ -1,54 +0,0 @@
/* eslint-disable testing-library/no-node-access, testing-library/no-container, testing-library/prefer-user-event */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import WidgetColors from './WidgetColors.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
})
function renderWidget(modelValue: string[], widget?: { name: string }) {
return render(WidgetColors, {
props: { modelValue, widget },
global: { plugins: [i18n] }
})
}
const cleanups: Array<() => void> = []
afterEach(() => {
while (cleanups.length) cleanups.pop()?.()
})
describe('WidgetColors', () => {
it('renders the palette swatch row for each color', () => {
renderWidget(['#ff0000', '#00ff00'])
const root = screen.getByTestId('colors')
expect(root.querySelectorAll('[data-index]')).toHaveLength(2)
})
it('shows the widget name as an inline label', () => {
renderWidget(['#ff0000'], { name: 'color_palette' })
expect(screen.getByText('color_palette')).toBeInTheDocument()
})
it('emits an updated palette when a color is added', async () => {
const { emitted } = renderWidget([])
await userEvent.click(screen.getByRole('button'))
const calls = emitted()['update:modelValue'] as unknown[][]
expect(calls[calls.length - 1][0]).toEqual(['#ffffff'])
})
it('does not stop swatch pointer moves from reaching document drag handlers', async () => {
const { container } = renderWidget(['#ff0000'])
const onDocMove = vi.fn()
document.addEventListener('pointermove', onDocMove)
cleanups.push(() => document.removeEventListener('pointermove', onDocMove))
await fireEvent.pointerMove(container.querySelector('[data-index="0"]')!)
expect(onDocMove).toHaveBeenCalled()
})
})

View File

@@ -1,29 +0,0 @@
<template>
<div
class="flex size-full items-center gap-2"
data-testid="colors"
@pointerdown.stop
>
<span
v-if="widget?.name"
class="shrink-0 truncate text-node-component-slot-text"
>
{{ widget.label || widget.name }}
</span>
<PaletteSwatchRow v-model="modelValue" :max="MAX_COLORS" />
</div>
</template>
<script setup lang="ts">
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import PaletteSwatchRow from './PaletteSwatchRow.vue'
const MAX_COLORS = 16
const { widget } = defineProps<{
widget?: Pick<SimplifiedWidget<string[]>, 'name' | 'label'>
}>()
const modelValue = defineModel<string[]>({ default: () => [] })
</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()
})

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