Compare commits

..

28 Commits

Author SHA1 Message Date
CodeJuggernaut
4308aad97f Merge branch 'main' into nathaniel/qol-fixes-FE-910 2026-07-02 17:10:15 -07:00
ShihChi Huang
7610a61250 test: cover queue display formatting (#13089)
## Summary

Add direct tests for queue job display formatting.

Base: `main`

## Changes

- Covers state icons, pending/initializing labels, running progress,
completed local/cloud output, fallback completed titles, and failed
display.

## Test Results

| | before | after |
| -- | -- | -- |
| `pnpm test:unit src/utils/queueDisplay.test.ts --run` | no direct
queue display test file |  13 passed |

## Coverage

Superseded by #13332. Historical pre-#13313 branch coverage:
`src/utils/queueDisplay.ts` 22.72% -> 79.54% (+56.82%); overall branches
52.95% -> 53.03% (+0.08%).

Codecov project coverage is intentionally omitted here because it is not
the branch-ratchet metric.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Test-only change; no runtime or production code modified.
> 
> **Overview**
> Adds **`src/utils/queueDisplay.test.ts`**, a Vitest suite that
exercises **`iconForJobState`** and **`buildJobDisplay`** from
`queueDisplay.ts` without touching UI or production logic.
> 
> Tests use small **`createJob` / `createTask` / `createCtx`** helpers
with a stub **`t`** and clock formatter so expectations assert i18n keys
and formatted values. Coverage includes pending “added to queue” hint,
queued/initializing labels, active vs inactive running progress,
completed local preview vs cloud duration, completed title fallback, and
failed rows with **`showClear`** behavior.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
6260c101e5. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-02 22:39:24 +00:00
ShihChi Huang
47c8b09ebf test: 2/x cover fuse search ranking (#13087)
## Summary

Add direct tests for `fuseUtil` search ranking and filter behavior.


## Changes

- Covers ranking tiers, deprecated penalties, post-processing, empty
queries, auxiliary score comparison, and filter wildcard/comma matching.

## Test Results

- `pnpm test:unit src/utils/fuseUtil.test.ts`: 7 passed.
- `pnpm typecheck`: passed.
- `pnpm test:coverage`: 876 test files passed; 11,759 passed / 8
skipped.

## Coverage

Superseded by #13332. Historical pre-#13313 branch coverage:
`src/utils/fuseUtil.ts` 81.48% -> 92.59% (+11.11%); overall branches
52.93% -> 52.95% (+0.02%).

Codecov project coverage is intentionally omitted here because it is not
the branch-ratchet metric.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> <sup>[Cursor Bugbot](https://cursor.com/bugbot) is generating a
summary for commit 8bf748d1a4. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-07-02 22:30:50 +00:00
ShihChi Huang
65b4c53bcb ci: skip website report deploy for fork PRs (#13344)
## Summary

Skip the website e2e report/deploy step for fork PRs, which lack the
deploy secrets and otherwise fail the job.

## Changes

- **What**: Guard the report/deploy step's `if:` in
`ci-website-e2e.yaml` so it runs only when the event is not a fork pull
request.
- **Breaking**: none. CI-config only.

## Review Focus

CI-config only — no test or coverage change. Confirms fork PRs no longer
fail on the deploy step.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> CI workflow condition only; no application or test logic changes.
> 
> **Overview**
> **Website E2E CI** no longer runs the **Deploy report to Cloudflare**
step on pull requests from forks.
> 
> The step’s `if:` still requires `always()` and `!cancelled()`, and now
also requires either a non–pull-request event or a PR whose head repo is
**not** a fork. Playwright tests and artifact upload are unchanged; only
the wrangler deploy (which needs `CLOUDFLARE_*` secrets) is skipped for
fork PRs so those runs don’t fail when secrets aren’t available.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
02a4ab0769. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-02 22:30:03 +00:00
ShihChi Huang
15b31d69ea ci: skip secret-backed CI deploys for fork PRs (#13291)
## Summary

Skip secret-backed CI deploy and dispatch work for fork PRs so missing
repo secrets do not fail otherwise valid checks.

## Changes

- **What**: Guard Website E2E report deploy, Vercel website preview
deploy, cloud build dispatch, cloud cleanup dispatch, and Storybook
Chromatic deploy so PR paths only run for same-repo PRs.
- **Dependencies**: None

## Why

Fork `pull_request` runs do not receive repository secrets. Several CI
jobs already separated normal validation from privileged follow-up work,
but some deploy or dispatch steps could still run on fork PRs and fail
only because their secret-backed integration token was empty.

The existing Website E2E fork guard only protected the PR comment job.
It did not protect the earlier Cloudflare report deploy step inside
`website-e2e`, which uses `CLOUDFLARE_API_TOKEN` and
`CLOUDFLARE_ACCOUNT_ID`.

The same failure mode existed in these CI jobs:

- `ci-vercel-website-preview.yaml`: preview deploy uses Vercel and
website API secrets.
- `cloud-dispatch-build.yaml`: preview dispatch uses
`CLOUD_DISPATCH_TOKEN` to call `Comfy-Org/cloud`.
- `cloud-dispatch-cleanup.yaml`: preview cleanup dispatch uses
`CLOUD_DISPATCH_TOKEN`.
- `ci-tests-storybook.yaml`: Chromatic deploy uses
`CHROMATIC_PROJECT_TOKEN`.

`ci-website-build.yaml` was left unchanged. Its Ashby and Cloud nodes
integrations intentionally fall back to committed snapshots when secrets
are missing for preview/local builds, so it is not the same class of
fork-secret failure.

## Review Focus

Confirm fork PRs still run the unprivileged validation/build paths,
while same-repo PRs and non-PR events keep the existing deploy or
dispatch behavior.

## Validation PRs

Both validation PRs compare against `main`.

- Fork PR from `shihchi`:
[#13309](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13309)
- Same-repo PR from `origin`:
[#13310](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13310)

| Workflow | Guarded job or step | Fork #13309 | Same-repo #13310 |
| --- | --- | --- | --- |
| CI: Website E2E | `Upload test report` | success  | success  |
| CI: Website E2E | `Deploy report to Cloudflare` | skipped  | success
 |
| CI: Vercel Website Preview | `deploy-preview` | skipped  | success 
|
| Cloud Frontend Build Dispatch | `dispatch` | skipped  | success  |
| CI: Tests Storybook | `chromatic-deployment` | skipped  | success  |

Expected result: fork PRs still keep the useful validation artifact
path, but skip secret-backed deploy and dispatch work. Same-repo PRs
keep the privileged behavior.

## Screenshots (if applicable)

N/A, CI-only.

Created by Codex

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Workflow `if` condition changes only; no application code. Same-repo
PR behavior is unchanged when secrets are available.
> 
> **Overview**
> Adds **`github.event.pull_request.head.repo.fork == false`** guards so
fork PRs no longer run steps that need repo secrets or trigger external
deploys.
> 
> **Website E2E** — the Cloudflare Playwright report deploy step now
runs only on non-PR events or same-repo PRs, so fork runs can still pass
tests and upload artifacts without failing on missing `CLOUDFLARE_*`
secrets.
> 
> **Vercel website preview** — the preview deploy job is skipped
entirely for fork PRs (Vercel tokens).
> 
> **Storybook Chromatic** — Chromatic deployment on `version-bump-*` PRs
is limited to non-fork PRs (`CHROMATIC_PROJECT_TOKEN`).
> 
> **Cloud dispatch** — build and cleanup dispatches to the cloud repo
for preview labels no longer run for fork PRs, aligning with the
existing fork-guard comment in those workflows.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
027aabc9e3. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-02 22:29:47 +00:00
Benjamin Lu
471236e08d feat: track subscription cancellation intent and resubscribe clicks (#13368)
## Summary

Instruments the churn funnel: cancellation intent, attempt, abandonment,
and request failure, plus resubscribe clicks — all client-observed from
existing request/response flows, no watchers or polling added. Covers
both billing paths: the mainline (`/customers/*` + Stripe portal) path
via the "Manage subscription" click, and the workspace path via its
in-app cancel dialog.

## Changes

- **What**:
- New events: `app:subscription_cancel_flow_opened` / `_confirmed` /
`_abandoned` / `_failed` and `app:resubscribe_button_clicked`, via
`trackSubscriptionCancellation(stage, metadata)` and
`trackResubscribeClicked` (registry, PostHog, host sink)
  - All cancellation events carry a `source` discriminator:
- `manage_subscription_button` — the mainline path. Legacy users can
only cancel inside the Stripe billing portal, and in-app UI already
covers plan changes, so this click is the closest observable
cancel-intent signal for ~all production users. Only `flow_opened` fires
here (everything past the click happens in Stripe's UI). Probable, not
certain, intent — the portal also serves card updates/invoices.
- `cancel_plan_menu` — the workspace in-app dialog (allowlist-gated
pilot): `flow_opened` on mount, `confirmed` before the API call (failed
attempts still register), `failed` with the error message, `abandoned`
on "Keep subscription"/close. Successful cancels close via a different
path and never emit `abandoned`.
- Metadata carries `current_tier`, billing `cycle`, and (dialog path)
the `end_date` shown to the user
- Resubscribe clicks tracked at both call sites with `source`:
`pricing_dialog` (`useSubscriptionCheckout`, also carrying the dialog's
`payment_intent_source` from #13363) and `settings_billing_panel`
(`useResubscribe`)
- Not instrumented on purpose: the workspace "Manage billing" button and
the "Invoice history" footer link (portal opens without cancel
connotation)

## Review Focus

- Deliberately **no** client-side "cancel succeeded" event: outcome
truth is server-side. Mainline already has it
(`billing:subscription_deleted` from the Stripe webhook in comfy-api);
the workspace path needs a `subscription_cancelled` billing event type
(separate cloud-repo change). The legacy
`useSubscriptionCancellationWatcher` poller emits an undercounted
`app:monthly_subscription_cancelled`; analysis should prefer the server
event.
- `confirmed` fires before the request; growth can join
`flow_opened`/`confirmed` → server-side cancelled events by user +
timestamp.
2026-07-02 14:51:12 -07:00
Mobeen Abdullah
4cc0402325 revert(website): remove Creative Campus customer stories (#13370) (#13407)
## Summary

Reverts #13370 (the five Creative Campus customer stories) from `main`.
These are education-tied stories, and the "Education Program is live"
CTA links to the education page, which is not live yet, so they should
not be public before the education launch.

This is a clean `git revert` of the squash commit `49a90d4e2` (no
history rewrite, no force-push). No work is lost: the story branch
(`feat/website-customer-stories-education`) is intact, and the stories
will relaunch together with pricing and the education page via #13406.

## Changes

- **What**: Reverts the 5 new story MDX files, the new article block
components, and the related changes to `CustomerArticle.astro`,
`global.css`, `Figure`/`Quote`/`Contributors`, the content test, and the
e2e spec. The existing five stories and the customers pages are
unaffected.
- **Breaking**: none.

## Review Focus

- Pure inverse of #13370; the diff is `-858/+11` mirroring the original
merge.
- Files touched by #13370 are disjoint from the education-page work in
#13406, so this does not conflict with that branch.

## Verification

- Build: 497 pages (down 5 en story pages). Unit: 156/156. Typecheck: 0
errors. format:check and knip clean.

## Next steps

- Stories move into the education bundle (#13406) via a separate PR.
- When the education page and its auth (FE-1174) are ready, pricing +
customer stories + education launch together.
2026-07-03 01:49:47 +05:00
Wei Hai
a2adfe5124 fix(ci): drop unsupported 'range' genhtml ignore-errors category (#13396)
## Summary
- `CI: E2E Coverage`'s `Generate HTML coverage report` step fails on
every run with `genhtml: ERROR: unknown argument for --ignore-errors:
'range'`
- The runner's `apt-get install lcov` resolves to lcov 2.0-4ubuntu2
(Ubuntu 24.04/noble), but the `range` ignore-errors category was only
added in lcov 2.1
- lcov 2.0 already reports the out-of-range-line condition under the
`source` category, which is already in the ignore list, so `range` was
both unsupported and redundant on this runner

## Test plan
- [x] Confirmed lcov 2.0-4ubuntu2 is what `apt-get install lcov`
resolves to on `ubuntu-latest`
- [x] Confirmed via lcov's `lcovutil.pm` source that `range`
(`$ERROR_RANGE`) is only registered as of v2.1, and in v2.0 the
equivalent out-of-range case falls under `$ERROR_SOURCE`
- [ ] CI: E2E Coverage run on this branch's merge should pass the
"Generate HTML coverage report" step
2026-07-02 20:08:47 +00:00
Mobeen Abdullah
49a90d4e2e feat(website): add five Creative Campus customer stories (#13370)
## Summary

Add the five new Comfy Education Initiative (Creative Campus) customer
stories to `/customers`, each with its own detail page, reusing the
existing Astro content-collection pattern. Brings the listing to ten
stories. Linear: FE-1161.

## Changes

- **What**: Five new English MDX stories (Xindi Zhang, Ina Conradi,
Golan Levin, Kathy Smith, and the UAL CCI partnership) added to the
customers collection, ordered after the existing five. Adds a small set
of reusable article blocks these stories need: `Embed` (Vimeo), `Video`
(wraps the existing `VideoPlayer`), `Download` (workflow JSON),
`AuthorBio`, `EducationCta`, `AtAGlance`, a styled inline `Link`, and
`Heading4`. `Quote`'s `name` is now optional for unattributed
pull-quotes; `Figure` gained an optional rich-caption slot (for captions
that contain links); `AuthorBio` supports a single-author bio via slot.
- **Breaking**: none. All additions are backward compatible; the
existing five stories and their pages are untouched.
- **Dependencies**: none.

## Review Focus

- The logic to review is small and isolated: the new block components in
`components/customers/content/` and their registration in
`CustomerArticle.astro`. The rest of the diff is MDX content.
- **Story copy is transcribed verbatim from the source docs**;
punctuation (em/en dashes, curly quotes) is preserved as written and is
intentional, not a formatting slip.
- **Downloads (cross-origin):** the workflow JSON files are on
media.comfy.org, so the HTML `download` attribute is ignored by
browsers. The real download is forced server-side with
`Content-Disposition: attachment` on the storage objects. Xindi's two
workflow files are served from a cache-fresh `.../workflows/` path (with
an explicit `filename=`) so the CDN serves the attachment header
immediately.
- **Embed hardening:** the Vimeo `Embed` iframe carries
`referrerpolicy="strict-origin-when-cross-origin"` and a scoped
`sandbox` (`allow-scripts allow-same-origin allow-presentation
allow-popups`); the player was verified to still load and play.
- All media (card covers, inline images, one video with a poster frame,
workflow JSON/PNG downloads) is hosted on media.comfy.org. No local
assets are committed. Golan's workflow files are re-hosted there; his
lesson-plan and demo-project links intentionally stay on GitHub/p5.js as
view-only.
- English-first: Chinese versions will be added later through a separate
translation service. The listing and detail pages already handle a
locale that only has English entries, so no page-code changes were
needed.
- Tags: "Creative Campus Showcase" for the four teaching stories, and
"Creative Campus Partnership" for the UAL announcement.

## Verification

- Unit `176/176`, typecheck (astro check) `0 errors`, build `502 pages`,
`format:check`, `knip`, and `eslint` all pass.
- e2e customer specs `6/6` pass (includes a new test asserting the
Creative Campus education blocks render).
- Visual pass on all ten stories at desktop (1440) and mobile (390): no
horizontal overflow, the Vimeo player plays, and all downloads resolve
to media.comfy.org.

## Screenshots (if applicable)

Easiest way to review is the Vercel preview:
https://comfy-website-preview-pr-13370.vercel.app/customers then open
the five new stories. Verified on desktop (1440) and mobile (390).
2026-07-03 00:34:20 +05:00
CodeJuggernaut
46273bf368 Merge branch 'main' into nathaniel/qol-fixes-FE-910 2026-07-02 11:48:21 -07:00
Hunter
d6c582c399 feat(billing): gate consolidated billing behind consolidated_billing_enabled flag (#13359)
## Summary

Shields personal-workspace billing code paths behind the new
`consolidated_billing_enabled` feature flag so they fall back to the
**legacy** billing flow while the flag is `false`. Team workspaces are
unaffected and continue to use the workspace-scoped billing flow.

## Changes

- Add `consolidatedBillingEnabled` to `useFeatureFlags` (reads the
`consolidated_billing_enabled` server flag / remote config, defaults to
`false`) and to the `RemoteConfig` type.
- New `useBillingRouting` composable — a single source of truth for
whether the active workspace uses the workspace vs. legacy billing flow:
  - team workspaces disabled → legacy
  - personal workspace + consolidated billing off/missing → legacy
  - personal workspace + consolidated billing on → workspace
  - team workspace → workspace
  - workspace not loaded yet → legacy
- Route `useBillingContext` and the affected UI sites
(`SubscriptionPanel`, `useSubscriptionDialog`, `UsageLogsTable`,
`TopUpCreditsDialogContentLegacy`) through `useBillingRouting` instead
of keying on `teamWorkspacesEnabled` directly.
- Update the storybook `useFeatureFlags` mock to stay in sync.

## Testing

- `pnpm test:unit` for `useBillingRouting`, `useBillingContext`,
`useSubscriptionDialog`, and `UsageLogsTable` (new + updated coverage
for the routing matrix). Remaining quality gates (`typecheck`, `lint`)
are being verified in CI.

## Related

Requires the backend PR that adds the `consolidated_billing_enabled`
flag to `/api/features`.

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-07-02 18:34:39 +00:00
Nathaniel Parson Koroso
1518bbd585 test: drop redundant empty-space marquee e2e
The empty-space marquee behavior is already covered deterministically by
the useAssetGridSelection unit test "selects intersecting cards when
dragging from empty space" and end-to-end by the passing "panel header"
marquee test (identical onPointerDown code path: both are non-card,
non-interactive presses). The removed test.fixme only exercised a headless
mouse-synthesis quirk, not product behavior, so no meaningful coverage is lost.
2026-07-02 11:16:11 -07:00
imick-io
a6db1ab3d6 fix(website): restore node-link.svg intrinsic sizing (#13384)
## Summary

Restore the original `node-link.svg` asset, which PR #13095 accidentally
overwrote with a stretch-to-fill Figma export, breaking the node
connector across the marketing site.

## Changes

- **What**: Revert `apps/website/public/icons/node-link.svg` to its
intrinsic **20×32** form (`fill="#F2FF59"`). PR #13283 had replaced it
with a raw Figma export (`preserveAspectRatio="none"`, `width="100%"
height="100%"`, `fill="var(--fill-0, …)"`). Every consumer loads it as a
bare `<img src>` and relies on the intrinsic size plus
`scale-*`/`rotate` classes — with no intrinsic dimensions the connector
expanded to fill its container and distorted.

## Review Focus

- The overwrite originated in the first commit of #13283's stack and
rode through the squash merge; nothing in that PR actually referenced
this file (the MCP page uses the separate `NodeUnionIcon.vue`), so
restoring the shared asset fixes all consumers (`BuildWhatSection`,
`ProductShowcaseSection`, `OurValuesSection`, `GalleryDetailModal`)
without touching the MCP page.
- `apps/website/dist/icons/node-link.svg` is stale build output and
regenerates on the next `pnpm build`.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-02 13:07:00 +00:00
Nathaniel Parson Koroso
a3437b0676 test: reference FE-910 in the skipped empty-space marquee fixme
Gives the test.fixme an explicit follow-up path per review; the
headless-CI flake is tracked under FE-910.
2026-07-01 16:55:01 -07:00
Nathaniel Parson Koroso
752084faa2 refactor: type RectEdges as a DOMRect subset, drop the Box alias
Per review: RectEdges is now Pick<DOMRect,'left'|'top'|'right'|'bottom'> (the built-in subset a DOMRect already satisfies) rather than a bespoke interface, and the redundant Box alias is removed in favor of RectEdges directly. Type-only, no runtime change.
2026-07-01 16:04:42 -07:00
Nathaniel Parson Koroso
2c0afbc70a test: name asset select-all guard for the outside-rect case 2026-07-01 14:56:14 -07:00
Nathaniel Parson Koroso
30a77a9a73 feat: enable graph live selection by default
Nodes select/deselect in real time during the rubber-band drag, matching the media assets panel. Comfy.Graph.LiveSelection already drove canvas.liveSelection via useLitegraphSettings; this flips its default on.
2026-07-01 11:41:21 -07:00
Nathaniel Parson Koroso
55edc67677 fix: recover asset-panel select-all after deselect all
Ctrl/Cmd+A select-all was gated only on useElementHover(panel). When the
absolute selection bar under the cursor unmounts on "deselect all", the browser
fires mouseleave up the panel's hover chain with no compensating mouseenter, so
isHoveringPanel latches false while the pointer is still inside the panel. The
next Ctrl/Cmd+A bailed and the browser's native select-all highlighted page text.

Also accept select-all when the live pointer position (tracked in the existing
window pointermove handler) lies within the panel's bounding rect, recovering
from the stale-hover desync while still declining when the pointer is genuinely
outside (e.g. over the canvas).
2026-07-01 11:31:10 -07:00
Nathaniel Parson Koroso
c3fc0a9218 test: mark flaky empty-space marquee e2e as fixme (covered by sibling tests) 2026-06-30 17:58:07 -07:00
Nathaniel Parson Koroso
fcfd314738 test: rubber-band across the full card width in the empty-space marquee e2e 2026-06-30 17:09:37 -07:00
Nathaniel Parson Koroso
d4e72f7330 fix: capture marquee pointer on drag start, and address review feedback
- Capture the pointer only once a marquee drag engages past the threshold
  (late capture). A Ctrl/Cmd+click on a card no longer captures the pointer and
  hijacks the click, which had broken Ctrl/Cmd+click multi-select
- Disable the marquee in list view explicitly via an isEnabled guard
- Snapshot card rects and container bounds once per drag instead of re-measuring
  every pointermove; cache the canvas panel bounds per drag in SelectionRectangle
- Reuse RectEdges for Box; use native Array.prototype.findLastIndex
- Assert display_name in the MediaAssetCard drag-payload test
- e2e: merge the duplicate beforeEach, add a panelHeader page-object locator,
  and add an aria-modal Ctrl/Cmd+A suppression test
2026-06-30 15:36:35 -07:00
Nathaniel Parson Koroso
3cf1dd2136 fix: keep display_name in the asset drag payload to match main 2026-06-30 14:17:39 -07:00
Nathaniel Parson Koroso
6e4162b9f8 test: assert panel select-all does not propagate to global handlers 2026-06-30 14:05:10 -07:00
Nathaniel Parson Koroso
4342c340fa test: cover marquee select edge cases from review
- unit: native drag not blocked when no marquee is tracking; text
  selection unblocks after a normal pointerup release
- e2e: Ctrl/Cmd-marquee starting from a card and within a single card;
  Ctrl/Cmd+A in the search input and a drag from it leave assets unselected
2026-06-30 12:26:23 -07:00
Nathaniel Parson Koroso
ab6ea4c32d fix: harden Media Assets marquee selection and Ctrl/Cmd+A
- Cancel the native drag image on Ctrl/Cmd-marquee from a card
- Skip Ctrl/Cmd+A select-all when an aria-modal dialog is open
- Use the primary-background theme token for the marquee overlay
- Replace global body user-select with a panel-scoped selectstart guard
- Auto-reset post-drag click suppression so a cancelled drag can't swallow a click
- Guard setPointerCapture against stale pointer ids
2026-06-30 11:27:03 -07:00
Nathaniel Parson Koroso
548c1ab40d fix: read hasSelection.value in the empty-space click guard
hasSelection is a ref, so the bare truthy check always passed; clearing
now only fires when something is actually selected.
2026-06-26 12:43:31 -07:00
Nathaniel Parson Koroso
d31052a450 fix: clip the canvas selection rectangle to the canvas panel
Clamp the graph node-selection rubber-band to the .graph-canvas-panel
bounds so it no longer renders over the sidebar. Visual only, through
the shared clampRectToBounds helper; no litegraph behavior changes.
2026-06-26 12:42:12 -07:00
Nathaniel Parson Koroso
2575a99d7c feat: add marquee select and Ctrl/Cmd+A to the Media Assets panel
Rubber-band select from an empty grid spot, or hold Ctrl/Cmd to marquee
from a card; the drag can begin anywhere in the panel (title, search,
tabs) since the grid gaps were too thin. The overlay is clamped to the
panel and the pointer is captured so a release over the canvas still
ends the drag. Ctrl/Cmd+A selects all assets while the panel is hovered
and yields to the canvas otherwise, bailing while a text field is
focused. Native card drag, trailing-click suppression, and the
shift-range anchor are preserved. Covered by unit and e2e tests.
2026-06-26 12:40:25 -07:00
75 changed files with 3414 additions and 2557 deletions

View File

@@ -121,7 +121,7 @@ jobs:
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1 \
--ignore-errors source,unmapped,range \
--ignore-errors source,unmapped \
--synthesize-missing
- name: Upload HTML report artifact

View File

@@ -95,6 +95,7 @@ jobs:
if: |
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& startsWith(github.head_ref, 'version-bump-')
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'

View File

@@ -30,7 +30,7 @@ concurrency:
jobs:
deploy-preview:
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-latest
permissions:
contents: read

View File

@@ -67,7 +67,15 @@ jobs:
- name: Deploy report to Cloudflare
id: deploy
if: always() && !cancelled()
if: >-
${{
always() &&
!cancelled() &&
(
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork == false
)
}}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

View File

@@ -32,12 +32,13 @@ jobs:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(github.event_name != 'pull_request' ||
(github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
(github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))))
runs-on: ubuntu-latest
steps:
- name: Build client payload

View File

@@ -21,6 +21,7 @@ jobs:
# - Preview label specifically removed
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'closed' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,3 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
</svg>

Before

Width:  |  Height:  |  Size: 380 B

After

Width:  |  Height:  |  Size: 279 B

View File

@@ -322,6 +322,9 @@ export class AssetsSidebarTab extends SidebarTab {
// --- Folder view ---
public readonly backToAssetsButton: Locator
// --- Panel chrome ---
public readonly panelHeader: Locator
// --- Loading ---
public readonly skeletonLoaders: Locator
@@ -358,6 +361,7 @@ export class AssetsSidebarTab extends SidebarTab {
this.deleteSelectedButton = page.getByTestId('assets-delete-selected')
this.downloadSelectedButton = page.getByTestId('assets-download-selected')
this.backToAssetsButton = page.getByText('Back to all assets')
this.panelHeader = page.locator('.comfy-vue-side-bar-header')
this.skeletonLoaders = page.locator(
'.sidebar-content-container .animate-pulse'
)

View File

@@ -28,7 +28,12 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// matches it against the members self-row.
const SELF_EMAIL = 'e2e@test.comfy.org'
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
// consolidated_billing_enabled routes personal workspaces to the unified
// pricing table asserted here; without it they fall back to the legacy table.
const BOOT_FEATURES = {
team_workspaces_enabled: true,
consolidated_billing_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.

View File

@@ -276,3 +276,224 @@ test.describe('FE-130 assets sidebar route mocks', () => {
)
})
})
test.describe('FE-910 marquee selection and select all', () => {
test.beforeEach(async ({ jobsRoutes, page, comfyPage }) => {
await jobsRoutes.mockJobsQueue([])
await jobsRoutes.mockJobsHistory(generatedJobs)
await mockInputFiles(page, ['imported.png'])
await mockViewFiles(page, viewFiles)
await comfyPage.setup()
await comfyPage.menu.assetsTab.open()
})
test('Ctrl/Cmd+A selects every asset while the panel is hovered', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await expect(tab.assetCards).toHaveCount(2)
await tab.getAssetCardByName('alpha').hover()
await comfyPage.page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(2)
})
test('a marquee that begins in the panel header selects the cards', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
await expect(tab.selectedCards).toHaveCount(0)
const header = await tab.panelHeader.boundingBox()
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!header || !beta) {
throw new Error('panel header or asset card has no layout box')
}
// Begin the rubber-band in the header (above the grid), then drag down
// across both cards.
await page.mouse.move(header.x + 24, header.y + 20)
await page.mouse.down()
await page.mouse.move(beta.x + 8, beta.y + beta.height - 8, { steps: 14 })
await page.mouse.up()
await expect(tab.selectedCards).toHaveCount(2)
await expect(tab.selectionFooter).toBeVisible()
})
test('Ctrl/Cmd+A leaves assets unselected while the canvas is hovered', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
const viewport = page.viewportSize()
if (!viewport) throw new Error('viewport size is unavailable')
// Hover the canvas (not the panel); Ctrl/Cmd+A must yield to the canvas.
await page.mouse.move(viewport.width - 100, viewport.height / 2)
await page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(0)
})
test('a modifier-held marquee adds to the existing selection', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
await tab.getAssetCardByName('alpha').click()
await expect(tab.selectedCards).toHaveCount(1)
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!beta) throw new Error('beta card has no layout box')
// Hold a modifier so the marquee is additive, then rubber-band over beta.
await page.keyboard.down('Control')
await page.mouse.move(beta.x + 12, beta.y + 12)
await page.mouse.down()
await page.mouse.move(beta.x + beta.width - 12, beta.y + beta.height - 12, {
steps: 12
})
await page.mouse.up()
await page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(2)
})
test('Ctrl/Cmd-dragging from an asset card starts a marquee selection', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
await expect(tab.selectedCards).toHaveCount(0)
const alpha = await tab.getAssetCardByName('alpha').boundingBox()
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!alpha || !beta) throw new Error('asset cards have no layout box')
// Ctrl bypasses card drag, so a press that begins on a card rubber-bands.
await page.keyboard.down('Control')
await page.mouse.move(alpha.x + alpha.width / 2, alpha.y + alpha.height / 2)
await page.mouse.down()
await page.mouse.move(beta.x + beta.width - 6, beta.y + beta.height - 6, {
steps: 12
})
await page.mouse.up()
await page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(2)
await expect(tab.selectionFooter).toBeVisible()
})
test('Ctrl/Cmd-dragging within a single card selects only that card', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
const alpha = tab.getAssetCardByName('alpha')
const box = await alpha.boundingBox()
if (!box) throw new Error('alpha card has no layout box')
const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
await page.keyboard.down('Control')
await page.mouse.move(start.x, start.y)
await page.mouse.down()
await page.mouse.move(start.x + 12, start.y + 12, { steps: 4 })
await page.mouse.up()
await page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(1)
await expect(alpha).toHaveAttribute('data-selected', 'true')
})
test('Ctrl/Cmd+A in the focused search input does not select assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const query = 'alpha'
await tab.searchInput.fill(query)
await expect(tab.assetCards).toHaveCount(1)
await tab.searchInput.focus()
await comfyPage.page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(0)
await expect
.poll(() =>
tab.searchInput.evaluate((el) => {
if (!(el instanceof HTMLInputElement)) {
throw new Error('expected the asset search input')
}
return { start: el.selectionStart, end: el.selectionEnd }
})
)
.toEqual({ start: 0, end: query.length })
})
test('a drag starting in the search input does not marquee-select assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
const search = await tab.searchInput.boundingBox()
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!search || !beta)
throw new Error('search box or card has no layout box')
await page.mouse.move(
search.x + search.width / 2,
search.y + search.height / 2
)
await page.mouse.down()
await page.mouse.move(beta.x + beta.width / 2, beta.y + beta.height / 2, {
steps: 12
})
await page.mouse.up()
await expect(tab.selectedCards).toHaveCount(0)
})
test('Ctrl/Cmd+A does not select assets while an aria-modal dialog is open', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await expect(tab.assetCards).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const dialog = document.createElement('div')
dialog.id = 'test-modal'
dialog.setAttribute('role', 'dialog')
dialog.setAttribute('aria-modal', 'true')
document.body.appendChild(dialog)
})
await tab.getAssetCardByName('alpha').hover()
await comfyPage.page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(0)
await comfyPage.page.evaluate(() => {
document.getElementById('test-modal')?.remove()
})
})
})

View File

@@ -158,8 +158,8 @@ import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import { useExternalLink } from '@/composables/useExternalLink'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
@@ -178,7 +178,7 @@ const settingsDialog = useSettingsDialog()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { flags } = useFeatureFlags()
const { shouldUseWorkspaceBilling } = useBillingRouting()
const { isSubscriptionEnabled } = useSubscription()
// Constants
@@ -260,9 +260,9 @@ async function handleBuy() {
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
handleClose(false)
// In workspace mode (personal workspace), show workspace settings panel
// Otherwise, show legacy subscription/credits panel
const settingsPanel = flags.teamWorkspacesEnabled
// On the consolidated (workspace) billing flow, show the workspace settings
// panel; otherwise show the legacy subscription/credits panel.
const settingsPanel = shouldUseWorkspaceBilling.value
? 'workspace'
: isSubscriptionEnabled()
? 'subscription'

View File

@@ -2,12 +2,11 @@ import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, onMounted, ref } from 'vue'
import { defineComponent, nextTick, onMounted, ref } from 'vue'
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'
@@ -35,19 +34,29 @@ vi.mock('@/services/customerEventsService', () => ({
}
}))
const mockTelemetry = vi.hoisted(() => ({
checkForCompletedTopup: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
useTelemetry: () => mockTelemetry
}))
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 mockBillingRouting = vi.hoisted(() => ({
shouldUseWorkspaceBilling: false
}))
vi.mock('@/composables/billing/useBillingRouting', async () => {
const { ref } = await import('vue')
const shouldUseWorkspaceBilling = ref(false)
Object.defineProperty(mockBillingRouting, 'shouldUseWorkspaceBilling', {
get: () => shouldUseWorkspaceBilling.value,
set: (value: boolean) => {
shouldUseWorkspaceBilling.value = value
}
})
return {
useBillingRouting: () => ({ shouldUseWorkspaceBilling })
}
})
const mockWorkspaceApi = vi.hoisted(() => ({
getBillingEvents: vi.fn()
@@ -68,7 +77,10 @@ const i18n = createI18n({
additionalInfo: 'Additional Info',
added: 'Added',
accountInitialized: 'Account initialized',
model: 'Model'
model: 'Model',
loadEventsError: 'Failed to load activity. Please try again.',
loadEventsUnknownError:
'Something went wrong while loading activity. Please refresh and try again.'
}
}
}
@@ -95,6 +107,11 @@ const AutoRefreshWrapper = defineComponent({
template: '<UsageLogsTable ref="tableRef" />'
})
async function flushMicrotasks() {
await new Promise((resolve) => setTimeout(resolve, 0))
await nextTick()
}
function makeEventsResponse(
events: Partial<AuditLog>[],
overrides: Record<string, unknown> = {}
@@ -137,7 +154,7 @@ describe('UsageLogsTable', () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
mockWorkspaceApi.getBillingEvents.mockResolvedValue(mockEventsResponse)
mockFlags.teamWorkspacesEnabled = false
mockBillingRouting.shouldUseWorkspaceBilling = false
mockCustomerEventsService.formatEventType.mockImplementation(
(type: string) => {
switch (type) {
@@ -228,7 +245,7 @@ describe('UsageLogsTable', () => {
})
})
it('shows error message when service throws', async () => {
it('shows a localized fallback instead of a raw Error message', async () => {
mockCustomerEventsService.getMyEvents.mockRejectedValue(
new Error('Network error')
)
@@ -236,7 +253,25 @@ describe('UsageLogsTable', () => {
renderWithAutoRefresh()
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument()
expect(
screen.getByText(
'Something went wrong while loading activity. Please refresh and try again.'
)
).toBeInTheDocument()
})
expect(screen.queryByText('Network error')).not.toBeInTheDocument()
})
it('shows a localized fallback when the service reports no message', async () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(null)
mockCustomerEventsService.error.value = null
renderWithAutoRefresh()
await waitFor(() => {
expect(
screen.getByText('Failed to load activity. Please try again.')
).toBeInTheDocument()
})
})
@@ -341,8 +376,8 @@ describe('UsageLogsTable', () => {
})
describe('billing events source', () => {
it('uses workspaceApi.getBillingEvents when teamWorkspacesEnabled is on', async () => {
mockFlags.teamWorkspacesEnabled = true
it('uses workspaceApi.getBillingEvents on the workspace billing flow', async () => {
mockBillingRouting.shouldUseWorkspaceBilling = true
await renderLoaded()
@@ -352,6 +387,90 @@ describe('UsageLogsTable', () => {
})
expect(mockCustomerEventsService.getMyEvents).not.toHaveBeenCalled()
})
it('discards a stale legacy response when routing flips mid-fetch', async () => {
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
mockCustomerEventsService.getMyEvents.mockReturnValue(
new Promise((resolve) => {
resolveLegacy = resolve
})
)
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
makeEventsResponse([
{
event_id: 'workspace-1',
event_type: EventType.API_USAGE_COMPLETED,
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
createdAt: '2024-02-01T10:00:00Z'
}
])
)
renderWithAutoRefresh()
mockBillingRouting.shouldUseWorkspaceBilling = true
await waitFor(() => {
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
})
resolveLegacy(
makeEventsResponse([
{
event_id: 'legacy-1',
event_type: EventType.API_USAGE_COMPLETED,
params: { api_name: 'LegacyAPI', model: 'legacy-model' },
createdAt: '2024-01-01T10:00:00Z'
}
])
)
await flushMicrotasks()
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
expect(screen.queryByText('LegacyAPI')).not.toBeInTheDocument()
})
it('runs top-up completion telemetry for a superseded response', async () => {
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
mockCustomerEventsService.getMyEvents.mockReturnValue(
new Promise((resolve) => {
resolveLegacy = resolve
})
)
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
makeEventsResponse([
{
event_id: 'workspace-1',
event_type: EventType.API_USAGE_COMPLETED,
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
createdAt: '2024-02-01T10:00:00Z'
}
])
)
renderWithAutoRefresh()
mockBillingRouting.shouldUseWorkspaceBilling = true
await waitFor(() => {
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
})
const legacyResponse = makeEventsResponse([
{
event_id: 'legacy-1',
event_type: EventType.CREDIT_ADDED,
params: { amount: 1000 },
createdAt: '2024-01-01T10:00:00Z'
}
])
resolveLegacy(legacyResponse)
await waitFor(() => {
expect(mockTelemetry.checkForCompletedTopup).toHaveBeenCalledWith(
legacyResponse.events
)
})
})
})
describe('EventType integration', () => {

View File

@@ -96,11 +96,11 @@ import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import { useTelemetry } from '@/platform/telemetry'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import type { AuditLog } from '@/services/customerEventsService'
@@ -109,14 +109,15 @@ import {
useCustomerEventsService
} from '@/services/customerEventsService'
const { t } = useI18n()
const events = ref<AuditLog[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const customerEventService = useCustomerEventsService()
const { flags } = useFeatureFlags()
const useBillingApi = computed(() => isCloud && flags.teamWorkspacesEnabled)
const { shouldUseWorkspaceBilling } = useBillingRouting()
const pagination = ref({
page: 1,
@@ -139,7 +140,12 @@ const tooltipContentMap = computed(() => {
return map
})
// A billing-route flip can overlap two loads against different backends; only
// the latest may mutate state, so a superseded response is discarded.
let latestLoadToken = 0
const loadEvents = async () => {
const loadToken = ++latestLoadToken
loading.value = true
error.value = null
@@ -148,10 +154,17 @@ const loadEvents = async () => {
page: pagination.value.page,
limit: pagination.value.limit
}
const response = useBillingApi.value
const response = shouldUseWorkspaceBilling.value
? await workspaceApi.getBillingEvents(params)
: await customerEventService.getMyEvents(params)
// Completion telemetry must run even when a mid-checkout route flip
// supersedes this load, since legacy and workspace backends emit different
// top-up events and the winning fetch may not carry the completion yet.
useTelemetry()?.checkForCompletedTopup(response?.events)
if (loadToken !== latestLoadToken) return
if (response) {
if (response.events) {
events.value = response.events
@@ -165,24 +178,25 @@ const loadEvents = async () => {
pagination.value.limit = response.limit
}
if (response.total) {
if (response.total != null) {
pagination.value.total = response.total
}
if (response.totalPages) {
if (response.totalPages != null) {
pagination.value.totalPages = response.totalPages
}
// Check if a pending top-up has completed
useTelemetry()?.checkForCompletedTopup(response.events)
} else {
error.value = customerEventService.error.value || 'Failed to load events'
const legacyError = shouldUseWorkspaceBilling.value
? null
: customerEventService.error.value
error.value = legacyError || t('credits.loadEventsError')
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error'
if (loadToken !== latestLoadToken) return
error.value = t('credits.loadEventsUnknownError')
console.error('Error loading events:', err)
} finally {
loading.value = false
if (loadToken === latestLoadToken) loading.value = false
}
}
@@ -198,6 +212,12 @@ const refresh = async () => {
await loadEvents()
}
watch(shouldUseWorkspaceBilling, () => {
refresh().catch((error) => {
console.error('Error loading events:', error)
})
})
defineExpose({
refresh
})

View File

@@ -42,22 +42,34 @@ function withStrictMillisecondParser<T>(run: () => T): T {
}
const mockSubscription = vi.hoisted(() => ({
value: null as { endDate: string | null } | null
value: null as {
endDate: string | null
duration?: 'ANNUAL' | 'MONTHLY' | 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())
const mockTier = vi.hoisted(() => ({ value: 'STANDARD' as string | null }))
const mockTrackCancellation = vi.hoisted(() => vi.fn())
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
cancelSubscription: mockCancelSubscription,
fetchStatus: mockFetchStatus,
subscription: mockSubscription
subscription: mockSubscription,
tier: mockTier
}))
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackSubscriptionCancellation: mockTrackCancellation
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: vi.fn(() => ({
closeDialog: mockCloseDialog
@@ -94,6 +106,95 @@ function renderComponent(props: { cancelAt?: string } = {}) {
describe('CancelSubscriptionDialogContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTier.value = 'STANDARD'
})
describe('cancellation telemetry', () => {
it('tracks flow_opened with tier and end date when the dialog mounts', () => {
mockSubscription.value = { endDate: '2026-08-01T00:00:00.000Z' }
renderComponent()
expect(mockTrackCancellation).toHaveBeenCalledWith('flow_opened', {
source: 'cancel_plan_menu',
current_tier: 'standard',
end_date: '2026-08-01T00:00:00.000Z'
})
})
it('tracks confirmed before the cancel request and no abandoned on success', async () => {
mockSubscription.value = null
mockCancelSubscription.mockResolvedValueOnce(undefined)
const { unmount } = renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /^cancel subscription$/i })
)
await waitFor(() => expect(mockCloseDialog).toHaveBeenCalled())
unmount()
expect(mockTrackCancellation).toHaveBeenCalledWith(
'confirmed',
expect.objectContaining({ current_tier: 'standard' })
)
expect(mockTrackCancellation).not.toHaveBeenCalledWith(
'abandoned',
expect.anything()
)
})
it('tracks confirmed and failed with message-carrying rejection values', async () => {
mockSubscription.value = null
mockCancelSubscription.mockRejectedValueOnce({ message: 'timed out' })
renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /^cancel subscription$/i })
)
await waitFor(() =>
expect(mockTrackCancellation).toHaveBeenCalledWith(
'failed',
expect.objectContaining({ error_message: 'timed out' })
)
)
expect(mockTrackCancellation).toHaveBeenCalledWith(
'confirmed',
expect.anything()
)
})
it('tracks abandoned when the user keeps the subscription', async () => {
mockSubscription.value = null
const { unmount } = renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /keep subscription/i })
)
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'cancel-subscription'
})
unmount()
expect(mockTrackCancellation).toHaveBeenCalledWith(
'abandoned',
expect.objectContaining({ current_tier: 'standard' })
)
expect(mockCancelSubscription).not.toHaveBeenCalled()
})
it('tracks abandoned when the dialog is dismissed by the shell', () => {
mockSubscription.value = null
const { unmount } = renderComponent()
mockTrackCancellation.mockClear()
unmount()
expect(mockTrackCancellation).toHaveBeenCalledWith(
'abandoned',
expect.objectContaining({ current_tier: 'standard' })
)
})
})
describe('cancel flow', () => {
@@ -138,6 +239,35 @@ describe('CancelSubscriptionDialogContent', () => {
expect.objectContaining({ severity: 'success' })
)
})
it('does not track cancellation failure when status refresh fails after cancellation succeeds', async () => {
mockSubscription.value = null
mockCancelSubscription.mockResolvedValueOnce(undefined)
mockFetchStatus.mockRejectedValueOnce(new Error('Refresh failed'))
const { unmount } = renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /^cancel subscription$/i })
)
await waitFor(() =>
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
)
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'cancel-subscription'
})
expect(
mockTrackCancellation.mock.calls.some(([stage]) => stage === 'failed')
).toBe(false)
unmount()
expect(mockTrackCancellation).not.toHaveBeenCalledWith(
'abandoned',
expect.anything()
)
})
})
describe('formattedEndDate fallbacks', () => {

View File

@@ -45,13 +45,16 @@
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useTelemetry } from '@/platform/telemetry'
import type { SubscriptionCancellationMetadata } from '@/platform/telemetry/types'
import { useDialogStore } from '@/stores/dialogStore'
import { parseIsoDateSafe } from '@/utils/dateTimeUtil'
import { getErrorMessage } from '@/utils/errorUtil'
const props = defineProps<{
cancelAt?: string
@@ -60,9 +63,41 @@ const props = defineProps<{
const { t } = useI18n()
const dialogStore = useDialogStore()
const toast = useToast()
const { cancelSubscription, fetchStatus, subscription } = useBillingContext()
const { cancelSubscription, fetchStatus, subscription, tier } =
useBillingContext()
const telemetry = useTelemetry()
const isLoading = ref(false)
const didCancelSucceed = ref(false)
function cancellationMetadata(): SubscriptionCancellationMetadata {
const endDate = props.cancelAt ?? subscription.value?.endDate
return {
source: 'cancel_plan_menu' as const,
current_tier: tier.value?.toLowerCase(),
...(subscription.value?.duration
? {
cycle:
subscription.value.duration === 'ANNUAL'
? ('yearly' as const)
: ('monthly' as const)
}
: {}),
...(endDate ? { end_date: endDate } : {})
}
}
onMounted(() => {
telemetry?.trackSubscriptionCancellation(
'flow_opened',
cancellationMetadata()
)
})
onUnmounted(() => {
if (didCancelSucceed.value || isLoading.value) return
telemetry?.trackSubscriptionCancellation('abandoned', cancellationMetadata())
})
const formattedEndDate = computed(() => {
const date = parseIsoDateSafe(props.cancelAt ?? subscription.value?.endDate)
@@ -84,24 +119,37 @@ function onClose() {
}
async function onConfirmCancel() {
telemetry?.trackSubscriptionCancellation('confirmed', cancellationMetadata())
isLoading.value = true
try {
await cancelSubscription()
await fetchStatus()
dialogStore.closeDialog({ key: 'cancel-subscription' })
toast.add({
severity: 'success',
summary: t('subscription.cancelSuccess'),
life: 5000
})
} catch (error) {
const errorMessage = getErrorMessage(error)
telemetry?.trackSubscriptionCancellation('failed', {
...cancellationMetadata(),
error_message: errorMessage ?? String(error)
})
toast.add({
severity: 'error',
summary: t('subscription.cancelDialog.failed'),
detail: error instanceof Error ? error.message : t('g.unknownError')
detail: errorMessage ?? t('g.unknownError')
})
} finally {
isLoading.value = false
return
}
didCancelSucceed.value = true
try {
await fetchStatus()
} catch {
// Cancellation already succeeded; stale local subscription status should not report failure.
}
dialogStore.closeDialog({ key: 'cancel-subscription' })
toast.add({
severity: 'success',
summary: t('subscription.cancelSuccess'),
life: 5000
})
isLoading.value = false
}
</script>

View File

@@ -0,0 +1,110 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { render, screen } from '@testing-library/vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import SelectionRectangle from './SelectionRectangle.vue'
const rafCallbacks: Array<() => void> = []
vi.mock('@vueuse/core', () => ({
useRafFn: (cb: () => void) => {
rafCallbacks.push(cb)
return { pause: vi.fn(), resume: vi.fn() }
}
}))
const mockCanvas = ref<unknown>(null)
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
get canvas() {
return mockCanvas.value
}
})
}))
function addCanvasPanelDom() {
const panel = document.createElement('div')
panel.className = 'graph-canvas-panel'
vi.spyOn(panel, 'getBoundingClientRect').mockReturnValue(
fromPartial<DOMRect>({ left: 300, top: 0, right: 1000, bottom: 800 })
)
const canvas = document.createElement('canvas')
canvas.id = 'graph-canvas'
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue(
fromPartial<DOMRect>({ left: 0, top: 0, right: 1000, bottom: 800 })
)
document.body.append(panel, canvas)
}
function dragRectangle(eDown: [number, number], eMove: [number, number]) {
mockCanvas.value = {
dragging_rectangle: true,
pointer: {
eDown: { safeOffsetX: eDown[0], safeOffsetY: eDown[1] },
eMove: { safeOffsetX: eMove[0], safeOffsetY: eMove[1] }
}
}
rafCallbacks[rafCallbacks.length - 1]()
}
describe('SelectionRectangle', () => {
afterEach(() => {
rafCallbacks.length = 0
mockCanvas.value = null
document.body.replaceChildren()
vi.restoreAllMocks()
})
it('clips the rectangle to the canvas panel when dragged over the sidebar', async () => {
addCanvasPanelDom()
render(SelectionRectangle)
dragRectangle([100, 100], [800, 400])
await nextTick()
const rect = screen.getByTestId('selection-rectangle')
expect(rect.style.left).toBe('300px')
expect(rect.style.top).toBe('100px')
expect(rect.style.width).toBe('500px')
expect(rect.style.height).toBe('300px')
})
it('leaves a rectangle within the panel unchanged', async () => {
addCanvasPanelDom()
render(SelectionRectangle)
dragRectangle([400, 100], [600, 300])
await nextTick()
const rect = screen.getByTestId('selection-rectangle')
expect(rect.style.left).toBe('400px')
expect(rect.style.top).toBe('100px')
expect(rect.style.width).toBe('200px')
expect(rect.style.height).toBe('200px')
})
it('normalizes and clips a rectangle dragged up-and-left', async () => {
addCanvasPanelDom()
render(SelectionRectangle)
dragRectangle([800, 400], [100, 100])
await nextTick()
const rect = screen.getByTestId('selection-rectangle')
expect(rect.style.left).toBe('300px')
expect(rect.style.top).toBe('100px')
expect(rect.style.width).toBe('500px')
expect(rect.style.height).toBe('300px')
})
it('renders unclamped edges when the canvas panel is absent', async () => {
render(SelectionRectangle)
dragRectangle([100, 100], [800, 400])
await nextTick()
const rect = screen.getByTestId('selection-rectangle')
expect(rect.style.left).toBe('100px')
expect(rect.style.width).toBe('700px')
})
})

View File

@@ -1,6 +1,7 @@
<template>
<div
v-show="isVisible"
data-testid="selection-rectangle"
class="pointer-events-none absolute z-9999 border border-blue-400 bg-blue-500/20"
:style="rectangleStyle"
/>
@@ -11,6 +12,8 @@ import { useRafFn } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { clampRectToBounds } from '@/utils/mathUtil'
import type { RectEdges } from '@/utils/mathUtil'
const canvasStore = useCanvasStore()
@@ -20,17 +23,22 @@ const selectionRect = ref<{
w: number
h: number
} | null>(null)
const panelBounds = ref<RectEdges | null>(null)
useRafFn(() => {
const canvas = canvasStore.canvas
if (!canvas) {
selectionRect.value = null
panelBounds.value = null
return
}
const { pointer, dragging_rectangle } = canvas
if (dragging_rectangle && pointer.eDown && pointer.eMove) {
if (!selectionRect.value) {
panelBounds.value = getCanvasPanelBounds()
}
const x = pointer.eDown.safeOffsetX
const y = pointer.eDown.safeOffsetY
const w = pointer.eMove.safeOffsetX - x
@@ -39,25 +47,47 @@ useRafFn(() => {
selectionRect.value = { x, y, w, h }
} else {
selectionRect.value = null
panelBounds.value = null
}
})
const isVisible = computed(() => selectionRect.value !== null)
function getCanvasPanelBounds(): RectEdges | null {
const panelEl = document.querySelector('.graph-canvas-panel')
const canvasEl = document.getElementById('graph-canvas')
if (!panelEl || !canvasEl) return null
const panel = panelEl.getBoundingClientRect()
const canvas = canvasEl.getBoundingClientRect()
return {
left: panel.left - canvas.left,
top: panel.top - canvas.top,
right: panel.right - canvas.left,
bottom: panel.bottom - canvas.top
}
}
const rectangleStyle = computed(() => {
const rect = selectionRect.value
if (!rect) return {}
const left = rect.w >= 0 ? rect.x : rect.x + rect.w
const top = rect.h >= 0 ? rect.y : rect.y + rect.h
const width = Math.abs(rect.w)
const height = Math.abs(rect.h)
const edges: RectEdges = {
left: rect.w >= 0 ? rect.x : rect.x + rect.w,
top: rect.h >= 0 ? rect.y : rect.y + rect.h,
right: rect.w >= 0 ? rect.x + rect.w : rect.x,
bottom: rect.h >= 0 ? rect.y + rect.h : rect.y
}
const bounds = panelBounds.value
const { left, top, right, bottom } = bounds
? clampRectToBounds(edges, bounds)
: edges
return {
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`
width: `${right - left}px`,
height: `${bottom - top}px`
}
})
</script>

View File

@@ -1,5 +1,6 @@
<template>
<SidebarTabTemplate
ref="panelRef"
:title="isInFolderView ? '' : $t('sideToolbar.mediaAssets.title')"
v-bind="$attrs"
>
@@ -100,18 +101,19 @@
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
<AssetsSidebarGridView
v-else
:assets="displayAssets"
:is-selected="isSelected"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
@zoom="handleZoomClick"
@output-count-click="enterFolderView"
/>
<div v-else class="size-full">
<AssetsSidebarGridView
:assets="displayAssets"
:is-selected="isSelected"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
@zoom="handleZoomClick"
@output-count-click="enterFolderView"
/>
</div>
</div>
</template>
<template #footer>
@@ -125,6 +127,13 @@
/>
</template>
</SidebarTabTemplate>
<Teleport to="body">
<div
v-if="marqueeStyle"
class="pointer-events-none fixed z-9999 border border-primary-background bg-primary-background/20"
:style="marqueeStyle"
/>
</Teleport>
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
@@ -164,8 +173,10 @@ import {
onMounted,
onUnmounted,
ref,
watch
watch,
watchEffect
} from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
@@ -182,6 +193,7 @@ import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBa
import MediaAssetSelectionBar from '@/platform/assets/components/MediaAssetSelectionBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
import { useAssetGridSelection } from '@/platform/assets/composables/useAssetGridSelection'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
@@ -259,7 +271,10 @@ const outputAssets = useAssetsApi('output')
// Asset selection
const {
isSelected,
selectedIds,
handleAssetClick,
selectAll,
setSelectedIds,
hasSelection,
clearSelection,
getSelectedAssets,
@@ -270,6 +285,13 @@ const {
deactivate: deactivateSelection
} = useAssetSelection()
const panelRef = ref<ComponentPublicInstance>()
const marqueePanelRef = ref<HTMLElement>()
watchEffect(() => {
const el = panelRef.value?.$el
marqueePanelRef.value = el instanceof HTMLElement ? el : undefined
})
const {
downloadAssets,
deleteAssets,
@@ -337,6 +359,16 @@ const visibleAssets = computed(() => {
return listViewSelectableAssets.value
})
const { marqueeStyle } = useAssetGridSelection({
marqueeContainerRef: marqueePanelRef,
hoverTargetRef: marqueePanelRef,
getAssets: () => visibleAssets.value,
getSelectedIds: () => [...selectedIds.value],
setSelectedIds,
selectAll,
isEnabled: () => !isListView.value
})
const previewableVisibleAssets = computed(() =>
visibleAssets.value.filter((asset) =>
isPreviewableMediaType(getMediaTypeFromFilename(asset.name))
@@ -575,7 +607,7 @@ const handleDeselectAll = () => {
}
const handleEmptySpaceClick = () => {
if (hasSelection) {
if (hasSelection.value) {
clearSelection()
}
}

View File

@@ -19,6 +19,7 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
const {
mockTeamWorkspacesEnabled,
mockConsolidatedBillingEnabled,
mockIsPersonal,
mockPlans,
mockPurchaseCredits,
@@ -26,6 +27,7 @@ const {
mockBillingStatus
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockConsolidatedBillingEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn(),
@@ -57,11 +59,23 @@ vi.mock('@/composables/useFeatureFlags', async () => {
teamWorkspacesEnabledRef.value = value
}
})
const consolidatedBillingEnabledRef = ref(
mockConsolidatedBillingEnabled.value
)
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
get: () => consolidatedBillingEnabledRef.value,
set: (value: boolean) => {
consolidatedBillingEnabledRef.value = value
}
})
return {
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
},
get consolidatedBillingEnabled() {
return mockConsolidatedBillingEnabled.value
}
}
})
@@ -151,6 +165,7 @@ describe('useBillingContext', () => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockTeamWorkspacesEnabled.value = false
mockConsolidatedBillingEnabled.value = false
mockIsPersonal.value = true
mockPlans.value = []
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
@@ -162,16 +177,27 @@ describe('useBillingContext', () => {
expect(type.value).toBe('legacy')
})
it('selects workspace type for personal when team workspaces are enabled', () => {
it('keeps personal on legacy when consolidated billing is disabled', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('legacy')
})
it('selects workspace type for personal when consolidated billing is enabled', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = true
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('workspace')
})
it('selects workspace type for team when team workspaces are enabled', () => {
it('selects workspace type for team regardless of consolidated billing', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockIsPersonal.value = false
const { type } = useBillingContext()
@@ -272,6 +298,7 @@ describe('useBillingContext', () => {
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
// Authenticated remote config resolves the flag on for the same workspace
mockConsolidatedBillingEnabled.value = true
mockTeamWorkspacesEnabled.value = true
await vi.waitFor(() => {
@@ -280,9 +307,27 @@ describe('useBillingContext', () => {
})
})
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockIsPersonal.value = true
const { type } = useBillingContext()
await nextTick()
expect(type.value).toBe('legacy')
mockConsolidatedBillingEnabled.value = true
await vi.waitFor(() => {
expect(type.value).toBe('workspace')
expect(workspaceApi.getBillingStatus).toHaveBeenCalled()
})
})
describe('subscription mirror to workspace store', () => {
it('mirrors subscription for personal workspaces when team workspaces are enabled', async () => {
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = true
mockIsPersonal.value = true
const { initialize } = useBillingContext()
@@ -294,6 +339,20 @@ describe('useBillingContext', () => {
subscriptionPlan: null
})
})
it('never clobbers the list-derived store when a subscription is absent', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
const { initialize } = useBillingContext()
await initialize()
await nextTick()
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalledWith({
isSubscribed: false,
subscriptionPlan: null
})
})
})
describe('getMaxSeats', () => {

View File

@@ -1,7 +1,6 @@
import { computed, ref, shallowRef, toValue, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
KEY_TO_TIER,
getTierFeatures
@@ -18,10 +17,10 @@ import type {
BalanceInfo,
BillingActions,
BillingContext,
BillingType,
BillingState,
SubscriptionInfo
} from './types'
import { useBillingRouting } from './useBillingRouting'
import { useLegacyBilling } from './useLegacyBilling'
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
@@ -35,8 +34,9 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
* Unified billing context that selects the billing implementation by build/flag.
*
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
* - Team workspaces enabled: workspace billing via /api/billing/* for both
* personal (single-seat workspace) and team workspaces
* - Team workspaces enabled: workspace billing via /api/billing/* for team
* workspaces, and for personal workspaces once consolidated billing is
* enabled; personal workspaces otherwise stay on legacy billing
*
* The context automatically initializes when the workspace changes and provides
* a unified interface for subscription status, balance, and billing actions.
@@ -69,7 +69,7 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
*/
function useBillingContextInternal(): BillingContext {
const store = useTeamWorkspaceStore()
const { flags } = useFeatureFlags()
const { type } = useBillingRouting()
const legacyBillingRef = shallowRef<(BillingState & BillingActions) | null>(
null
@@ -96,16 +96,6 @@ function useBillingContextInternal(): BillingContext {
const isLoading = ref(false)
const error = ref<string | null>(null)
/**
* Determines which billing type to use, keyed only on the build/flag:
* - Team workspaces feature disabled (OSS/Desktop): legacy (/customers)
* - Team workspaces feature enabled: workspace (/api/billing), for both
* personal (single-seat workspace) and team workspaces
*/
const type = computed<BillingType>(() =>
flags.teamWorkspacesEnabled ? 'workspace' : 'legacy'
)
const activeContext = computed(() =>
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
)
@@ -170,9 +160,12 @@ function useBillingContextInternal(): BillingContext {
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
}
// Sync subscription info to workspace store for display in workspace switcher
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
// This ensures the delete button is enabled after cancellation, even before the period ends
// Sync subscription info to workspace store for display in workspace switcher.
// Subscribed means active AND not cancelled, so the delete button enables
// after cancellation, even before the period ends. A null subscription means
// "not loaded yet" (adapters are discarded on every workspace/type switch);
// skip it so the transient reinit gap can't clobber the list-derived baseline
// (personal workspaces and subscribed teams already read subscribed there).
watch(
subscription,
(sub) => {
@@ -186,24 +179,27 @@ function useBillingContextInternal(): BillingContext {
{ immediate: true }
)
// Discarding the adapter instances forces a fresh fetch and lets an in-flight
// init detect that it was superseded (its captured adapter is no longer the
// active one), so a stale response can't resolve into a ready state for the
// wrong workspace.
function resetBillingState() {
legacyBillingRef.value = null
workspaceBillingRef.value = null
isInitialized.value = false
isLoading.value = false
error.value = null
}
// type can flip after setup when the team-workspaces flag resolves from
// authenticated config, swapping the active backend; a fresh init is needed.
// The watch fires only when id or type actually changes, so any fire with a
// workspace selected warrants a reinit.
// type flips when the team-workspaces or consolidated-billing flag resolves
// from authenticated config, swapping the active backend. Reset then reinit
// on every workspace-id or type change.
watch(
[() => store.activeWorkspace?.id, () => type.value],
async ([newWorkspaceId]) => {
if (!newWorkspaceId) {
resetBillingState()
return
}
resetBillingState()
if (!newWorkspaceId) return
isInitialized.value = false
try {
await initialize()
} catch (err) {
@@ -216,17 +212,20 @@ function useBillingContextInternal(): BillingContext {
async function initialize(): Promise<void> {
if (isInitialized.value) return
const adapter = activeContext.value
isLoading.value = true
error.value = null
try {
await activeContext.value.initialize()
await adapter.initialize()
if (activeContext.value !== adapter) return
isInitialized.value = true
} catch (err) {
if (activeContext.value !== adapter) return
error.value =
err instanceof Error ? err.message : 'Failed to initialize billing'
throw err
} finally {
isLoading.value = false
if (activeContext.value === adapter) isLoading.value = false
}
}

View File

@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBillingRouting } from './useBillingRouting'
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
mockFlags: {
teamWorkspacesEnabled: false,
consolidatedBillingEnabled: false
},
mockActiveWorkspace: {
value: null as { id: string; type: 'personal' | 'team' } | null
}
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: mockFlags })
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
get activeWorkspace() {
return mockActiveWorkspace.value
}
})
}))
const personal = { id: 'w-personal', type: 'personal' as const }
const team = { id: 'w-team', type: 'team' as const }
describe('useBillingRouting', () => {
beforeEach(() => {
mockFlags.teamWorkspacesEnabled = false
mockFlags.consolidatedBillingEnabled = false
mockActiveWorkspace.value = personal
})
it('uses legacy billing when team workspaces are disabled', () => {
mockFlags.teamWorkspacesEnabled = false
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
expect(type.value).toBe('legacy')
expect(shouldUseWorkspaceBilling.value).toBe(false)
})
it('keeps personal on legacy when consolidated billing is disabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = false
mockActiveWorkspace.value = personal
const { type } = useBillingRouting()
expect(type.value).toBe('legacy')
})
it('moves personal to workspace billing when consolidated billing is enabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockActiveWorkspace.value = personal
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
expect(type.value).toBe('workspace')
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = false
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
expect(type.value).toBe('workspace')
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
expect(type.value).toBe('workspace')
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('defaults to legacy while the workspace has not loaded', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockActiveWorkspace.value = null
const { type } = useBillingRouting()
expect(type.value).toBe('legacy')
})
})

View File

@@ -0,0 +1,36 @@
import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type { BillingType } from './types'
/**
* Selects the billing backend for the active workspace: legacy user-scoped
* (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
*/
export function useBillingRouting() {
const { flags } = useFeatureFlags()
const workspaceStore = useTeamWorkspaceStore()
const type = computed<BillingType>(() => {
if (!flags.teamWorkspacesEnabled) return 'legacy'
// An unloaded workspace has no type yet; stay legacy so bootstrap never
// eagerly routes to workspace billing.
const workspaceType = workspaceStore.activeWorkspace?.type
if (!workspaceType) return 'legacy'
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
return 'legacy'
}
return 'workspace'
})
const shouldUseWorkspaceBilling = computed(() => type.value === 'workspace')
return { type, shouldUseWorkspaceBilling }
}

View File

@@ -1,256 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useLegacyBilling } from './useLegacyBilling'
const mocks = vi.hoisted(() => ({
isActiveSubscription: { value: false },
subscriptionTier: { value: null as string | null },
subscriptionDuration: { value: null as string | null },
subscriptionStatus: {
value: null as null | {
renewal_date?: string | null
end_date?: string | null
}
},
isCancelled: { value: false },
fetchStatus: vi.fn(),
manageSubscription: vi.fn(),
subscribe: vi.fn(),
showSubscriptionDialog: vi.fn(),
balance: {
value: null as null | {
amount_micros?: number
currency?: string
effective_balance_micros?: number
prepaid_balance_micros?: number
cloud_credit_balance_micros?: number
}
},
fetchBalance: vi.fn(),
purchaseCredits: vi.fn()
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isActiveSubscription: mocks.isActiveSubscription,
subscriptionTier: mocks.subscriptionTier,
subscriptionDuration: mocks.subscriptionDuration,
subscriptionStatus: mocks.subscriptionStatus,
isCancelled: mocks.isCancelled,
fetchStatus: mocks.fetchStatus,
manageSubscription: mocks.manageSubscription,
subscribe: mocks.subscribe,
showSubscriptionDialog: mocks.showSubscriptionDialog
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
get balance() {
return mocks.balance.value
},
fetchBalance: mocks.fetchBalance
})
}))
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
purchaseCredits: mocks.purchaseCredits
})
}))
describe('useLegacyBilling', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mocks.isActiveSubscription.value = false
mocks.subscriptionTier.value = null
mocks.subscriptionDuration.value = null
mocks.subscriptionStatus.value = null
mocks.isCancelled.value = false
mocks.balance.value = null
mocks.fetchStatus.mockResolvedValue(undefined)
mocks.manageSubscription.mockResolvedValue(undefined)
mocks.subscribe.mockResolvedValue(undefined)
mocks.fetchBalance.mockResolvedValue(undefined)
mocks.purchaseCredits.mockResolvedValue(undefined)
})
it('returns empty subscription and balance state without legacy data', () => {
const billing = useLegacyBilling()
expect(billing.subscription.value).toBeNull()
expect(billing.balance.value).toBeNull()
expect(billing.subscriptionStatus.value).toBeNull()
expect(billing.renewalDate.value).toBeNull()
expect(billing.isFreeTier.value).toBe(false)
})
it('maps active subscription and explicit balance fields', () => {
mocks.isActiveSubscription.value = true
mocks.subscriptionTier.value = 'PRO'
mocks.subscriptionDuration.value = 'MONTHLY'
mocks.subscriptionStatus.value = {
renewal_date: '2026-01-01T00:00:00Z',
end_date: '2026-02-01T00:00:00Z'
}
mocks.balance.value = {
amount_micros: 500,
currency: 'eur',
effective_balance_micros: 400,
prepaid_balance_micros: 300,
cloud_credit_balance_micros: 200
}
const billing = useLegacyBilling()
expect(billing.subscription.value).toEqual({
isActive: true,
tier: 'PRO',
duration: 'MONTHLY',
planSlug: null,
renewalDate: '2026-01-01T00:00:00Z',
endDate: '2026-02-01T00:00:00Z',
isCancelled: false,
hasFunds: true
})
expect(billing.balance.value).toEqual({
amountMicros: 500,
currency: 'eur',
effectiveBalanceMicros: 400,
prepaidBalanceMicros: 300,
cloudCreditBalanceMicros: 200
})
expect(billing.subscriptionStatus.value).toBe('active')
})
it('uses legacy balance defaults when optional fields are absent', () => {
mocks.subscriptionTier.value = 'FREE'
mocks.balance.value = {}
const billing = useLegacyBilling()
expect(billing.balance.value).toEqual({
amountMicros: 0,
currency: 'usd',
effectiveBalanceMicros: 0,
prepaidBalanceMicros: 0,
cloudCreditBalanceMicros: 0
})
expect(billing.subscription.value?.hasFunds).toBe(false)
})
it('uses amount as effective balance when only amount is present', () => {
mocks.balance.value = { amount_micros: 250 }
const billing = useLegacyBilling()
expect(billing.balance.value?.effectiveBalanceMicros).toBe(250)
})
it('reports canceled status before active status', () => {
mocks.isActiveSubscription.value = true
mocks.isCancelled.value = true
const billing = useLegacyBilling()
expect(billing.subscriptionStatus.value).toBe('canceled')
})
it('initializes once and re-fetches zero free-tier balance', async () => {
mocks.subscriptionTier.value = 'FREE'
mocks.balance.value = { amount_micros: 0 }
const billing = useLegacyBilling()
await billing.initialize()
await billing.initialize()
expect(billing.isInitialized.value).toBe(true)
expect(mocks.fetchStatus).toHaveBeenCalledTimes(1)
expect(mocks.fetchBalance).toHaveBeenCalledTimes(2)
})
it('stores initialization error messages from Error failures', async () => {
mocks.fetchStatus.mockRejectedValue(new Error('status failed'))
const billing = useLegacyBilling()
await expect(billing.initialize()).rejects.toThrow('status failed')
expect(billing.error.value).toBe('status failed')
expect(billing.isLoading.value).toBe(false)
})
it('stores fallback initialization error messages for non-Error failures', async () => {
mocks.fetchStatus.mockRejectedValue('status failed')
const billing = useLegacyBilling()
await expect(billing.initialize()).rejects.toBe('status failed')
expect(billing.error.value).toBe('Failed to initialize billing')
})
it('stores subscription fetch fallback errors', async () => {
mocks.fetchStatus.mockRejectedValue('status failed')
const billing = useLegacyBilling()
await expect(billing.fetchStatus()).rejects.toBe('status failed')
expect(billing.error.value).toBe('Failed to fetch subscription')
expect(billing.isLoading.value).toBe(false)
})
it('stores balance fetch errors', async () => {
mocks.fetchBalance.mockRejectedValue(new Error('balance failed'))
const billing = useLegacyBilling()
await expect(billing.fetchBalance()).rejects.toThrow('balance failed')
expect(billing.error.value).toBe('balance failed')
expect(billing.isLoading.value).toBe(false)
})
it('stores balance fetch fallback errors', async () => {
mocks.fetchBalance.mockRejectedValue('balance failed')
const billing = useLegacyBilling()
await expect(billing.fetchBalance()).rejects.toBe('balance failed')
expect(billing.error.value).toBe('Failed to fetch balance')
})
it('delegates legacy billing actions', async () => {
const billing = useLegacyBilling()
await expect(billing.subscribe('pro-monthly')).resolves.toBeUndefined()
await expect(billing.previewSubscribe('pro-monthly')).resolves.toBeNull()
await billing.manageSubscription()
await billing.cancelSubscription()
await billing.resubscribe()
await billing.topup(750)
await expect(billing.fetchPlans()).resolves.toBeUndefined()
billing.showSubscriptionDialog()
expect(mocks.subscribe).toHaveBeenCalledTimes(2)
expect(mocks.manageSubscription).toHaveBeenCalledTimes(2)
expect(mocks.purchaseCredits).toHaveBeenCalledWith(7.5)
expect(mocks.showSubscriptionDialog).toHaveBeenCalledTimes(1)
})
it('shows the subscription dialog when active subscription is required', async () => {
const billing = useLegacyBilling()
await billing.requireActiveSubscription()
expect(mocks.showSubscriptionDialog).toHaveBeenCalledTimes(1)
})
it('does not show the subscription dialog for active subscribers', async () => {
mocks.isActiveSubscription.value = true
const billing = useLegacyBilling()
await billing.requireActiveSubscription()
expect(mocks.showSubscriptionDialog).not.toHaveBeenCalled()
})
})

View File

@@ -1,217 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, ref } from 'vue'
interface MockTerminalInstance {
cols: number
rows: number
options: unknown
loadAddon: ReturnType<typeof vi.fn>
attachCustomKeyEventHandler: ReturnType<typeof vi.fn>
open: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
resize: ReturnType<typeof vi.fn>
hasSelection: ReturnType<typeof vi.fn>
}
interface MockFitAddonInstance {
proposeDimensions: ReturnType<typeof vi.fn>
}
const mockXterm = vi.hoisted(() => {
const terminalInstances: MockTerminalInstance[] = []
const fitAddonInstances: MockFitAddonInstance[] = []
class Terminal {
cols = 80
rows = 24
loadAddon = vi.fn()
attachCustomKeyEventHandler = vi.fn()
open = vi.fn()
dispose = vi.fn()
resize = vi.fn((cols: number, rows: number) => {
this.cols = cols
this.rows = rows
})
hasSelection = vi.fn(() => false)
constructor(readonly options: unknown) {
terminalInstances.push(this)
}
}
class FitAddon {
proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 }))
constructor() {
fitAddonInstances.push(this)
}
}
return {
Terminal,
FitAddon,
terminalInstances,
fitAddonInstances
}
})
const mockResizeObserverInstances = [] as MockResizeObserver[]
class MockResizeObserver {
observe = vi.fn()
disconnect = vi.fn()
constructor(readonly callback: ResizeObserverCallback) {
mockResizeObserverInstances.push(this)
}
}
vi.mock('@xterm/xterm', () => ({
Terminal: mockXterm.Terminal
}))
vi.mock('@xterm/addon-fit', () => ({
FitAddon: mockXterm.FitAddon
}))
vi.mock('es-toolkit/compat', () => ({
debounce: (fn: () => void) => fn
}))
vi.mock('@/platform/distribution/types', () => ({
isDesktop: true
}))
import { useTerminal } from './useTerminal'
function terminalElement() {
const element = document.createElement('div')
Object.defineProperty(element, 'clientWidth', { value: 160 })
Object.defineProperty(element, 'clientHeight', { value: 100 })
return element
}
function mountTerminal(
configure?: (
result: ReturnType<typeof useTerminal>,
root: ReturnType<typeof ref<HTMLElement | undefined>>
) => void
) {
let result: ReturnType<typeof useTerminal> | undefined
const root = ref<HTMLElement | undefined>(terminalElement())
const app = createApp(
defineComponent({
setup() {
result = useTerminal(root)
configure?.(result, root)
return () => null
}
})
)
app.mount(document.createElement('div'))
if (!result) throw new Error('Expected terminal composable to initialize')
return { app, result, root }
}
describe('useTerminal', () => {
beforeEach(() => {
mockXterm.terminalInstances.length = 0
mockXterm.fitAddonInstances.length = 0
mockResizeObserverInstances.length = 0
vi.stubGlobal('ResizeObserver', MockResizeObserver)
})
it('creates a desktop themed terminal and opens it on mount', () => {
const { app, root } = mountTerminal()
const terminal = mockXterm.terminalInstances[0]
const fitAddon = mockXterm.fitAddonInstances[0]
expect(terminal.options).toMatchObject({
convertEol: true,
theme: { background: '#171717' }
})
expect(terminal.loadAddon).toHaveBeenCalledWith(fitAddon)
expect(terminal.open).toHaveBeenCalledWith(root.value)
app.unmount()
expect(terminal.dispose).toHaveBeenCalledOnce()
})
it('lets browser copy and paste shortcuts pass through', () => {
mountTerminal()
const terminal = mockXterm.terminalInstances[0]
const handler = terminal.attachCustomKeyEventHandler.mock.calls[0][0] as (
event: KeyboardEvent
) => boolean
terminal.hasSelection.mockReturnValue(true)
expect(
handler(new KeyboardEvent('keydown', { key: 'c', ctrlKey: true }))
).toBe(false)
expect(
handler(new KeyboardEvent('keydown', { key: 'v', metaKey: true }))
).toBe(false)
terminal.hasSelection.mockReturnValue(false)
expect(
handler(new KeyboardEvent('keydown', { key: 'c', ctrlKey: true }))
).toBe(true)
expect(
handler(new KeyboardEvent('keyup', { key: 'v', ctrlKey: true }))
).toBe(true)
})
it('auto-sizes from fit dimensions and disconnects the observer on unmount', () => {
const onResize = vi.fn()
const { app, root } = mountTerminal((terminal, rootRef) => {
terminal.useAutoSize({
root: rootRef,
minCols: 100,
minRows: 20,
onResize
})
})
const terminal = mockXterm.terminalInstances[0]
const observer = mockResizeObserverInstances[0]
expect(observer.observe).toHaveBeenCalledWith(root.value)
expect(terminal.resize).toHaveBeenCalledWith(120, 40)
expect(onResize).toHaveBeenCalledOnce()
app.unmount()
expect(observer.disconnect).toHaveBeenCalledOnce()
})
it('estimates invalid fit dimensions from the root element', () => {
const { result, root } = mountTerminal()
const fitAddon = mockXterm.fitAddonInstances[0]
fitAddon.proposeDimensions.mockReturnValue({
cols: Number.NaN,
rows: undefined
})
const { resize } = result.useAutoSize({ root, minCols: 30, minRows: 10 })
const terminal = mockXterm.terminalInstances[0]
resize()
expect(terminal.resize).toHaveBeenLastCalledWith(30, 10)
})
it('keeps existing terminal dimensions when auto sizing is disabled', () => {
const { result, root } = mountTerminal()
const terminal = mockXterm.terminalInstances[0]
terminal.cols = 90
terminal.rows = 30
const { resize } = result.useAutoSize({
root,
autoCols: false,
autoRows: false,
minCols: 10,
minRows: 10
})
resize()
expect(terminal.resize).toHaveBeenLastCalledWith(90, 30)
})
})

View File

@@ -6,6 +6,12 @@ import {
useFeatureFlags
} from '@/composables/useFeatureFlags'
import * as distributionTypes from '@/platform/distribution/types'
import {
cachedConsolidatedBillingEnabled,
cachedTeamWorkspacesEnabled,
remoteConfig,
remoteConfigState
} from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
// Mock the API module
@@ -219,6 +225,86 @@ describe('useFeatureFlags', () => {
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
})
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
vi.mocked(distributionTypes).isCloud = false
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
const { flags } = useFeatureFlags()
expect(flags.consolidatedBillingEnabled).toBe(true)
})
it('consolidatedBillingEnabled is false off-cloud even without an override', () => {
vi.mocked(distributionTypes).isCloud = false
const { flags } = useFeatureFlags()
expect(flags.consolidatedBillingEnabled).toBe(false)
})
})
describe('auth-gated flags on cloud', () => {
beforeEach(() => {
vi.mocked(distributionTypes).isCloud = true
remoteConfigState.value = 'unloaded'
remoteConfig.value = {}
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
localStorage.clear()
})
afterEach(() => {
vi.mocked(distributionTypes).isCloud = false
remoteConfigState.value = 'unloaded'
remoteConfig.value = {}
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
localStorage.clear()
})
it('returns the cached session value during the auth window', () => {
cachedTeamWorkspacesEnabled.value = false
cachedConsolidatedBillingEnabled.value = true
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
expect(flags.consolidatedBillingEnabled).toBe(true)
})
it('defaults to false during the auth window when nothing is cached', () => {
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
expect(flags.consolidatedBillingEnabled).toBe(false)
})
it('prefers authenticated remoteConfig over the server feature fallback', () => {
remoteConfigState.value = 'authenticated'
remoteConfig.value = {
team_workspaces_enabled: true,
consolidated_billing_enabled: true
}
vi.mocked(api.getServerFeature).mockReturnValue(false)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(flags.consolidatedBillingEnabled).toBe(true)
})
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
remoteConfigState.value = 'authenticated'
remoteConfig.value = {}
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(flags.consolidatedBillingEnabled).toBe(true)
})
})
describe('signupTurnstileMode', () => {

View File

@@ -1,7 +1,9 @@
import { computed, reactive, readonly } from 'vue'
import type { Ref } from 'vue'
import { isCloud, isNightly } from '@/platform/distribution/types'
import {
cachedConsolidatedBillingEnabled,
cachedTeamWorkspacesEnabled,
isAuthenticatedConfigLoaded,
remoteConfig
@@ -30,6 +32,7 @@ export enum ServerFeatureFlag {
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
SIGNUP_TURNSTILE = 'signup_turnstile'
}
@@ -46,6 +49,26 @@ function resolveFlag<T>(
return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue)
}
/**
* Resolves a per-user, Cloud-only flag that selects backend behavior. Off the
* Cloud build it is always false; during the auth window it falls back to the
* cached session value so anonymous bootstrap config cannot route the user to
* the wrong backend before authenticated config confirms the flag.
*/
function resolveAuthGatedFlag(
flagKey: string,
remoteConfigValue: boolean | undefined,
cachedValue: Ref<boolean | undefined>
): boolean {
const override = getDevOverride<boolean>(flagKey)
if (override !== undefined) return override
if (!isCloud) return false
if (!isAuthenticatedConfigLoaded.value) return cachedValue.value ?? false
return remoteConfigValue ?? api.getServerFeature(flagKey, false)
}
/**
* Composable for reactive access to server-side feature flags
*/
@@ -104,18 +127,10 @@ export function useFeatureFlags() {
* and prevents race conditions during initialization.
*/
get teamWorkspacesEnabled() {
const override = getDevOverride<boolean>(
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED
)
if (override !== undefined) return override
if (!isCloud) return false
if (!isAuthenticatedConfigLoaded.value)
return cachedTeamWorkspacesEnabled.value ?? false
return (
remoteConfig.value.team_workspaces_enabled ??
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
return resolveAuthGatedFlag(
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED,
remoteConfig.value.team_workspaces_enabled,
cachedTeamWorkspacesEnabled
)
},
get userSecretsEnabled() {
@@ -175,6 +190,18 @@ export function useFeatureFlags() {
false
)
},
/**
* Whether personal workspaces use the consolidated (workspace-scoped)
* billing flow. While false (default), personal workspaces stay on the
* legacy per-user billing flow; team workspaces are unaffected.
*/
get consolidatedBillingEnabled() {
return resolveAuthGatedFlag(
ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED,
remoteConfig.value.consolidated_billing_enabled,
cachedConsolidatedBillingEnabled
)
},
get signupTurnstileMode() {
return resolveFlag(
ServerFeatureFlag.SIGNUP_TURNSTILE,

View File

@@ -2484,6 +2484,8 @@
"model": "Model",
"added": "Added",
"accountInitialized": "Account initialized",
"loadEventsError": "Failed to load activity. Please try again.",
"loadEventsUnknownError": "Something went wrong while loading activity. Please refresh and try again.",
"eventTypes": {
"creditAdded": "Credits Added",
"accountCreated": "Account Created",

View File

@@ -0,0 +1,117 @@
import { render, screen } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({ isAssetDeleting: () => false })
}))
vi.mock('../composables/useMediaAssetActions', () => ({
useMediaAssetActions: () => ({ downloadAssets: vi.fn() })
}))
vi.mock('@/platform/assets/schemas/assetMetadataSchema', () => ({
getOutputAssetMetadata: () => ({
allOutputs: [
{
filename: 'a.png',
subfolder: '',
type: 'output',
display_name: 'Display A'
}
]
})
}))
const asset: AssetItem = {
id: 'a',
name: 'a.png',
tags: [],
preview_url: '/preview.png'
}
function renderCard() {
setActivePinia(createPinia())
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
return render(MediaAssetCard, {
props: { asset, loading: true },
global: {
plugins: [i18n],
stubs: {
IconGroup: true,
LoadingOverlay: true,
Button: true,
MediaTitle: true
},
directives: { tooltip: {} }
}
})
}
function dispatchDragStart(
init: { ctrlKey?: boolean; metaKey?: boolean } = {}
) {
const dataTransfer = new DataTransfer()
const add = vi.spyOn(dataTransfer.items, 'add').mockImplementation(() => null)
const event = new DragEvent('dragstart', { bubbles: true, cancelable: true })
// happy-dom's DragEvent ignores dataTransfer/modifier init, so set them here.
Object.defineProperties(event, {
dataTransfer: { value: dataTransfer, configurable: true },
ctrlKey: { value: init.ctrlKey ?? false, configurable: true },
metaKey: { value: init.metaKey ?? false, configurable: true }
})
screen.getByRole('button').dispatchEvent(event)
return { event, add }
}
describe('MediaAssetCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('dragStart', () => {
it('cancels the native drag when Ctrl is held so a marquee can start over the card', () => {
renderCard()
const { event, add } = dispatchDragStart({ ctrlKey: true })
expect(event.defaultPrevented).toBe(true)
expect(add).not.toHaveBeenCalled()
})
it('cancels the native drag when Meta is held', () => {
renderCard()
const { event } = dispatchDragStart({ metaKey: true })
expect(event.defaultPrevented).toBe(true)
})
it('includes the asset metadata with display_name in the drag payload', () => {
renderCard()
const { event, add } = dispatchDragStart()
expect(event.defaultPrevented).toBe(false)
expect(add).toHaveBeenCalledWith(
JSON.stringify({
filename: 'a.png',
subfolder: '',
type: 'output',
display_name: 'Display A'
}),
expect.any(String)
)
})
})
})

View File

@@ -21,6 +21,7 @@
)
"
:data-selected="selected"
:data-asset-id="asset?.id"
:draggable="true"
@click.stop="$emit('click')"
@contextmenu.prevent.stop="
@@ -316,6 +317,11 @@ const handleOutputCountClick = () => {
emit('output-count-click')
}
function dragStart(e: DragEvent) {
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
return
}
if (!asset?.preview_url) return
const { dataTransfer } = e

View File

@@ -0,0 +1,741 @@
import { render, screen } from '@testing-library/vue'
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetGridSelection } from './useAssetGridSelection'
const assets: AssetItem[] = [
{ id: 'a', name: 'a.png', tags: [] },
{ id: 'b', name: 'b.png', tags: [] },
{ id: 'c', name: 'c.png', tags: [] }
]
const cardBoxes: Record<string, { left: number; right: number }> = {
a: { left: 0, right: 50 },
b: { left: 60, right: 110 },
c: { left: 120, right: 170 }
}
function pointer(type: string, init: PointerEventInit = {}) {
return new PointerEvent(type, {
bubbles: true,
cancelable: true,
button: 0,
pointerId: 1,
isPrimary: true,
...init
})
}
function createCallbacks(overrides: Record<string, unknown> = {}) {
return {
getAssets: () => assets,
getSelectedIds: vi.fn(() => [] as string[]),
setSelectedIds: vi.fn(),
selectAll: vi.fn(),
...overrides
}
}
async function renderHarness(callbacks: ReturnType<typeof createCallbacks>) {
const Harness = defineComponent({
setup() {
const gridContainerRef = ref<HTMLElement>()
const hoverTargetRef = ref<HTMLElement>()
const { marqueeStyle } = useAssetGridSelection({
marqueeContainerRef: gridContainerRef,
hoverTargetRef,
getAssets: callbacks.getAssets,
getSelectedIds: callbacks.getSelectedIds,
setSelectedIds: callbacks.setSelectedIds,
selectAll: callbacks.selectAll
})
return { gridContainerRef, hoverTargetRef, marqueeStyle }
},
template: `
<div ref="hoverTargetRef" data-testid="panel">
<input data-testid="search" />
<textarea data-testid="textarea"></textarea>
<div contenteditable="true" data-testid="editable"></div>
<div ref="gridContainerRef" data-testid="grid">
<button data-testid="grid-button">x</button>
<div data-asset-id="a" data-testid="card-a"></div>
<div data-asset-id="b" data-testid="card-b"></div>
<div data-asset-id="c" data-testid="card-c"></div>
</div>
<div
v-if="marqueeStyle"
data-testid="marquee"
:style="marqueeStyle"
></div>
</div>
`
})
render(Harness)
await nextTick()
vi.spyOn(screen.getByTestId('grid'), 'getBoundingClientRect').mockReturnValue(
fromPartial<DOMRect>({ left: 0, top: 0, right: 1000, bottom: 1000 })
)
for (const id of Object.keys(cardBoxes)) {
vi.spyOn(
screen.getByTestId(`card-${id}`),
'getBoundingClientRect'
).mockReturnValue(
fromPartial<DOMRect>({
left: cardBoxes[id].left,
right: cardBoxes[id].right,
top: 0,
bottom: 50
})
)
}
}
const grid = () => screen.getByTestId('grid')
const panel = () => screen.getByTestId('panel')
const card = (id: string) => screen.getByTestId(`card-${id}`)
describe('useAssetGridSelection', () => {
afterEach(() => {
vi.restoreAllMocks()
})
describe('marquee', () => {
it('selects intersecting cards when dragging from empty space', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(
pointer('pointermove', { clientX: 110, clientY: 50 })
)
expect(callbacks.setSelectedIds).toHaveBeenLastCalledWith(
['a', 'b'],
assets
)
})
it('unions with the current selection when a modifier is held', async () => {
const callbacks = createCallbacks({ getSelectedIds: vi.fn(() => ['c']) })
await renderHarness(callbacks)
grid().dispatchEvent(
pointer('pointerdown', { clientX: 0, clientY: 0, shiftKey: true })
)
window.dispatchEvent(pointer('pointermove', { clientX: 55, clientY: 50 }))
expect(
[...callbacks.setSelectedIds.mock.lastCall![0]].sort(
(a: string, b: string) => a.localeCompare(b)
)
).toEqual(['a', 'c'])
})
it('ignores movement below the drag threshold', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(pointer('pointermove', { clientX: 2, clientY: 2 }))
expect(callbacks.setSelectedIds).not.toHaveBeenCalled()
})
it('does not start a marquee on a plain pointer-down on a card', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
card('a').dispatchEvent(
pointer('pointerdown', { clientX: 5, clientY: 5 })
)
window.dispatchEvent(pointer('pointermove', { clientX: 80, clientY: 40 }))
expect(callbacks.setSelectedIds).not.toHaveBeenCalled()
})
it('starts a marquee on a card when Ctrl is held and blocks native drag', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
card('a').dispatchEvent(
pointer('pointerdown', { clientX: 5, clientY: 5, ctrlKey: true })
)
const dragEvent = new DragEvent('dragstart', {
bubbles: true,
cancelable: true
})
card('a').dispatchEvent(dragEvent)
window.dispatchEvent(pointer('pointermove', { clientX: 65, clientY: 40 }))
expect(dragEvent.defaultPrevented).toBe(true)
expect(callbacks.setSelectedIds).toHaveBeenCalled()
})
it('does not block native drag when no marquee is tracking', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
const dragEvent = new DragEvent('dragstart', {
bubbles: true,
cancelable: true
})
card('a').dispatchEvent(dragEvent)
expect(dragEvent.defaultPrevented).toBe(false)
})
it('shows a marquee overlay while dragging and removes it on release', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
await nextTick()
const overlay = screen.getByTestId('marquee')
expect(overlay.style.width).toBe('30px')
expect(overlay.style.height).toBe('40px')
window.dispatchEvent(pointer('pointerup', { clientX: 30, clientY: 40 }))
await nextTick()
expect(screen.queryByTestId('marquee')).toBeNull()
})
it('clips the overlay to the grid container when dragging past its edge', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 10, clientY: 10 }))
window.dispatchEvent(
pointer('pointermove', { clientX: 2000, clientY: 2000 })
)
await nextTick()
const overlay = screen.getByTestId('marquee')
expect(overlay.style.left).toBe('10px')
expect(overlay.style.top).toBe('10px')
expect(overlay.style.width).toBe('990px')
expect(overlay.style.height).toBe('990px')
})
it('clears the overlay on dragend when a native drag swallows pointerup', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
await nextTick()
expect(screen.getByTestId('marquee')).toBeTruthy()
window.dispatchEvent(new DragEvent('dragend', { bubbles: true }))
await nextTick()
expect(screen.queryByTestId('marquee')).toBeNull()
})
it('suppresses the click that trails a drag, but not a plain click', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(pointer('pointermove', { clientX: 80, clientY: 40 }))
window.dispatchEvent(pointer('pointerup', { clientX: 80, clientY: 40 }))
const trailing = new MouseEvent('click', {
bubbles: true,
cancelable: true
})
window.dispatchEvent(trailing)
expect(trailing.defaultPrevented).toBe(true)
const next = new MouseEvent('click', { bubbles: true, cancelable: true })
window.dispatchEvent(next)
expect(next.defaultPrevented).toBe(false)
})
it('does not start a marquee for a non-primary mouse button', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(
pointer('pointerdown', { clientX: 0, clientY: 0, button: 2 })
)
window.dispatchEvent(
pointer('pointermove', { clientX: 110, clientY: 50 })
)
expect(callbacks.setSelectedIds).not.toHaveBeenCalled()
})
it('does not start a marquee when the press lands on an interactive control', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
screen
.getByTestId('grid-button')
.dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(
pointer('pointermove', { clientX: 110, clientY: 50 })
)
expect(callbacks.setSelectedIds).not.toHaveBeenCalled()
})
it('starts a marquee on a card when Cmd/Meta is held', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
card('a').dispatchEvent(
pointer('pointerdown', { clientX: 5, clientY: 5, metaKey: true })
)
window.dispatchEvent(pointer('pointermove', { clientX: 65, clientY: 40 }))
expect(callbacks.setSelectedIds).toHaveBeenCalled()
})
it('clears the selection when a no-modifier marquee covers no card', async () => {
const callbacks = createCallbacks({ getSelectedIds: vi.fn(() => ['a']) })
await renderHarness(callbacks)
grid().dispatchEvent(
pointer('pointerdown', { clientX: 300, clientY: 300 })
)
window.dispatchEvent(
pointer('pointermove', { clientX: 400, clientY: 400 })
)
expect(callbacks.setSelectedIds).toHaveBeenLastCalledWith([], assets)
})
it('prevents text selection during a marquee and releases it on pointercancel', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
await nextTick()
expect(screen.getByTestId('marquee')).toBeTruthy()
const duringDrag = new Event('selectstart', {
bubbles: true,
cancelable: true
})
grid().dispatchEvent(duringDrag)
expect(duringDrag.defaultPrevented).toBe(true)
window.dispatchEvent(
pointer('pointercancel', { clientX: 30, clientY: 40 })
)
await nextTick()
expect(screen.queryByTestId('marquee')).toBeNull()
const afterEnd = new Event('selectstart', {
bubbles: true,
cancelable: true
})
grid().dispatchEvent(afterEnd)
expect(afterEnd.defaultPrevented).toBe(false)
})
it('auto-resets click suppression when a drag ends without a trailing click', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(pointer('pointermove', { clientX: 80, clientY: 40 }))
window.dispatchEvent(
pointer('pointercancel', { clientX: 80, clientY: 40 })
)
await new Promise((resolve) => setTimeout(resolve))
const laterClick = new MouseEvent('click', {
bubbles: true,
cancelable: true
})
window.dispatchEvent(laterClick)
expect(laterClick.defaultPrevented).toBe(false)
})
it('only blocks text selection inside the grid container during a marquee', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
await nextTick()
const insideGrid = new Event('selectstart', {
bubbles: true,
cancelable: true
})
card('a').dispatchEvent(insideGrid)
expect(insideGrid.defaultPrevented).toBe(true)
const outsideGrid = new Event('selectstart', {
bubbles: true,
cancelable: true
})
screen.getByTestId('search').dispatchEvent(outsideGrid)
expect(outsideGrid.defaultPrevented).toBe(false)
})
it('stops blocking text selection after a normal marquee release', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
const duringDrag = new Event('selectstart', {
bubbles: true,
cancelable: true
})
card('a').dispatchEvent(duringDrag)
expect(duringDrag.defaultPrevented).toBe(true)
window.dispatchEvent(pointer('pointerup', { clientX: 30, clientY: 40 }))
const afterRelease = new Event('selectstart', {
bubbles: true,
cancelable: true
})
card('a').dispatchEvent(afterRelease)
expect(afterRelease.defaultPrevented).toBe(false)
})
it('does not suppress the click after a sub-threshold press', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(pointer('pointermove', { clientX: 2, clientY: 2 }))
window.dispatchEvent(pointer('pointerup', { clientX: 2, clientY: 2 }))
const click = new MouseEvent('click', { bubbles: true, cancelable: true })
window.dispatchEvent(click)
expect(click.defaultPrevented).toBe(false)
})
it('does not marquee or clear selection when the container has no cards', async () => {
const setSelectedIds = vi.fn()
const Harness = defineComponent({
setup() {
const containerRef = ref<HTMLElement>()
useAssetGridSelection({
marqueeContainerRef: containerRef,
hoverTargetRef: containerRef,
getAssets: () => assets,
getSelectedIds: () => ['a'],
setSelectedIds,
selectAll: vi.fn()
})
return { containerRef }
},
template: `
<div ref="containerRef" data-testid="list">
<div data-testid="row">a.png</div>
</div>
`
})
render(Harness)
await nextTick()
screen
.getByTestId('list')
.dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(
pointer('pointermove', { clientX: 110, clientY: 50 })
)
expect(setSelectedIds).not.toHaveBeenCalled()
})
it('does not start a marquee when disabled (e.g. list view)', async () => {
const setSelectedIds = vi.fn()
const Harness = defineComponent({
setup() {
const containerRef = ref<HTMLElement>()
useAssetGridSelection({
marqueeContainerRef: containerRef,
hoverTargetRef: containerRef,
getAssets: () => assets,
getSelectedIds: () => [],
setSelectedIds,
selectAll: vi.fn(),
isEnabled: () => false
})
return { containerRef }
},
template: `
<div ref="containerRef" data-testid="disabled-grid">
<div data-asset-id="a"></div>
<div data-asset-id="b"></div>
</div>
`
})
render(Harness)
await nextTick()
screen
.getByTestId('disabled-grid')
.dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(
pointer('pointermove', { clientX: 110, clientY: 50 })
)
expect(setSelectedIds).not.toHaveBeenCalled()
})
it('ignores a reentrant pointer-down and does not leave text selection blocked', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
grid().dispatchEvent(pointer('pointerdown', { clientX: 5, clientY: 5 }))
window.dispatchEvent(pointer('pointerup', { clientX: 5, clientY: 5 }))
const selection = new Event('selectstart', {
bubbles: true,
cancelable: true
})
grid().dispatchEvent(selection)
expect(selection.defaultPrevented).toBe(false)
})
it('captures the pointer once a marquee drag starts, not on press', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
const capture = vi.spyOn(grid(), 'setPointerCapture')
card('a').dispatchEvent(
pointer('pointerdown', { clientX: 5, clientY: 5 })
)
expect(capture).not.toHaveBeenCalled()
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
expect(capture).not.toHaveBeenCalled()
window.dispatchEvent(
pointer('pointermove', { clientX: 110, clientY: 50 })
)
expect(capture).toHaveBeenCalledWith(1)
})
it('does not capture the pointer on a Ctrl/Cmd-click of a card', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
const capture = vi.spyOn(grid(), 'setPointerCapture')
card('a').dispatchEvent(
pointer('pointerdown', { clientX: 5, clientY: 5, ctrlKey: true })
)
window.dispatchEvent(pointer('pointerup', { clientX: 5, clientY: 5 }))
expect(capture).not.toHaveBeenCalled()
})
it('still tracks a marquee when setPointerCapture throws', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
vi.spyOn(grid(), 'setPointerCapture').mockImplementation(() => {
throw new Error('stale pointer id')
})
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(
pointer('pointermove', { clientX: 110, clientY: 50 })
)
expect(callbacks.setSelectedIds).toHaveBeenLastCalledWith(
['a', 'b'],
assets
)
window.dispatchEvent(pointer('pointerup', { clientX: 110, clientY: 50 }))
await nextTick()
expect(screen.queryByTestId('marquee')).toBeNull()
})
})
describe('ctrl/cmd + A', () => {
function pressSelectAll(init: KeyboardEventInit = {}) {
const event = new KeyboardEvent('keydown', {
key: 'a',
ctrlKey: true,
bubbles: true,
cancelable: true,
...init
})
window.dispatchEvent(event)
return event
}
it('selects all visible assets and blocks the event when hovered', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
panel().dispatchEvent(new MouseEvent('mouseenter'))
const event = pressSelectAll()
expect(callbacks.selectAll).toHaveBeenCalledWith(assets)
expect(event.defaultPrevented).toBe(true)
})
it('selects all with the Cmd/Meta key while hovered', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
panel().dispatchEvent(new MouseEvent('mouseenter'))
pressSelectAll({ ctrlKey: false, metaKey: true })
expect(callbacks.selectAll).toHaveBeenCalledWith(assets)
})
it('does nothing when the panel is not hovered', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
pressSelectAll()
expect(callbacks.selectAll).not.toHaveBeenCalled()
})
it('still selects all when hover desyncs but the pointer stays inside the panel', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
vi.spyOn(panel(), 'getBoundingClientRect').mockReturnValue(
fromPartial<DOMRect>({
left: 0,
top: 0,
right: 500,
bottom: 500,
width: 500,
height: 500
})
)
panel().dispatchEvent(new MouseEvent('mouseenter'))
window.dispatchEvent(
pointer('pointermove', { clientX: 100, clientY: 100 })
)
// The selection bar under the cursor unmounts on "deselect all", which
// latches useElementHover false while the pointer is still inside.
panel().dispatchEvent(new MouseEvent('mouseleave'))
const event = pressSelectAll()
expect(callbacks.selectAll).toHaveBeenCalledWith(assets)
expect(event.defaultPrevented).toBe(true)
})
it('does not select all when the live pointer is outside the panel rect', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
vi.spyOn(panel(), 'getBoundingClientRect').mockReturnValue(
fromPartial<DOMRect>({
left: 0,
top: 0,
right: 500,
bottom: 500,
width: 500,
height: 500
})
)
window.dispatchEvent(
pointer('pointermove', { clientX: 900, clientY: 900 })
)
panel().dispatchEvent(new MouseEvent('mouseleave'))
const event = pressSelectAll()
expect(callbacks.selectAll).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(false)
})
it('ignores other keys and the unmodified A while hovered', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
panel().dispatchEvent(new MouseEvent('mouseenter'))
pressSelectAll({ ctrlKey: false })
pressSelectAll({ key: 'b' })
expect(callbacks.selectAll).not.toHaveBeenCalled()
})
it('does not hijack select-all while typing in a field', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
panel().dispatchEvent(new MouseEvent('mouseenter'))
screen.getByTestId<HTMLInputElement>('search').focus()
pressSelectAll()
expect(callbacks.selectAll).not.toHaveBeenCalled()
})
it('does not hijack select-all while focused in a textarea', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
panel().dispatchEvent(new MouseEvent('mouseenter'))
screen.getByTestId<HTMLTextAreaElement>('textarea').focus()
pressSelectAll()
expect(callbacks.selectAll).not.toHaveBeenCalled()
})
it('does not hijack select-all while focused in a contenteditable element', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
panel().dispatchEvent(new MouseEvent('mouseenter'))
screen.getByTestId('editable').focus()
pressSelectAll()
expect(callbacks.selectAll).not.toHaveBeenCalled()
})
it('does not hijack select-all while an aria-modal dialog is open', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
panel().dispatchEvent(new MouseEvent('mouseenter'))
const dialog = document.createElement('div')
dialog.setAttribute('role', 'dialog')
dialog.setAttribute('aria-modal', 'true')
document.body.appendChild(dialog)
const event = pressSelectAll()
expect(callbacks.selectAll).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(false)
dialog.remove()
})
it('stops the select-all keystroke from reaching other handlers when hovered', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
const downstream = vi.fn()
window.addEventListener('keydown', downstream)
panel().dispatchEvent(new MouseEvent('mouseenter'))
pressSelectAll()
expect(callbacks.selectAll).toHaveBeenCalledTimes(1)
expect(downstream).not.toHaveBeenCalled()
panel().dispatchEvent(new MouseEvent('mouseleave'))
pressSelectAll()
expect(callbacks.selectAll).toHaveBeenCalledTimes(1)
expect(downstream).toHaveBeenCalledTimes(1)
window.removeEventListener('keydown', downstream)
})
})
})

View File

@@ -0,0 +1,245 @@
import { useElementHover, useEventListener } from '@vueuse/core'
import type { Ref } from 'vue'
import { computed, onScopeDispose, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
normalizeMarqueeRect,
selectMarqueeIds
} from '@/platform/assets/utils/marqueeSelectionUtil'
import { clampRectToBounds } from '@/utils/mathUtil'
import type { RectEdges } from '@/utils/mathUtil'
const DRAG_THRESHOLD_PX = 4
const CARD_SELECTOR = '[data-asset-id]'
const INTERACTIVE_SELECTOR =
'button, input, textarea, select, a[href], [role="slider"], [role="tab"], [contenteditable]'
interface AssetGridSelectionOptions {
marqueeContainerRef: Ref<HTMLElement | undefined>
hoverTargetRef: Ref<HTMLElement | undefined>
getAssets: () => AssetItem[]
getSelectedIds: () => string[]
setSelectedIds: (ids: string[], allAssets: AssetItem[]) => void
selectAll: (assets: AssetItem[]) => void
isEnabled?: () => boolean
}
function isTextEntryTarget(element: Element | null): boolean {
return (
element instanceof HTMLInputElement ||
element instanceof HTMLTextAreaElement ||
(element instanceof HTMLElement && element.isContentEditable)
)
}
export function useAssetGridSelection(options: AssetGridSelectionOptions) {
const {
marqueeContainerRef,
hoverTargetRef,
getAssets,
getSelectedIds,
setSelectedIds,
selectAll,
isEnabled = () => true
} = options
const marqueeRect = ref<RectEdges | null>(null)
const isHoveringPanel = useElementHover(hoverTargetRef)
let startX = 0
let startY = 0
let pointerId = 0
let baseIds: string[] = []
let isTracking = false
let isDragging = false
let suppressNextClick = false
let suppressClickResetTimeout: ReturnType<typeof setTimeout> | null = null
let dragCards: { id: string; rect: DOMRect }[] = []
let dragBounds: DOMRect | null = null
let pointerClientX = 0
let pointerClientY = 0
let hasPointerSample = false
function collectCards(container: HTMLElement) {
return [...container.querySelectorAll<HTMLElement>(CARD_SELECTOR)].flatMap(
(el) => {
const id = el.dataset.assetId
return id ? [{ id, rect: el.getBoundingClientRect() }] : []
}
)
}
function snapshotDrag() {
const container = marqueeContainerRef.value
if (!container) return
dragCards = collectCards(container)
dragBounds = container.getBoundingClientRect()
}
function applyMarquee(clientX: number, clientY: number) {
if (!dragBounds) return
const rect = clampRectToBounds(
normalizeMarqueeRect(
{ x: startX, y: startY },
{ x: clientX, y: clientY }
),
dragBounds
)
marqueeRect.value = rect
setSelectedIds([...selectMarqueeIds(dragCards, rect, baseIds)], getAssets())
}
function onPointerMove(e: PointerEvent) {
pointerClientX = e.clientX
pointerClientY = e.clientY
hasPointerSample = true
if (!isTracking) return
if (
!isDragging &&
Math.hypot(e.clientX - startX, e.clientY - startY) < DRAG_THRESHOLD_PX
) {
return
}
if (!isDragging) {
isDragging = true
snapshotDrag()
capturePointer()
}
applyMarquee(e.clientX, e.clientY)
}
function capturePointer() {
try {
marqueeContainerRef.value?.setPointerCapture(pointerId)
} catch {
// Stale/invalid pointerId: window listeners still end the drag.
}
}
function endDrag() {
if (!isTracking) return
isTracking = false
marqueeRect.value = null
dragCards = []
dragBounds = null
if (isDragging) scheduleSuppressNextClick()
isDragging = false
}
function scheduleSuppressNextClick() {
suppressNextClick = true
clearSuppressTimer()
suppressClickResetTimeout = setTimeout(() => {
suppressNextClick = false
suppressClickResetTimeout = null
}, 0)
}
function clearSuppressTimer() {
if (suppressClickResetTimeout !== null) {
clearTimeout(suppressClickResetTimeout)
suppressClickResetTimeout = null
}
}
function preventDragStart(e: Event) {
if (isTracking) e.preventDefault()
}
function preventTextSelection(e: Event) {
if (!isTracking) return
const container = marqueeContainerRef.value
if (container && e.target instanceof Node && container.contains(e.target)) {
e.preventDefault()
}
}
function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return
if (isTracking) return
if (!isEnabled()) return
suppressNextClick = false
clearSuppressTimer()
const container = marqueeContainerRef.value
if (!container) return
if (!container.querySelector(CARD_SELECTOR)) return
const target = e.target
if (!(target instanceof HTMLElement)) return
if (target.closest(INTERACTIVE_SELECTOR)) return
const onCard = target.closest(CARD_SELECTOR)
if (onCard && !e.ctrlKey && !e.metaKey) return
startX = e.clientX
startY = e.clientY
pointerId = e.pointerId
baseIds = e.shiftKey || e.ctrlKey || e.metaKey ? getSelectedIds() : []
isDragging = false
isTracking = true
}
function onClickCapture(e: MouseEvent) {
if (!suppressNextClick) return
suppressNextClick = false
clearSuppressTimer()
e.stopImmediatePropagation()
e.preventDefault()
}
// useElementHover latches stale-false when an overlay under the cursor (the
// selection bar) unmounts on "deselect all"; recheck the live pointer against
// the panel rect so Ctrl/Cmd+A still resolves against the panel it is over.
function isPointerInsidePanel(): boolean {
const el = hoverTargetRef.value
if (!el || !hasPointerSample) return false
const { left, top, right, bottom } = el.getBoundingClientRect()
return (
pointerClientX >= left &&
pointerClientX <= right &&
pointerClientY >= top &&
pointerClientY <= bottom
)
}
function onKeydown(e: KeyboardEvent) {
if (!(e.ctrlKey || e.metaKey) || (e.key !== 'a' && e.key !== 'A')) return
if (
!(isHoveringPanel.value || isPointerInsidePanel()) ||
isTextEntryTarget(document.activeElement) ||
document.querySelector('[role="dialog"][aria-modal="true"]')
) {
return
}
e.preventDefault()
e.stopImmediatePropagation()
selectAll(getAssets())
}
useEventListener(marqueeContainerRef, 'pointerdown', onPointerDown)
useEventListener(marqueeContainerRef, 'dragstart', preventDragStart, {
capture: true
})
useEventListener(window, 'pointermove', onPointerMove)
useEventListener(window, ['pointerup', 'pointercancel', 'dragend'], endDrag)
useEventListener(window, 'click', onClickCapture, { capture: true })
useEventListener(window, 'keydown', onKeydown, { capture: true })
useEventListener(window, 'selectstart', preventTextSelection, {
capture: true
})
onScopeDispose(clearSuppressTimer)
const marqueeStyle = computed(() => {
const rect = marqueeRect.value
if (!rect) return null
return {
left: `${rect.left}px`,
top: `${rect.top}px`,
width: `${rect.right - rect.left}px`,
height: `${rect.bottom - rect.top}px`
}
})
return { marqueeStyle }
}

View File

@@ -248,6 +248,36 @@ describe('useAssetSelection', () => {
})
})
describe('setSelectedIds', () => {
it('replaces selection and anchors on the last selected asset', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
const assets = createMockAssets(5)
selection.setSelectedIds(['asset-1', 'asset-3'], assets)
expect(Array.from(store.selectedAssetIds).sort()).toEqual([
'asset-1',
'asset-3'
])
expect(store.lastSelectedIndex).toBe(3)
expect(store.lastSelectedAssetId).toBe('asset-3')
})
it('clears the anchor when the selection is empty', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
const assets = createMockAssets(3)
store.setLastSelectedIndex(2)
store.setLastSelectedAssetId('asset-2')
selection.setSelectedIds([], assets)
expect(store.lastSelectedIndex).toBe(-1)
expect(store.lastSelectedAssetId).toBeNull()
})
})
describe('clearSelection', () => {
it('clears all selections', () => {
const { handleAssetClick, clearSelection, selectedCount } =

View File

@@ -101,6 +101,19 @@ export function useAssetSelection() {
}
}
/**
* Replace the selection (e.g. from a marquee) and keep the shift-range anchor
* on the last selected asset, the same way selectAll maintains it.
*/
function setSelectedIds(ids: string[], allAssets: AssetItem[]) {
selectionStore.setSelection(ids)
const selected = new Set(ids)
const anchorIndex = allAssets.findLastIndex((asset) =>
selected.has(asset.id)
)
setAnchor(anchorIndex, anchorIndex >= 0 ? allAssets[anchorIndex].id : null)
}
/**
* Get the actual asset objects for selected IDs
*/
@@ -182,6 +195,7 @@ export function useAssetSelection() {
// Selection actions
handleAssetClick,
selectAll,
setSelectedIds,
clearSelection: () => selectionStore.clearSelection(),
getSelectedAssets,
reconcileSelection,

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import type { RectEdges } from '@/utils/mathUtil'
import type { MarqueeCard } from './marqueeSelectionUtil'
import { normalizeMarqueeRect, selectMarqueeIds } from './marqueeSelectionUtil'
const box = (
left: number,
top: number,
right: number,
bottom: number
): RectEdges => ({
left,
top,
right,
bottom
})
describe('normalizeMarqueeRect', () => {
it('orders corners when dragging down-right', () => {
expect(normalizeMarqueeRect({ x: 10, y: 20 }, { x: 50, y: 80 })).toEqual(
box(10, 20, 50, 80)
)
})
it('orders corners when dragging up-left', () => {
expect(normalizeMarqueeRect({ x: 50, y: 80 }, { x: 10, y: 20 })).toEqual(
box(10, 20, 50, 80)
)
})
it('orders corners when dragging across axes', () => {
expect(normalizeMarqueeRect({ x: 50, y: 20 }, { x: 10, y: 80 })).toEqual(
box(10, 20, 50, 80)
)
})
})
describe('selectMarqueeIds', () => {
const cards: MarqueeCard[] = [
{ id: 'a', rect: box(0, 0, 10, 10) },
{ id: 'b', rect: box(20, 0, 30, 10) },
{ id: 'c', rect: box(40, 0, 50, 10) }
]
it('selects only intersecting cards when base is empty (replace)', () => {
const result = selectMarqueeIds(cards, box(15, 0, 35, 10))
expect([...result]).toEqual(['b'])
})
it('unions intersecting cards with the base selection (additive)', () => {
const result = selectMarqueeIds(cards, box(35, 0, 55, 10), ['a'])
expect([...result].sort()).toEqual(['a', 'c'])
})
it('returns a copy of the base when nothing intersects', () => {
const base = new Set(['a'])
const result = selectMarqueeIds(cards, box(100, 100, 110, 110), base)
expect([...result]).toEqual(['a'])
expect(result).not.toBe(base)
})
it('does not mutate the provided base set', () => {
const base = new Set(['a'])
selectMarqueeIds(cards, box(15, 0, 35, 10), base)
expect([...base]).toEqual(['a'])
})
it('includes a card whose edge merely touches the marquee', () => {
const touching = [{ id: 'edge', rect: box(0, 0, 10, 10) }]
expect([...selectMarqueeIds(touching, box(10, 0, 20, 10))]).toEqual([
'edge'
])
})
it('includes a card that contains the marquee and vice versa', () => {
const around = [{ id: 'around', rect: box(40, 40, 60, 60) }]
expect([...selectMarqueeIds(around, box(0, 0, 100, 100))]).toEqual([
'around'
])
})
it('excludes a card separated on a single axis', () => {
const below = [{ id: 'below', rect: box(0, 20, 10, 30) }]
expect([...selectMarqueeIds(below, box(0, 0, 10, 10))]).toEqual([])
})
})

View File

@@ -0,0 +1,46 @@
import type { RectEdges } from '@/utils/mathUtil'
export interface MarqueeCard {
id: string
rect: RectEdges
}
export function normalizeMarqueeRect(
start: { x: number; y: number },
end: { x: number; y: number }
): RectEdges {
return {
left: Math.min(start.x, end.x),
top: Math.min(start.y, end.y),
right: Math.max(start.x, end.x),
bottom: Math.max(start.y, end.y)
}
}
function rectsIntersect(a: RectEdges, b: RectEdges): boolean {
return !(
a.right < b.left ||
a.left > b.right ||
a.bottom < b.top ||
a.top > b.bottom
)
}
/**
* Resolve the asset ids a marquee covers, starting from `baseIds` (the selection
* to preserve when a modifier makes the drag additive). A fresh Set is returned;
* `baseIds` is never mutated.
*/
export function selectMarqueeIds(
cards: readonly MarqueeCard[],
marquee: RectEdges,
baseIds: Iterable<string> = []
): Set<string> {
const result = new Set(baseIds)
for (const { id, rect } of cards) {
if (rectsIntersect(rect, marquee)) {
result.add(id)
}
}
return result
}

View File

@@ -18,7 +18,7 @@
</div>
<!-- Workspace mode: workspace-aware subscription content (renders its own footer) -->
<SubscriptionPanelContentWorkspace v-if="teamWorkspacesEnabled" />
<SubscriptionPanelContentWorkspace v-if="shouldUseWorkspaceBilling" />
<!-- Legacy mode: user-level subscription content -->
<template v-else>
<SubscriptionPanelContentLegacy />
@@ -29,24 +29,20 @@
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent } from 'vue'
import { defineAsyncComponent } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import SubscriptionFooterLinks from '@/platform/cloud/subscription/components/SubscriptionFooterLinks.vue'
import SubscriptionPanelContentLegacy from '@/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue'
import { isCloud } from '@/platform/distribution/types'
const SubscriptionPanelContentWorkspace = defineAsyncComponent(
() =>
import('@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue')
)
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const { shouldUseWorkspaceBilling } = useBillingRouting()
const { isActiveSubscription } = useBillingContext()
</script>

View File

@@ -0,0 +1,136 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import SubscriptionPanelContentLegacy from './SubscriptionPanelContentLegacy.vue'
const mockAccessBillingPortal = vi.fn()
const mockTrackSubscriptionCancellation = vi.fn()
const mockShowSubscriptionDialog = vi.fn()
const mockHandleRefresh = vi.fn()
const mockIsActiveSubscription = ref(true)
const mockIsCancelled = ref(false)
const mockIsFreeTier = ref(false)
const mockSubscriptionTier = ref<'STANDARD' | 'CREATOR' | 'PRO' | null>(
'STANDARD'
)
const mockIsYearlySubscription = ref(true)
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
accessBillingPortal: mockAccessBillingPortal
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackSubscriptionCancellation: mockTrackSubscriptionCancellation
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
isCancelled: computed(() => mockIsCancelled.value),
isFreeTier: computed(() => mockIsFreeTier.value),
formattedRenewalDate: computed(() => '2026-08-01'),
formattedEndDate: computed(() => '2026-08-01'),
subscriptionTier: computed(() => mockSubscriptionTier.value),
subscriptionTierName: computed(() => 'Standard'),
isYearlySubscription: computed(() => mockIsYearlySubscription.value)
})
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionActions',
() => ({
useSubscriptionActions: () => ({
handleRefresh: mockHandleRefresh
})
})
)
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({
show: mockShowSubscriptionDialog
})
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subscription: {
perMonth: '/ month',
manageSubscription: 'Manage subscription',
upgradePlan: 'Upgrade plan',
subscribeNow: 'Subscribe now',
yourPlanIncludes: 'Your plan includes',
viewMoreDetailsPlans: 'View more details',
renewsDate: 'Renews {date}',
expiresDate: 'Expires {date}',
monthlyCreditsLabel: 'monthly credits',
maxDurationLabel: 'max duration',
gpuLabel: 'GPU access',
addCreditsLabel: 'Add credits',
customLoRAsLabel: 'Custom LoRAs',
maxDuration: {
standard: '30 min'
}
}
}
}
})
function renderComponent() {
return render(SubscriptionPanelContentLegacy, {
global: {
plugins: [i18n],
stubs: {
CreditsTile: true,
SubscribeButton: true,
Button: {
template: '<button @click="$emit(\'click\')"><slot /></button>',
emits: ['click']
}
}
}
})
}
describe('SubscriptionPanelContentLegacy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAccessBillingPortal.mockResolvedValue(undefined)
mockIsActiveSubscription.value = true
mockIsCancelled.value = false
mockIsFreeTier.value = false
mockSubscriptionTier.value = 'STANDARD'
mockIsYearlySubscription.value = true
})
it('tracks cancel intent before opening the billing portal', async () => {
renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /manage subscription/i })
)
expect(mockTrackSubscriptionCancellation).toHaveBeenCalledExactlyOnceWith(
'flow_opened',
{
source: 'manage_subscription_button',
current_tier: 'standard',
cycle: 'yearly'
}
)
expect(mockAccessBillingPortal).toHaveBeenCalledOnce()
})
})

View File

@@ -36,11 +36,7 @@
v-if="isActiveSubscription && !isFreeTier"
variant="secondary"
class="ml-auto rounded-lg bg-interface-menu-component-surface-selected px-4 py-2 text-sm font-normal text-text-primary"
@click="
async () => {
await authActions.accessBillingPortal()
}
"
@click="handleManageSubscription"
>
{{ $t('subscription.manageSubscription') }}
</Button>
@@ -125,6 +121,7 @@ import { useAuthActions } from '@/composables/auth/useAuthActions'
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
@@ -160,6 +157,18 @@ const tierPrice = computed(() =>
getTierPrice(tierKey.value, isYearlySubscription.value)
)
// The portal is the only place a legacy user can cancel (in-app UI already
// covers plan changes), so this click is the closest observable cancel-intent
// signal on the mainline path.
async function handleManageSubscription() {
useTelemetry()?.trackSubscriptionCancellation('flow_opened', {
source: 'manage_subscription_button',
current_tier: subscriptionTier.value?.toLowerCase(),
cycle: isYearlySubscription.value ? 'yearly' : 'monthly'
})
await authActions.accessBillingPortal()
}
const tierBenefits = computed((): TierBenefit[] =>
getCommonTierBenefits(tierKey.value, t, n)
)

View File

@@ -9,7 +9,7 @@ const mockTrackSubscription = vi.hoisted(() => vi.fn())
const mockIsInPersonalWorkspace = vi.hoisted(() => ({ value: true }))
const mockIsFreeTier = vi.hoisted(() => ({ value: false }))
const mockTier = vi.hoisted(() => ({ value: 'FREE' as string | null }))
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
const mockShouldUseWorkspaceBilling = vi.hoisted(() => ({ value: false }))
const mockIsCloud = vi.hoisted(() => ({ value: true }))
const mockIsLegacyTeamPlan = vi.hoisted(() => ({ value: false }))
const mockCanManageSubscription = vi.hoisted(() => ({ value: true }))
@@ -35,12 +35,10 @@ vi.mock('@/services/dialogService', () => ({
})
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
vi.mock('@/composables/billing/useBillingRouting', () => ({
useBillingRouting: () => ({
get shouldUseWorkspaceBilling() {
return mockShouldUseWorkspaceBilling
}
})
}))
@@ -88,7 +86,7 @@ describe('useSubscriptionDialog', () => {
mockIsInPersonalWorkspace.value = true
mockIsFreeTier.value = false
mockTier.value = 'FREE'
mockTeamWorkspacesEnabled.value = false
mockShouldUseWorkspaceBilling.value = false
mockIsLegacyTeamPlan.value = false
mockCanManageSubscription.value = true
@@ -119,7 +117,7 @@ describe('useSubscriptionDialog', () => {
})
it('does not wire onChooseTeam on the unified table (personal subscribes directly)', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -131,7 +129,7 @@ describe('useSubscriptionDialog', () => {
})
it('sizes the unified pricing dialog via the Reka contentClass, not the ignored PrimeVue style', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -146,7 +144,7 @@ describe('useSubscriptionDialog', () => {
})
it('defaults to the personal tab in a personal workspace', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -157,7 +155,7 @@ describe('useSubscriptionDialog', () => {
})
it('opens the team tab when planMode is forced from a personal workspace', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -167,8 +165,9 @@ describe('useSubscriptionDialog', () => {
expect(props.initialPlanMode).toBe('team')
})
it('uses the legacy table (with onChooseTeam) when team workspaces are disabled', () => {
mockTeamWorkspacesEnabled.value = false
it('uses the legacy table (with onChooseTeam) on the legacy billing flow', () => {
mockShouldUseWorkspaceBilling.value = false
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable()
@@ -178,7 +177,7 @@ describe('useSubscriptionDialog', () => {
})
it('routes an existing per-member (legacy) team subscriber to the old team table', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = false
mockIsLegacyTeamPlan.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -196,7 +195,7 @@ describe('useSubscriptionDialog', () => {
})
it('keeps a non-legacy (credit-slider) team subscriber on the unified table', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = false
mockIsLegacyTeamPlan.value = false
const { showPricingTable } = useSubscriptionDialog()
@@ -220,7 +219,7 @@ describe('useSubscriptionDialog', () => {
})
it('tracks modal_opened on the workspace (unified) path too', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'subscribe_to_run' })
@@ -232,7 +231,7 @@ describe('useSubscriptionDialog', () => {
})
it('does not track modal_opened for the inactive member dialog', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = false
mockCanManageSubscription.value = false
const { showPricingTable } = useSubscriptionDialog()

View File

@@ -2,7 +2,7 @@ import { defineAsyncComponent } from 'vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
@@ -24,7 +24,7 @@ export interface SubscriptionDialogOptions {
}
export const useSubscriptionDialog = () => {
const { flags } = useFeatureFlags()
const { shouldUseWorkspaceBilling } = useBillingRouting()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
@@ -57,7 +57,7 @@ export const useSubscriptionDialog = () => {
// small read-only "ask your owner to reactivate" modal instead of the
// pricing table. Out-of-credits still routes everyone to the credits flow.
if (
flags.teamWorkspacesEnabled &&
shouldUseWorkspaceBilling.value &&
!workspaceStore.isInPersonalWorkspace &&
!permissions.value.canManageSubscription &&
options?.reason !== 'out_of_credits'
@@ -95,9 +95,10 @@ export const useSubscriptionDialog = () => {
}
// Jun-5 model: a single unified pricing table (personal/team plan toggle on
// one workspace) when team workspaces are enabled. Replaces the old
// personal-vs-team workspace fork. Flag-off keeps the legacy table.
if (flags.teamWorkspacesEnabled) {
// one workspace) for workspaces on the consolidated billing flow. Replaces
// the old personal-vs-team workspace fork. Personal workspaces still on the
// legacy flow (consolidated billing disabled) get the legacy table.
if (shouldUseWorkspaceBilling.value) {
// Existing per-member (legacy) team subscribers keep the old tier-based
// team table; the unified credit-slider table is for everyone else.
// Resolved lazily (not at composable setup): these three composables form

View File

@@ -1,4 +1,5 @@
import {
cachedConsolidatedBillingEnabled,
cachedTeamWorkspacesEnabled,
remoteConfig,
remoteConfigState
@@ -55,10 +56,14 @@ export async function refreshRemoteConfig(
window.__CONFIG__ = config
remoteConfig.value = config
remoteConfigState.value = useAuth ? 'authenticated' : 'anonymous'
if (useAuth)
if (useAuth) {
cachedTeamWorkspacesEnabled.value = Boolean(
config.team_workspaces_enabled
)
cachedConsolidatedBillingEnabled.value = Boolean(
config.consolidated_billing_enabled
)
}
return
}

View File

@@ -59,3 +59,8 @@ export const cachedTeamWorkspacesEnabled = useStorage<boolean | undefined>(
'team_workspaces_enabled' satisfies `${ServerFeatureFlag.TEAM_WORKSPACES_ENABLED}`,
undefined
)
export const cachedConsolidatedBillingEnabled = useStorage<boolean | undefined>(
'consolidated_billing_enabled' satisfies `${ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED}`,
undefined
)

View File

@@ -111,6 +111,7 @@ export type RemoteConfig = {
comfyhub_upload_enabled?: boolean
comfyhub_profile_gate_enabled?: boolean
unified_cloud_auth?: boolean
consolidated_billing_enabled?: boolean
sentry_dsn?: string
turnstile_sitekey?: string
// Raw, unvalidated wire value (a server typo like 'enfroce' is possible).

View File

@@ -11,21 +11,49 @@ import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingUI } from './useSettingUI'
const env = vi.hoisted(() => {
const state = {
isCloud: false,
isDesktop: false,
isLoggedIn: false,
teamWorkspacesEnabled: false,
userSecretsEnabled: false,
isActiveSubscription: false,
billingType: 'legacy' as 'legacy' | 'workspace'
}
const fakeRef = <K extends keyof typeof state>(key: K) => ({
get value() {
return state[key]
}
})
return { state, fakeRef }
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (_: string, fallback: string) => fallback })
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ isLoggedIn: ref(false) })
useCurrentUser: () => ({ isLoggedIn: env.fakeRef('isLoggedIn') })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({ isActiveSubscription: ref(false) })
useBillingContext: () => ({
isActiveSubscription: env.fakeRef('isActiveSubscription'),
type: env.fakeRef('billingType')
})
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: { teamWorkspacesEnabled: false, userSecretsEnabled: false }
flags: {
get teamWorkspacesEnabled() {
return env.state.teamWorkspacesEnabled
},
get userSecretsEnabled() {
return env.state.userSecretsEnabled
}
}
})
}))
@@ -34,8 +62,12 @@ vi.mock('@/composables/useVueFeatureFlags', () => ({
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
isDesktop: false
get isCloud() {
return env.state.isCloud
},
get isDesktop() {
return env.state.isDesktop
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
@@ -77,6 +109,16 @@ describe('useSettingUI', () => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
Object.assign(env.state, {
isCloud: false,
isDesktop: false,
isLoggedIn: false,
teamWorkspacesEnabled: false,
userSecretsEnabled: false,
isActiveSubscription: false,
billingType: 'legacy'
})
vi.mocked(useSettingStore).mockReturnValue({
settingsById: mockSettings
} as ReturnType<typeof useSettingStore>)
@@ -137,4 +179,59 @@ describe('useSettingUI', () => {
const { defaultCategory } = useSettingUI('about', 'Comfy.Locale')
expect(defaultCategory.value.key).toBe('about')
})
describe('legacy billing in the workspace layout', () => {
const navKeys = (groups: { items: { id: string }[] }[]) =>
groups.flatMap((group) => group.items.map((item) => item.id))
beforeEach(() => {
Object.assign(env.state, {
isCloud: true,
isLoggedIn: true,
teamWorkspacesEnabled: true,
isActiveSubscription: true
})
window.__CONFIG__ = {
subscription_required: true
} as typeof window.__CONFIG__
})
it('exposes the legacy plan panel when billing is legacy', () => {
env.state.billingType = 'legacy'
const { defaultCategory, navGroups } = useSettingUI('subscription')
expect(defaultCategory.value.key).toBe('subscription')
expect(navKeys(navGroups.value)).toContain('subscription')
expect(navKeys(navGroups.value)).toContain('workspace')
})
it('hides the legacy plan panel when billing is workspace', () => {
env.state.billingType = 'workspace'
const { navGroups } = useSettingUI()
expect(navKeys(navGroups.value)).not.toContain('subscription')
expect(navKeys(navGroups.value)).toContain('workspace')
})
it('never renders the plan panel in more than one tab', () => {
const countSubscription = () => {
const { navGroups } = useSettingUI()
return navKeys(navGroups.value).filter((id) => id === 'subscription')
.length
}
for (const teamWorkspacesEnabled of [true, false]) {
for (const billingType of ['legacy', 'workspace'] as const) {
for (const isLoggedIn of [true, false]) {
Object.assign(env.state, {
teamWorkspacesEnabled,
billingType,
isLoggedIn
})
expect(countSubscription()).toBeLessThanOrEqual(1)
}
}
}
})
})
})

View File

@@ -53,7 +53,7 @@ export function useSettingUI(
const { flags } = useFeatureFlags()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { isActiveSubscription } = useBillingContext()
const { isActiveSubscription, type: billingType } = useBillingContext()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
@@ -157,6 +157,13 @@ export function useSettingUI(
return isActiveSubscription.value
})
const shouldShowLegacyPlanCreditsPanel = computed(
() =>
isLoggedIn.value &&
billingType.value === 'legacy' &&
shouldShowPlanCreditsPanel.value
)
const userPanel: SettingPanelItem = {
node: {
key: 'user',
@@ -301,6 +308,9 @@ export function useSettingUI(
label: 'General',
children: [
translateCategory(userPanel.node),
...(shouldShowLegacyPlanCreditsPanel.value && subscriptionPanel
? [translateCategory(subscriptionPanel.node)]
: []),
...coreSettingCategories.value.slice(0, 1).map(translateCategory),
...(shouldShowSecretsPanel.value
? [translateCategory(secretsPanel.node)]
@@ -332,9 +342,7 @@ export function useSettingUI(
label: 'Account',
children: [
userPanel.node,
...(isLoggedIn.value &&
shouldShowPlanCreditsPanel.value &&
subscriptionPanel
...(shouldShowLegacyPlanCreditsPanel.value && subscriptionPanel
? [subscriptionPanel.node]
: []),
...(shouldShowSecretsPanel.value ? [secretsPanel.node] : []),

View File

@@ -779,7 +779,7 @@ export const CORE_SETTINGS: SettingParams[] = [
tooltip:
'When enabled, nodes are selected/deselected in real-time as you drag the selection rectangle, similar to other design tools.',
type: 'boolean',
defaultValue: false,
defaultValue: true,
versionAdded: '1.36.1'
},
{

View File

@@ -78,4 +78,43 @@ describe('TelemetryRegistry', () => {
})
).not.toThrow()
})
it('dispatches subscription cancellation telemetry to every registered provider', () => {
const a: TelemetryProvider = { trackSubscriptionCancellation: vi.fn() }
const b: TelemetryProvider = { trackSubscriptionCancellation: vi.fn() }
const registry = new TelemetryRegistry()
registry.registerProvider(a)
registry.registerProvider(b)
const payload = {
source: 'cancel_plan_menu' as const,
current_tier: 'standard',
cycle: 'monthly' as const,
end_date: '2026-08-01T00:00:00.000Z'
}
registry.trackSubscriptionCancellation('flow_opened', payload)
expect(a.trackSubscriptionCancellation).toHaveBeenCalledExactlyOnceWith(
'flow_opened',
payload
)
expect(b.trackSubscriptionCancellation).toHaveBeenCalledExactlyOnceWith(
'flow_opened',
payload
)
})
it('dispatches resubscribe click telemetry to every registered provider', () => {
const a: TelemetryProvider = { trackResubscribeClicked: vi.fn() }
const b: TelemetryProvider = { trackResubscribeClicked: vi.fn() }
const registry = new TelemetryRegistry()
registry.registerProvider(a)
registry.registerProvider(b)
const payload = { source: 'settings_billing_panel' as const }
registry.trackResubscribeClicked(payload)
expect(a.trackResubscribeClicked).toHaveBeenCalledExactlyOnceWith(payload)
expect(b.trackResubscribeClicked).toHaveBeenCalledExactlyOnceWith(payload)
})
})

View File

@@ -19,10 +19,12 @@ import type {
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
ResubscribeClickMetadata,
RunButtonProperties,
SettingChangedMetadata,
SharedWorkflowRunMetadata,
ShellLayoutMetadata,
SubscriptionCancellationMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
@@ -100,6 +102,19 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackMonthlySubscriptionCancelled?.())
}
trackSubscriptionCancellation(
event: 'flow_opened' | 'confirmed' | 'abandoned' | 'failed',
metadata?: SubscriptionCancellationMetadata
): void {
this.dispatch((provider) =>
provider.trackSubscriptionCancellation?.(event, metadata)
)
}
trackResubscribeClicked(metadata: ResubscribeClickMetadata): void {
this.dispatch((provider) => provider.trackResubscribeClicked?.(metadata))
}
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
this.dispatch((provider) =>
provider.trackAddApiCreditButtonClicked?.(metadata)

View File

@@ -313,6 +313,45 @@ describe('PostHogTelemetryProvider', () => {
)
})
it.for([
['flow_opened', TelemetryEvents.SUBSCRIPTION_CANCEL_FLOW_OPENED, {}],
['confirmed', TelemetryEvents.SUBSCRIPTION_CANCEL_CONFIRMED, {}],
['abandoned', TelemetryEvents.SUBSCRIPTION_CANCEL_ABANDONED, {}],
[
'failed',
TelemetryEvents.SUBSCRIPTION_CANCEL_FAILED,
{ error_message: 'timed out' }
]
] as const)(
'captures %s cancellation stage',
async ([stage, event, extra]) => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackSubscriptionCancellation(stage, {
current_tier: 'standard',
...extra
})
expect(hoisted.mockCapture).toHaveBeenCalledWith(event, {
current_tier: 'standard',
...extra
})
}
)
it('captures resubscribe clicks with their source', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackResubscribeClicked({ source: 'settings_billing_panel' })
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.RESUBSCRIBE_BUTTON_CLICKED,
{ source: 'settings_billing_panel' }
)
})
it('captures begin_checkout with intent metadata', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()

View File

@@ -26,10 +26,12 @@ import type {
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
ResubscribeClickMetadata,
RunButtonProperties,
SettingChangedMetadata,
SharedWorkflowRunMetadata,
ShellLayoutMetadata,
SubscriptionCancellationMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
@@ -47,7 +49,7 @@ import type {
WorkflowSavedMetadata,
WorkspaceInviteMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
import { CANCELLATION_STAGE_EVENTS, TelemetryEvents } from '../../types'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
const DEFAULT_DISABLED_EVENTS = [
@@ -370,6 +372,17 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED)
}
trackSubscriptionCancellation(
event: 'flow_opened' | 'confirmed' | 'abandoned' | 'failed',
metadata?: SubscriptionCancellationMetadata
): void {
this.trackEvent(CANCELLATION_STAGE_EVENTS[event], metadata)
}
trackResubscribeClicked(metadata: ResubscribeClickMetadata): void {
this.trackEvent(TelemetryEvents.RESUBSCRIBE_BUTTON_CLICKED, metadata)
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED, {
credit_amount: amount

View File

@@ -115,6 +115,36 @@ describe('HostTelemetrySink', () => {
)
})
it('forwards subscription cancellation telemetry to the host bridge', () => {
new HostTelemetrySink().trackSubscriptionCancellation('confirmed', {
source: 'cancel_plan_menu',
current_tier: 'standard',
cycle: 'yearly',
end_date: '2026-08-01T00:00:00.000Z'
})
expect(state.capture).toHaveBeenCalledExactlyOnceWith(
TelemetryEvents.SUBSCRIPTION_CANCEL_CONFIRMED,
{
source: 'cancel_plan_menu',
current_tier: 'standard',
cycle: 'yearly',
end_date: '2026-08-01T00:00:00.000Z'
}
)
})
it('forwards resubscribe click telemetry to the host bridge', () => {
new HostTelemetrySink().trackResubscribeClicked({
source: 'pricing_dialog'
})
expect(state.capture).toHaveBeenCalledExactlyOnceWith(
TelemetryEvents.RESUBSCRIBE_BUTTON_CLICKED,
{ source: 'pricing_dialog' }
)
})
it('forwards add-credit clicks with their source', () => {
new HostTelemetrySink().trackAddApiCreditButtonClicked({
source: 'avatar_menu'

View File

@@ -31,6 +31,8 @@ import type {
ShareFlowMetadata,
ShareLinkOpenedMetadata,
SharedWorkflowRunMetadata,
ResubscribeClickMetadata,
SubscriptionCancellationMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
@@ -46,7 +48,7 @@ import type {
WorkflowImportMetadata,
WorkflowSavedMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
import { CANCELLATION_STAGE_EVENTS, TelemetryEvents } from '../../types'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
type HostTelemetryProperties = Parameters<
@@ -127,6 +129,17 @@ export class HostTelemetrySink implements TelemetryProvider {
this.capture(TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED)
}
trackSubscriptionCancellation(
event: 'flow_opened' | 'confirmed' | 'abandoned' | 'failed',
metadata?: SubscriptionCancellationMetadata
): void {
this.capture(CANCELLATION_STAGE_EVENTS[event], metadata)
}
trackResubscribeClicked(metadata: ResubscribeClickMetadata): void {
this.capture(TelemetryEvents.RESUBSCRIBE_BUTTON_CLICKED, metadata)
}
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
this.capture(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED, metadata)
}

View File

@@ -450,6 +450,27 @@ export interface AddCreditsClickMetadata {
source: 'credits_panel' | 'avatar_menu' | 'settings_billing_panel'
}
export interface SubscriptionCancellationMetadata {
current_tier?: string
cycle?: BillingCycle
/**
* `manage_subscription_button` opens the external billing portal, where
* cancellation is one of the few possible actions but not the only one —
* treat it as probable, not certain, cancel intent.
*/
source?: 'cancel_plan_menu' | 'manage_subscription_button'
/** ISO date the subscription runs until if the cancel goes through. */
end_date?: string
/** Present only on the `failed` stage. */
error_message?: string
}
export interface ResubscribeClickMetadata {
source: 'pricing_dialog' | 'settings_billing_panel'
/** Why the pricing dialog was opened, when the click came from one. */
payment_intent_source?: PaymentIntentSource
}
export interface BeginCheckoutMetadata
extends Record<string, unknown>, CheckoutAttributionMetadata {
user_id: string
@@ -514,6 +535,11 @@ export interface TelemetryProvider {
metadata?: SubscriptionSuccessMetadata
): void
trackMonthlySubscriptionCancelled?(): void
trackSubscriptionCancellation?(
event: 'flow_opened' | 'confirmed' | 'abandoned' | 'failed',
metadata?: SubscriptionCancellationMetadata
): void
trackResubscribeClicked?(metadata: ResubscribeClickMetadata): void
trackAddApiCreditButtonClicked?(metadata?: AddCreditsClickMetadata): void
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
trackApiCreditTopupSucceeded?(): void
@@ -617,6 +643,11 @@ export const TelemetryEvents = {
SUBSCRIBE_NOW_BUTTON_CLICKED: 'app:subscribe_now_button_clicked',
MONTHLY_SUBSCRIPTION_SUCCEEDED: 'app:monthly_subscription_succeeded',
MONTHLY_SUBSCRIPTION_CANCELLED: 'app:monthly_subscription_cancelled',
SUBSCRIPTION_CANCEL_FLOW_OPENED: 'app:subscription_cancel_flow_opened',
SUBSCRIPTION_CANCEL_CONFIRMED: 'app:subscription_cancel_confirmed',
SUBSCRIPTION_CANCEL_ABANDONED: 'app:subscription_cancel_abandoned',
SUBSCRIPTION_CANCEL_FAILED: 'app:subscription_cancel_failed',
RESUBSCRIBE_BUTTON_CLICKED: 'app:resubscribe_button_clicked',
ADD_API_CREDIT_BUTTON_CLICKED: 'app:add_api_credit_button_clicked',
API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED:
'app:api_credit_topup_button_purchase_clicked',
@@ -691,6 +722,13 @@ export const TelemetryEvents = {
export type TelemetryEventName =
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
export const CANCELLATION_STAGE_EVENTS = {
flow_opened: TelemetryEvents.SUBSCRIPTION_CANCEL_FLOW_OPENED,
confirmed: TelemetryEvents.SUBSCRIPTION_CANCEL_CONFIRMED,
abandoned: TelemetryEvents.SUBSCRIPTION_CANCEL_ABANDONED,
failed: TelemetryEvents.SUBSCRIPTION_CANCEL_FAILED
} as const
export type ExecutionTriggerSource =
| 'button'
| 'keybinding'

View File

@@ -3,6 +3,7 @@ import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useTelemetry } from '@/platform/telemetry'
/**
* Reactivates a cancelled-but-still-active subscription and surfaces success or
@@ -16,6 +17,9 @@ export function useResubscribe() {
const isResubscribing = ref(false)
async function handleResubscribe() {
useTelemetry()?.trackResubscribeClicked({
source: 'settings_billing_panel'
})
isResubscribing.value = true
try {
await resubscribe()

View File

@@ -123,9 +123,12 @@ vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: mockToastAdd })
}))
const mockTrackResubscribeClicked = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackMonthlySubscriptionSucceeded: vi.fn(),
trackResubscribeClicked: mockTrackResubscribeClicked,
trackBeginCheckout: mockTrackBeginCheckout
})
}))
@@ -854,7 +857,7 @@ describe('useSubscriptionCheckout', () => {
describe('handleResubscribe', () => {
it('emits close on success', async () => {
const checkout = await setup()
const checkout = await setup('subscribe_to_run')
mockResubscribe.mockResolvedValueOnce({
billing_op_id: 'op-4',
status: 'active'
@@ -866,6 +869,10 @@ describe('useSubscriptionCheckout', () => {
expect(mockResubscribe).toHaveBeenCalled()
expect(emit).toHaveBeenCalledWith('close', true)
expect(mockTrackResubscribeClicked).toHaveBeenCalledWith({
source: 'pricing_dialog',
payment_intent_source: 'subscribe_to_run'
})
})
it('shows error toast on failure', async () => {

View File

@@ -343,6 +343,10 @@ export function useSubscriptionCheckout(
}
async function handleResubscribe() {
telemetry?.trackResubscribeClicked({
source: 'pricing_dialog',
payment_intent_source: paymentIntentSource
})
isResubscribing.value = true
try {
await resubscribe()

View File

@@ -179,14 +179,6 @@ describe('useTeamWorkspaceStore', () => {
expect(store.canCreateWorkspace).toBe(true)
expect(store.members).toEqual([])
expect(store.pendingInvites).toEqual([])
expect(store.originalOwnerId).toBeNull()
expect(store.isCurrentUserOriginalOwner).toBe(false)
expect(store.totalMemberSlots).toBe(0)
expect(store.isInviteLimitReached).toBe(false)
expect(store.workspaceId).toBeNull()
expect(store.workspaceName).toBe('')
expect(store.isWorkspaceSubscribed).toBe(false)
expect(store.subscriptionPlan).toBeNull()
})
})
@@ -216,30 +208,6 @@ describe('useTeamWorkspaceStore', () => {
expect(mockWorkspaceAuthStore.switchWorkspace).not.toHaveBeenCalled()
})
it('falls back when the restored session workspace is no longer available', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = {
...mockTeamWorkspace,
id: 'ws-stale'
}
mockWorkspaceAuthStore.switchWorkspace.mockRejectedValueOnce(
new Error('Token exchange failed')
)
const store = useTeamWorkspaceStore()
await store.initialize()
expect(mockWorkspaceAuthStore.clearWorkspaceContext).toHaveBeenCalled()
expect(store.activeWorkspaceId).toBe(mockPersonalWorkspace.id)
expect(store.initState).toBe('ready')
expect(consoleSpy).toHaveBeenCalledWith(
'[teamWorkspaceStore] Token exchange failed during fallback'
)
consoleSpy.mockRestore()
})
it('falls back to localStorage if no session', async () => {
mockLocalStorage.getItem.mockReturnValue(mockTeamWorkspace.id)
@@ -249,17 +217,6 @@ describe('useTeamWorkspaceStore', () => {
expect(store.activeWorkspaceId).toBe(mockTeamWorkspace.id)
})
it('uses the default workspace when localStorage cannot be read', async () => {
mockLocalStorage.getItem.mockImplementationOnce(() => {
throw new Error('blocked')
})
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.activeWorkspaceId).toBe(mockPersonalWorkspace.id)
})
it('falls back to personal if stored workspace not in list', async () => {
mockLocalStorage.getItem.mockReturnValue('non-existent-workspace')
@@ -269,23 +226,6 @@ describe('useTeamWorkspaceStore', () => {
expect(store.activeWorkspaceId).toBe(mockPersonalWorkspace.id)
})
it('continues initialization when persisting the last workspace fails', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockLocalStorage.setItem.mockImplementationOnce(() => {
throw new Error('quota')
})
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.initState).toBe('ready')
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to persist last workspace ID to localStorage'
)
consoleSpy.mockRestore()
})
it('sets error state when workspaces fetch fails after retries', async () => {
vi.useFakeTimers()
mockWorkspaceApi.list.mockRejectedValue(new Error('Network error'))
@@ -488,14 +428,6 @@ describe('useTeamWorkspaceStore', () => {
})
describe('deleteWorkspace', () => {
it('throws when no active workspace is available', async () => {
const store = useTeamWorkspaceStore()
await expect(store.deleteWorkspace()).rejects.toThrow(
'No workspace to delete'
)
})
it('deletes non-active workspace without reload', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
@@ -527,35 +459,6 @@ describe('useTeamWorkspaceStore', () => {
expect(mockReload).toHaveBeenCalled()
})
it('deletes an active team workspace even when no personal workspace exists', async () => {
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
mockWorkspaceApi.list.mockResolvedValue({
workspaces: [mockTeamWorkspace]
})
const store = useTeamWorkspaceStore()
await store.initialize()
await store.deleteWorkspace()
expect(mockWorkspaceApi.delete).toHaveBeenCalledWith(mockTeamWorkspace.id)
expect(mockWorkspaceAuthStore.clearWorkspaceContext).toHaveBeenCalled()
expect(mockReload).toHaveBeenCalled()
})
it('resets isDeleting when deletion fails', async () => {
mockWorkspaceApi.delete.mockRejectedValue(new Error('Delete failed'))
const store = useTeamWorkspaceStore()
await store.initialize()
await expect(store.deleteWorkspace(mockTeamWorkspace.id)).rejects.toThrow(
'Delete failed'
)
expect(store.isDeleting).toBe(false)
})
it('throws when trying to delete personal workspace', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
@@ -597,32 +500,6 @@ describe('useTeamWorkspaceStore', () => {
)
expect(updated?.name).toBe('Renamed Workspace')
})
it('renames the active workspace by name', async () => {
mockWorkspaceApi.update.mockResolvedValue({
...mockPersonalWorkspace,
name: 'Renamed Personal'
})
const store = useTeamWorkspaceStore()
await store.initialize()
await store.updateWorkspaceName('Renamed Personal')
expect(mockWorkspaceApi.update).toHaveBeenCalledWith(
mockPersonalWorkspace.id,
{ name: 'Renamed Personal' }
)
expect(store.activeWorkspace?.name).toBe('Renamed Personal')
})
it('throws when renaming without an active workspace', async () => {
const store = useTeamWorkspaceStore()
await expect(store.updateWorkspaceName('Nope')).rejects.toThrow(
'No active workspace'
)
})
})
describe('leaveWorkspace', () => {
@@ -736,13 +613,6 @@ describe('useTeamWorkspaceStore', () => {
})
describe('member actions', () => {
it('fetchMembers returns empty before a workspace is active', async () => {
const store = useTeamWorkspaceStore()
await expect(store.fetchMembers()).resolves.toEqual([])
expect(mockWorkspaceApi.listMembers).not.toHaveBeenCalled()
})
it('fetchMembers updates active workspace members', async () => {
const mockMembers = [
{
@@ -820,27 +690,6 @@ describe('useTeamWorkspaceStore', () => {
expect(store.members[0].id).toBe('user-2')
})
it('removeMember succeeds without a current workspace', async () => {
const store = useTeamWorkspaceStore()
await store.removeMember('user-1')
expect(mockWorkspaceApi.removeMember).toHaveBeenCalledWith('user-1')
expect(store.members).toEqual([])
})
it('changeMemberRole succeeds without a current workspace', async () => {
const store = useTeamWorkspaceStore()
await store.changeMemberRole('user-1', 'owner')
expect(mockWorkspaceApi.updateMemberRole).toHaveBeenCalledWith(
'user-1',
'owner'
)
expect(store.members).toEqual([])
})
it('changeMemberRole flips the role locally without trusting the response body', async () => {
mockWorkspaceApi.listMembers.mockResolvedValue({
members: [
@@ -1094,14 +943,6 @@ describe('useTeamWorkspaceStore', () => {
return store
}
it('does nothing before a workspace is active', async () => {
const store = useTeamWorkspaceStore()
await store.ensureMembersLoaded()
expect(mockWorkspaceApi.listMembers).not.toHaveBeenCalled()
})
it('loads members for a team workspace that is not yet loaded', async () => {
mockMembersResponse()
const store = await activateTeamWorkspace()
@@ -1288,21 +1129,6 @@ describe('useTeamWorkspaceStore', () => {
})
describe('invite actions', () => {
it('fetchPendingInvites returns empty before a workspace is active', async () => {
const store = useTeamWorkspaceStore()
await expect(store.fetchPendingInvites()).resolves.toEqual([])
expect(mockWorkspaceApi.listInvites).not.toHaveBeenCalled()
})
it('fetchPendingInvites returns empty for personal workspaces', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
await expect(store.fetchPendingInvites()).resolves.toEqual([])
expect(mockWorkspaceApi.listInvites).not.toHaveBeenCalled()
})
it('fetchPendingInvites updates active workspace invites', async () => {
const mockInvites = [
{
@@ -1353,23 +1179,6 @@ describe('useTeamWorkspaceStore', () => {
)
})
it('createInvite returns the invite before a workspace is active', async () => {
const newInvite = {
id: 'inv-new',
email: 'new@test.com',
token: 'token-new',
invited_at: '2024-01-01T00:00:00Z',
expires_at: '2024-01-08T00:00:00Z'
}
mockWorkspaceApi.createInvite.mockResolvedValue(newInvite)
const store = useTeamWorkspaceStore()
const result = await store.createInvite('new@test.com')
expect(result.email).toBe('new@test.com')
expect(store.pendingInvites).toEqual([])
})
it('revokeInvite removes from local list', async () => {
const mockInvites = [
{
@@ -1402,15 +1211,6 @@ describe('useTeamWorkspaceStore', () => {
expect(store.pendingInvites[0].id).toBe('inv-2')
})
it('revokeInvite succeeds before a workspace is active', async () => {
const store = useTeamWorkspaceStore()
await store.revokeInvite('inv-1')
expect(mockWorkspaceApi.revokeInvite).toHaveBeenCalledWith('inv-1')
expect(store.pendingInvites).toEqual([])
})
it('resendInvite creates a fresh invite before revoking the old one', async () => {
mockWorkspaceApi.listInvites.mockResolvedValue({
invites: [
@@ -1591,36 +1391,6 @@ describe('useTeamWorkspaceStore', () => {
})
})
describe('subscription placeholder', () => {
it('warns with the default subscription plan', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const store = useTeamWorkspaceStore()
store.subscribeWorkspace()
expect(consoleSpy).toHaveBeenCalledWith(
'PRO_MONTHLY',
'Billing endpoint has not been added yet.'
)
consoleSpy.mockRestore()
})
it('warns with a custom subscription plan', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const store = useTeamWorkspaceStore()
store.subscribeWorkspace('TEAM_YEARLY')
expect(consoleSpy).toHaveBeenCalledWith(
'TEAM_YEARLY',
'Billing endpoint has not been added yet.'
)
consoleSpy.mockRestore()
})
})
describe('totalMemberSlots and isInviteLimitReached', () => {
it('calculates total slots from members and invites', async () => {
const mockMembers = [

View File

@@ -74,23 +74,6 @@ function expectedExpiresAtMs(expiresAt: string): string {
return new Date(expiresAt).getTime().toString()
}
function createThrowingSessionStorage(
overrides: Partial<Pick<Storage, 'getItem' | 'removeItem'>>
): Storage {
const original = globalThis.sessionStorage
return {
get length() {
return original.length
},
key: original.key.bind(original),
getItem: original.getItem.bind(original),
setItem: original.setItem.bind(original),
removeItem: original.removeItem.bind(original),
clear: original.clear.bind(original),
...overrides
} satisfies Storage
}
describe('useWorkspaceAuthStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
@@ -219,93 +202,6 @@ describe('useWorkspaceAuthStore', () => {
expect(result).toBe(false)
})
it('returns false and clears storage when the workspace shape is invalid', () => {
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify({ ...mockWorkspaceWithRole, role: 'admin' })
)
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'some-token')
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
(Date.now() + 3600 * 1000).toString()
)
const store = useWorkspaceAuthStore()
const result = store.initializeFromSession()
expect(result).toBe(false)
expect(
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
).toBeNull()
})
it('returns false when sessionStorage access throws', () => {
const originalSessionStorage = globalThis.sessionStorage
const throwingSessionStorage = createThrowingSessionStorage({
getItem: vi.fn(() => {
throw new Error('blocked')
}),
removeItem: vi.fn(() => {
throw new Error('blocked')
})
})
vi.stubGlobal('sessionStorage', throwingSessionStorage)
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
try {
const store = useWorkspaceAuthStore()
expect(store.initializeFromSession()).toBe(false)
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to clear workspace context from sessionStorage'
)
} finally {
vi.stubGlobal('sessionStorage', originalSessionStorage)
consoleWarnSpy.mockRestore()
}
})
it('init restores session state and refreshes immediately inside the buffer window', async () => {
const nearExpiry = Date.now() + 1000
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify(mockWorkspaceWithRole)
)
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'session-token')
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
nearExpiry.toString()
)
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const refreshedExpiry = new Date(Date.now() + 3600 * 1000).toISOString()
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockTokenResponse,
token: 'refreshed-token',
expires_at: refreshedExpiry
})
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const { workspaceToken } = storeToRefs(store)
store.init()
expect(workspaceToken.value).toBe('session-token')
await vi.advanceTimersByTimeAsync(0)
expect(workspaceToken.value).toBe('refreshed-token')
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBe(
'refreshed-token'
)
})
})
describe('switchWorkspace', () => {
@@ -535,71 +431,6 @@ describe('useWorkspaceAuthStore', () => {
)
})
it('falls back to statusText when an error body has no message', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Server exploded',
json: () => Promise.resolve({})
})
)
const store = useWorkspaceAuthStore()
const { error } = storeToRefs(store)
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
WorkspaceAuthError
)
expect((error.value as WorkspaceAuthError).code).toBe(
'TOKEN_EXCHANGE_FAILED'
)
})
it('throws TOKEN_EXCHANGE_FAILED when the expiry timestamp is invalid', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockTokenResponse,
expires_at: 'not-a-date'
})
})
)
const store = useWorkspaceAuthStore()
const { error } = storeToRefs(store)
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
WorkspaceAuthError
)
expect((error.value as WorkspaceAuthError).code).toBe(
'TOKEN_EXCHANGE_FAILED'
)
})
it('normalizes non-Error request failures into store error state', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal('fetch', vi.fn().mockRejectedValue('network down'))
const store = useWorkspaceAuthStore()
const { error } = storeToRefs(store)
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
'network down'
)
expect(error.value).toBeInstanceOf(Error)
expect(error.value?.message).toBe('network down')
})
it('sends correct request to API', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockResolvedValue({
@@ -624,120 +455,6 @@ describe('useWorkspaceAuthStore', () => {
}
)
})
it('uses status text when the error body cannot be parsed', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Gateway Timeout',
json: () => Promise.reject(new Error('bad json'))
})
)
const store = useWorkspaceAuthStore()
const { error } = storeToRefs(store)
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
WorkspaceAuthError
)
expect((error.value as WorkspaceAuthError).code).toBe(
'TOKEN_EXCHANGE_FAILED'
)
})
it('does not let an older switch overwrite a newer committed workspace', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
let resolveFirst: (value: unknown) => void = () => {}
const firstResponse = new Promise((resolve) => {
resolveFirst = resolve
})
const secondExpiry = new Date(Date.now() + 3600 * 1000).toISOString()
const mockFetch = vi
.fn()
.mockReturnValueOnce(firstResponse)
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
...mockTokenResponse,
token: 'newer-token',
expires_at: secondExpiry,
workspace: { ...mockWorkspace, id: 'workspace-other' }
})
})
vi.stubGlobal('fetch', mockFetch)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const store = useWorkspaceAuthStore()
const { currentWorkspace, workspaceToken } = storeToRefs(store)
const firstSwitch = store.switchWorkspace('workspace-123')
await Promise.resolve()
const secondSwitch = store.switchWorkspace('workspace-other')
await secondSwitch
resolveFirst({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
await firstSwitch
expect(currentWorkspace.value?.id).toBe('workspace-other')
expect(workspaceToken.value).toBe('newer-token')
expect(warn).toHaveBeenCalledWith(
'Aborting stale workspace switch: workspace context changed before commit'
)
warn.mockRestore()
})
it('does not surface an older switch error after a newer workspace commits', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
let resolveFirst: (value: unknown) => void = () => {}
const firstResponse = new Promise((resolve) => {
resolveFirst = resolve
})
const mockFetch = vi
.fn()
.mockReturnValueOnce(firstResponse)
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
...mockTokenResponse,
token: 'newer-token',
workspace: { ...mockWorkspace, id: 'workspace-other' }
})
})
vi.stubGlobal('fetch', mockFetch)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const store = useWorkspaceAuthStore()
const { currentWorkspace, error } = storeToRefs(store)
const firstSwitch = store.switchWorkspace('workspace-123')
await Promise.resolve()
await store.switchWorkspace('workspace-other')
resolveFirst({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.resolve({ message: 'Server error' })
})
await firstSwitch
expect(currentWorkspace.value?.id).toBe('workspace-other')
expect(error.value).toBeNull()
expect(warn).toHaveBeenCalledWith(
'Aborting stale workspace switch: workspace context changed before error commit',
expect.any(WorkspaceAuthError)
)
warn.mockRestore()
})
})
describe('clearWorkspaceContext', () => {
@@ -787,32 +504,6 @@ describe('useWorkspaceAuthStore', () => {
).toBeNull()
})
it('warns when sessionStorage cannot be cleared', () => {
const originalSessionStorage = globalThis.sessionStorage
const throwingSessionStorage = createThrowingSessionStorage({
removeItem: vi.fn(() => {
throw new Error('blocked')
})
})
vi.stubGlobal('sessionStorage', throwingSessionStorage)
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
try {
const store = useWorkspaceAuthStore()
store.clearWorkspaceContext()
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to clear workspace context from sessionStorage'
)
} finally {
vi.stubGlobal('sessionStorage', originalSessionStorage)
consoleWarnSpy.mockRestore()
}
})
it('prevents in-flight refreshes from restoring cleared state', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockResolvedValueOnce({
@@ -890,25 +581,6 @@ describe('useWorkspaceAuthStore', () => {
Authorization: 'Bearer workspace-token-abc'
})
})
it('returns the raw workspace token only when present', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
)
const store = useWorkspaceAuthStore()
expect(store.getWorkspaceToken()).toBeUndefined()
await store.switchWorkspace('workspace-123')
expect(store.getWorkspaceToken()).toBe('workspace-token-abc')
})
})
describe('token refresh scheduling', () => {
@@ -942,28 +614,6 @@ describe('useWorkspaceAuthStore', () => {
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('destroy stops a scheduled token refresh', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const expiresInMs = 3600 * 1000
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockTokenResponse,
expires_at: new Date(Date.now() + expiresInMs).toISOString()
})
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
await store.switchWorkspace('workspace-123')
store.destroy()
await vi.advanceTimersByTimeAsync(expiresInMs)
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it('clears context when refresh fails with ACCESS_DENIED', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const expiresInMs = 3600 * 1000
@@ -1228,49 +878,6 @@ describe('useWorkspaceAuthStore', () => {
consoleWarnSpy.mockRestore()
})
it('clears context when transient refresh retries outlive the token expiry', async () => {
const nearExpiry = Date.now() + 1
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify(mockWorkspaceWithRole)
)
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'nearly-expired')
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
nearExpiry.toString()
)
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.resolve({ message: 'Server error' })
})
vi.stubGlobal('fetch', mockFetch)
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
const store = useWorkspaceAuthStore()
const { currentWorkspace, workspaceToken } = storeToRefs(store)
expect(store.initializeFromSession()).toBe(true)
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(1000)
await vi.advanceTimersByTimeAsync(2000)
await vi.advanceTimersByTimeAsync(4000)
expect(mockFetch).toHaveBeenCalledTimes(4)
expect(currentWorkspace.value).toBeNull()
expect(workspaceToken.value).toBeNull()
consoleErrorSpy.mockRestore()
consoleWarnSpy.mockRestore()
})
it('clears context immediately on INVALID_FIREBASE_TOKEN without retrying', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockResolvedValueOnce({
@@ -2216,62 +1823,6 @@ describe('useWorkspaceAuthStore', () => {
)
})
it('warns and resolves false when the login mint hits a transient error', async () => {
mockUnifiedCloudAuthEnabled.value = true
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.resolve({ message: 'try again' })
})
)
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
const store = useWorkspaceAuthStore()
const { unifiedToken } = storeToRefs(store)
const result = await store.mintAtLogin()
expect(result).toBe(false)
expect(unifiedToken.value).toBeNull()
expect(mockToastAdd).not.toHaveBeenCalled()
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Unified login mint failed:',
expect.any(WorkspaceAuthError)
)
consoleWarnSpy.mockRestore()
})
it('skips a scheduled unified refresh after the flag turns off', async () => {
mockUnifiedCloudAuthEnabled.value = true
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const expiresInMs = 3600 * 1000
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...personalTokenResponse,
expires_at: new Date(Date.now() + expiresInMs).toISOString()
})
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
await store.mintAtLogin()
mockUnifiedCloudAuthEnabled.value = false
await vi.advanceTimersByTimeAsync(expiresInMs - 5 * 60 * 1000)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockNotifyTokenRefreshed).not.toHaveBeenCalled()
})
it('never toasts from the unified lifecycle when the flag is OFF', async () => {
mockUnifiedCloudAuthEnabled.value = false
mockGetIdToken.mockResolvedValue('firebase-token-xyz')

View File

@@ -1,26 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
const electronAPI = vi.hoisted(() => vi.fn())
vi.mock('@/platform/distribution/types', () => ({ isDesktop: false }))
vi.mock('@/utils/envUtil', () => ({ electronAPI }))
describe('electronDownloadStore outside desktop', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
electronAPI.mockClear()
})
it('skips the Electron bridge when not running on desktop', async () => {
const store = useElectronDownloadStore()
await store.initialize()
expect(electronAPI).not.toHaveBeenCalled()
expect(store.downloads).toEqual([])
})
})

View File

@@ -1,106 +0,0 @@
import { DownloadStatus } from '@comfyorg/comfyui-electron-types'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
const downloadManagerMock = vi.hoisted(() => ({
cancelDownload: vi.fn(),
getAllDownloads: vi.fn(),
onDownloadProgress: vi.fn(),
pauseDownload: vi.fn(),
resumeDownload: vi.fn(),
startDownload: vi.fn()
}))
vi.mock('@/platform/distribution/types', () => ({
isDesktop: true
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: () => ({
DownloadManager: downloadManagerMock
})
}))
describe('electronDownloadStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
Object.values(downloadManagerMock).forEach((mock) => mock.mockReset())
downloadManagerMock.getAllDownloads.mockResolvedValue([
{
filename: 'done.bin',
status: DownloadStatus.COMPLETED,
url: 'https://example.com/done.bin'
}
])
})
it('loads existing downloads and applies progress updates by URL', async () => {
let progressCallback:
| Parameters<typeof downloadManagerMock.onDownloadProgress>[0]
| undefined
downloadManagerMock.onDownloadProgress.mockImplementation((callback) => {
progressCallback = callback
})
// The store runs initialize() automatically during setup; wait for it to
// finish instead of calling it again (which would double-load downloads).
const store = useElectronDownloadStore()
await vi.waitFor(() => expect(progressCallback).toBeDefined())
progressCallback?.({
filename: 'model.bin',
progress: 25,
savePath: '/tmp/model.bin',
status: DownloadStatus.IN_PROGRESS,
url: 'https://example.com/model.bin'
})
progressCallback?.({
filename: 'model.bin',
progress: 50,
savePath: '/tmp/model.bin',
status: DownloadStatus.IN_PROGRESS,
url: 'https://example.com/model.bin'
})
expect(store.findByUrl('https://example.com/done.bin')?.status).toBe(
DownloadStatus.COMPLETED
)
expect(store.findByUrl('https://example.com/model.bin')).toMatchObject({
filename: 'model.bin',
progress: 50,
status: DownloadStatus.IN_PROGRESS
})
expect(store.inProgressDownloads).toHaveLength(1)
expect(store.downloads).toHaveLength(2)
})
it('delegates download controls to the Electron bridge', async () => {
const store = useElectronDownloadStore()
await store.start({
filename: 'model.bin',
savePath: '/tmp/model.bin',
url: 'https://example.com/model.bin'
})
await store.pause('https://example.com/model.bin')
await store.resume('https://example.com/model.bin')
await store.cancel('https://example.com/model.bin')
expect(downloadManagerMock.startDownload).toHaveBeenCalledWith(
'https://example.com/model.bin',
'/tmp/model.bin',
'model.bin'
)
expect(downloadManagerMock.pauseDownload).toHaveBeenCalledWith(
'https://example.com/model.bin'
)
expect(downloadManagerMock.resumeDownload).toHaveBeenCalledWith(
'https://example.com/model.bin'
)
expect(downloadManagerMock.cancelDownload).toHaveBeenCalledWith(
'https://example.com/model.bin'
)
})
})

View File

@@ -6,7 +6,7 @@ import type { SystemStats } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
const mockData = vi.hoisted(() => ({ isCloud: false, isDesktop: false }))
const mockData = vi.hoisted(() => ({ isDesktop: false }))
// Mock the API
vi.mock('@/scripts/api', () => ({
@@ -19,9 +19,7 @@ vi.mock('@/platform/distribution/types', () => ({
get isDesktop() {
return mockData.isDesktop
},
get isCloud() {
return mockData.isCloud
}
isCloud: false
}))
describe('useSystemStatsStore', () => {
@@ -140,7 +138,6 @@ describe('useSystemStatsStore', () => {
describe('getFormFactor', () => {
beforeEach(() => {
// Reset systemStats for each test
mockData.isCloud = false
store.systemStats = null
})
@@ -165,12 +162,6 @@ describe('useSystemStatsStore', () => {
expect(store.getFormFactor()).toBe('other')
})
it('should return "cloud" in cloud mode', () => {
mockData.isCloud = true
expect(store.getFormFactor()).toBe('cloud')
})
describe('desktop environment', () => {
beforeEach(() => {
mockData.isDesktop = true

View File

@@ -116,33 +116,6 @@ describe('useUserFileStore', () => {
"Failed to load file 'file1.txt': 404 Not Found"
)
})
it('should skip loading temporary and already loaded files', async () => {
const temporaryFile = UserFile.createTemporary('draft.txt')
const loadedFile = new UserFile('file1.txt', 123, 100)
loadedFile.content = 'content'
loadedFile.originalContent = 'content'
await temporaryFile.load()
await loadedFile.load()
expect(api.getUserData).not.toHaveBeenCalled()
})
it('should force reload loaded files', async () => {
const file = new UserFile('file1.txt', 123, 100)
file.content = 'old'
file.originalContent = 'old'
vi.mocked(api.getUserData).mockResolvedValue({
status: 200,
text: () => Promise.resolve('new')
} as Response)
await file.load({ force: true })
expect(api.getUserData).toHaveBeenCalledWith('file1.txt')
expect(file.content).toBe('new')
})
})
describe('save', () => {
@@ -175,60 +148,6 @@ describe('useUserFileStore', () => {
expect(api.storeUserData).not.toHaveBeenCalled()
})
it('should save unmodified files when forced', async () => {
const file = new UserFile('file1.txt', 123, 100)
file.content = 'content'
file.originalContent = 'content'
vi.mocked(api.storeUserData).mockResolvedValue({
status: 200,
json: () => Promise.resolve('file1.txt')
} as Response)
await file.save({ force: true })
expect(api.storeUserData).toHaveBeenCalledWith('file1.txt', 'content', {
throwOnError: true,
full_info: true,
overwrite: true
})
expect(file.lastModified).toBe(123)
expect(file.size).toBe(100)
})
it('should normalize string modified times', async () => {
const file = new UserFile('file1.txt', 123, 100)
file.content = 'modified content'
file.originalContent = 'original content'
vi.mocked(api.storeUserData).mockResolvedValue({
status: 200,
json: () =>
Promise.resolve({ modified: '2024-01-02T03:04:05Z', size: 200 })
} as Response)
await file.save()
expect(file.lastModified).toBe(
new Date('2024-01-02T03:04:05Z').getTime()
)
expect(file.size).toBe(200)
})
it('should fall back when modified time is invalid', async () => {
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(999)
const file = new UserFile('file1.txt', 123, 100)
file.content = 'modified content'
file.originalContent = 'original content'
vi.mocked(api.storeUserData).mockResolvedValue({
status: 200,
json: () => Promise.resolve({ modified: 'bad date', size: 200 })
} as Response)
await file.save()
expect(file.lastModified).toBe(999)
dateNow.mockRestore()
})
})
describe('delete', () => {
@@ -242,26 +161,6 @@ describe('useUserFileStore', () => {
expect(api.deleteUserData).toHaveBeenCalledWith('file1.txt')
})
it('should skip deleting temporary files', async () => {
const file = UserFile.createTemporary('draft.txt')
await file.delete()
expect(api.deleteUserData).not.toHaveBeenCalled()
})
it('should throw when delete fails', async () => {
const file = new UserFile('file1.txt', 123, 100)
vi.mocked(api.deleteUserData).mockResolvedValue({
status: 500,
statusText: 'Server Error'
} as Response)
await expect(file.delete()).rejects.toThrow(
"Failed to delete file 'file1.txt': 500 Server Error"
)
})
})
describe('rename', () => {
@@ -282,41 +181,6 @@ describe('useUserFileStore', () => {
expect(file.lastModified).toBe(456)
expect(file.size).toBe(200)
})
it('should rename temporary files locally', async () => {
const file = UserFile.createTemporary('draft.txt')
await file.rename('renamed.txt')
expect(api.moveUserData).not.toHaveBeenCalled()
expect(file.path).toBe('renamed.txt')
})
it('should throw when rename fails', async () => {
const file = new UserFile('file1.txt', 123, 100)
vi.mocked(api.moveUserData).mockResolvedValue({
status: 409,
statusText: 'Conflict'
} as Response)
await expect(file.rename('newfile.txt')).rejects.toThrow(
"Failed to rename file 'file1.txt': 409 Conflict"
)
})
it('should leave metadata unchanged when rename returns a string', async () => {
const file = new UserFile('file1.txt', 123, 100)
vi.mocked(api.moveUserData).mockResolvedValue({
status: 200,
json: () => Promise.resolve('newfile.txt')
} as Response)
await file.rename('newfile.txt')
expect(file.path).toBe('newfile.txt')
expect(file.lastModified).toBe(123)
expect(file.size).toBe(100)
})
})
describe('saveAs', () => {
@@ -343,25 +207,6 @@ describe('useUserFileStore', () => {
expect(newFile.size).toBe(200)
expect(newFile.content).toBe('file content')
})
it('should save temporary files in place', async () => {
const file = UserFile.createTemporary('draft.txt')
file.content = 'file content'
vi.mocked(api.storeUserData).mockResolvedValue({
status: 200,
json: () => Promise.resolve({ modified: 456, size: 200 })
} as Response)
const newFile = await file.saveAs('newfile.txt')
expect(api.storeUserData).toHaveBeenCalledWith(
'draft.txt',
'file content',
{ throwOnError: true, full_info: true, overwrite: false }
)
expect(newFile).toBe(file)
expect(newFile.path).toBe('draft.txt')
})
})
})
})

View File

@@ -1,72 +1,61 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useUserStore } from './userStore'
const apiMock = vi.hoisted(() => ({
createUser: vi.fn(),
getUserConfig: vi.fn(),
user: undefined as string | undefined
}))
const getUserConfig = vi.fn()
vi.mock('@/scripts/api', () => ({
api: apiMock
api: {
getUserConfig: (...args: unknown[]) => getUserConfig(...args)
}
}))
describe('userStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
apiMock.createUser.mockReset()
apiMock.getUserConfig.mockReset()
apiMock.user = undefined
setActivePinia(createPinia())
getUserConfig.mockReset()
localStorage.clear()
})
describe('initialize', () => {
it('returns an empty user list before initialization', () => {
const store = useUserStore()
expect(store.users).toEqual([])
})
it('fetches user config on first call', async () => {
apiMock.getUserConfig.mockResolvedValue({})
getUserConfig.mockResolvedValue({})
const store = useUserStore()
await store.initialize()
expect(apiMock.getUserConfig).toHaveBeenCalledTimes(1)
expect(getUserConfig).toHaveBeenCalledTimes(1)
expect(store.initialized).toBe(true)
})
it('is a no-op once already initialized', async () => {
apiMock.getUserConfig.mockResolvedValue({})
getUserConfig.mockResolvedValue({})
const store = useUserStore()
await store.initialize()
apiMock.getUserConfig.mockClear()
getUserConfig.mockClear()
await store.initialize()
expect(apiMock.getUserConfig).not.toHaveBeenCalled()
expect(getUserConfig).not.toHaveBeenCalled()
})
it('retries on a subsequent call when the first fetch failed', async () => {
apiMock.getUserConfig.mockRejectedValueOnce(new Error('network down'))
apiMock.getUserConfig.mockResolvedValueOnce({})
getUserConfig.mockRejectedValueOnce(new Error('network down'))
getUserConfig.mockResolvedValueOnce({})
const store = useUserStore()
await expect(store.initialize()).rejects.toThrow('network down')
expect(store.initialized).toBe(false)
await expect(store.initialize()).resolves.toBeUndefined()
expect(apiMock.getUserConfig).toHaveBeenCalledTimes(2)
expect(getUserConfig).toHaveBeenCalledTimes(2)
expect(store.initialized).toBe(true)
})
it('deduplicates concurrent calls before the first fetch resolves', async () => {
let resolveConfig: (value: unknown) => void = () => {}
apiMock.getUserConfig.mockImplementation(
getUserConfig.mockImplementation(
() =>
new Promise((resolve) => {
resolveConfig = resolve
@@ -79,100 +68,7 @@ describe('userStore', () => {
resolveConfig({})
await Promise.all([a, b])
expect(apiMock.getUserConfig).toHaveBeenCalledTimes(1)
})
it('derives multi-user state and restores the current user from storage', async () => {
localStorage['Comfy.userId'] = 'user-2'
apiMock.getUserConfig.mockResolvedValue({
users: { 'user-1': 'Ada', 'user-2': 'Grace' }
})
const store = useUserStore()
await store.initialize()
expect(store.isMultiUserServer).toBe(true)
expect(store.needsLogin).toBe(false)
expect(store.users).toEqual([
{ userId: 'user-1', username: 'Ada' },
{ userId: 'user-2', username: 'Grace' }
])
expect(store.currentUser).toEqual({ userId: 'user-2', username: 'Grace' })
await vi.waitFor(() => expect(apiMock.user).toBe('user-2'))
})
it('requires login on multi-user servers without a stored user', async () => {
apiMock.getUserConfig.mockResolvedValue({
users: { 'user-1': 'Ada' }
})
const store = useUserStore()
await store.initialize()
expect(store.needsLogin).toBe(true)
expect(store.currentUser).toBeNull()
expect(apiMock.user).toBeUndefined()
})
})
describe('createUser', () => {
it('returns the created user id with the requested username', async () => {
apiMock.createUser.mockResolvedValue({
json: () => Promise.resolve('user-1'),
status: 201
})
const store = useUserStore()
await expect(store.createUser('Ada')).resolves.toEqual({
userId: 'user-1',
username: 'Ada'
})
})
it('throws API errors returned by user creation', async () => {
apiMock.createUser.mockResolvedValue({
json: () => Promise.resolve({ error: 'name taken' }),
status: 409,
statusText: 'Conflict'
})
const store = useUserStore()
await expect(store.createUser('Ada')).rejects.toThrow('name taken')
})
it('throws a fallback error when user creation has no error body', async () => {
apiMock.createUser.mockResolvedValue({
json: () => Promise.resolve({}),
status: 500,
statusText: 'Server Error'
})
const store = useUserStore()
await expect(store.createUser('Ada')).rejects.toThrow(
'Error creating user: 500 Server Error'
)
})
})
describe('login/logout', () => {
it('persists login identity and clears it on logout', async () => {
const store = useUserStore()
await store.login({ userId: 'user-1', username: 'Ada' })
expect(localStorage['Comfy.userId']).toBe('user-1')
expect(localStorage['Comfy.userName']).toBe('Ada')
await store.logout()
expect(localStorage['Comfy.userId']).toBeUndefined()
expect(localStorage['Comfy.userName']).toBeUndefined()
})
it('does not set api.user when login happens before user config loads', async () => {
const store = useUserStore()
await store.login({ userId: 'user-1', username: 'Ada' })
expect(apiMock.user).toBeUndefined()
expect(getUserConfig).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -5,10 +5,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import type { BottomPanelExtension } from '@/types/extensionTypes'
const { mockRegisterCommand } = vi.hoisted(() => ({
mockRegisterCommand: vi.fn()
}))
// Mock dependencies
vi.mock('@/composables/bottomPanelTabs/useShortcutsTab', () => ({
useShortcutsTab: () => [
@@ -48,7 +44,7 @@ vi.mock('@/composables/bottomPanelTabs/useTerminalTabs', () => ({
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
registerCommand: mockRegisterCommand
registerCommand: vi.fn()
})
}))
@@ -63,8 +59,6 @@ vi.mock('@/platform/distribution/types', () => ({
describe('useBottomPanelStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockRegisterCommand.mockClear()
mockData.isDesktop = false
})
it('should initialize with empty panels', () => {
@@ -92,39 +86,6 @@ describe('useBottomPanelStore', () => {
tab
)
expect(store.panels.terminal.activeTabId).toBe('test-tab')
expect(mockRegisterCommand).toHaveBeenCalledWith(
expect.objectContaining({
id: 'Workspace.ToggleBottomPanelTab.test-tab',
label: 'Toggle Test Tab Bottom Panel'
})
)
})
it('uses titleKey and id fallbacks in registered command labels', () => {
const store = useBottomPanelStore()
store.registerBottomPanelTab({
id: 'title-key-tab',
titleKey: 'panel.titleKey',
component: {},
type: 'vue'
})
store.registerBottomPanelTab({
id: 'id-fallback-tab',
component: {},
type: 'vue'
})
expect(mockRegisterCommand).toHaveBeenCalledWith(
expect.objectContaining({
label: 'Toggle panel.titleKey Bottom Panel'
})
)
expect(mockRegisterCommand).toHaveBeenCalledWith(
expect.objectContaining({
label: 'Toggle id-fallback-tab Bottom Panel'
})
)
})
it('should toggle panel visibility', () => {
@@ -153,14 +114,6 @@ describe('useBottomPanelStore', () => {
expect(store.bottomPanelVisible).toBe(false)
})
it('does not open an empty panel', () => {
const store = useBottomPanelStore()
store.togglePanel('terminal')
expect(store.activePanel).toBeNull()
})
it('should switch between panel types', () => {
const store = useBottomPanelStore()
@@ -194,31 +147,6 @@ describe('useBottomPanelStore', () => {
expect(store.activeBottomPanelTab?.id).toBe('shortcuts-tab')
})
it('sets active tab only when a panel is active', () => {
const store = useBottomPanelStore()
store.setActiveTab('missing')
expect(store.activeBottomPanelTabId).toBe('')
store.registerBottomPanelTab({
id: 'first',
title: 'First',
component: {},
type: 'vue',
targetPanel: 'shortcuts'
})
store.registerBottomPanelTab({
id: 'second',
title: 'Second',
component: {},
type: 'vue',
targetPanel: 'shortcuts'
})
store.togglePanel('shortcuts')
store.setActiveTab('second')
expect(store.activeBottomPanelTab?.id).toBe('second')
})
it('should toggle specific tabs', () => {
const store = useBottomPanelStore()
const tab: BottomPanelExtension = {
@@ -240,84 +168,4 @@ describe('useBottomPanelStore', () => {
store.toggleBottomPanelTab('specific-tab')
expect(store.activePanel).toBeNull()
})
it('ignores toggles for unknown bottom panel tabs', () => {
const store = useBottomPanelStore()
store.toggleBottomPanelTab('missing-tab')
expect(store.activePanel).toBeNull()
})
it('toggles terminal when available and shortcuts otherwise', () => {
const store = useBottomPanelStore()
const shortcutsTab: BottomPanelExtension = {
id: 'shortcuts-tab',
title: 'Shortcuts',
component: {},
type: 'vue',
targetPanel: 'shortcuts'
}
const terminalTab: BottomPanelExtension = {
id: 'terminal-tab',
title: 'Terminal',
component: {},
type: 'vue',
targetPanel: 'terminal'
}
store.registerBottomPanelTab(shortcutsTab)
store.toggleBottomPanel()
expect(store.activePanel).toBe('shortcuts')
store.registerBottomPanelTab(terminalTab)
store.toggleBottomPanel()
expect(store.activePanel).toBe('terminal')
})
it('registers extension bottom panel tabs when present', () => {
const store = useBottomPanelStore()
store.registerExtensionBottomPanelTabs({
name: 'extension',
bottomPanelTabs: [
{
id: 'extension-tab',
title: 'Extension',
component: {},
type: 'vue',
targetPanel: 'shortcuts'
}
]
})
expect(store.panels.shortcuts.tabs.map((tab) => tab.id)).toEqual([
'extension-tab'
])
})
it('ignores extensions without bottom panel tabs', () => {
const store = useBottomPanelStore()
store.registerExtensionBottomPanelTabs({ name: 'extension' })
expect(store.panels.shortcuts.tabs).toHaveLength(0)
expect(store.panels.terminal.tabs).toHaveLength(0)
})
it('registers core tabs including desktop command terminal', async () => {
mockData.isDesktop = true
const store = useBottomPanelStore()
await store.registerCoreBottomPanelTabs()
expect(store.panels.shortcuts.tabs.map((tab) => tab.id)).toEqual([
'shortcuts-essentials',
'shortcuts-view-controls'
])
expect(store.panels.terminal.tabs.map((tab) => tab.id)).toEqual([
'logs',
'command'
])
})
})

View File

@@ -1,108 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import {
CORE_COLOR_PALETTES,
DEFAULT_DARK_COLOR_PALETTE,
DEFAULT_LIGHT_COLOR_PALETTE
} from '@/constants/coreColorPalettes'
import type { Palette } from '@/schemas/colorPaletteSchema'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
function createPalette(overrides: Partial<Palette> = {}): Palette {
return {
id: 'custom',
name: 'Custom',
colors: {
node_slot: {},
litegraph_base: {},
comfy_base: {}
},
...overrides
}
}
describe('useColorPaletteStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('adds and deletes custom palettes', () => {
const store = useColorPaletteStore()
const palette = createPalette()
store.addCustomPalette(palette)
expect(store.isCustomPalette('custom')).toBe(true)
expect(store.activePaletteId).toBe('custom')
expect(store.palettesLookup.custom).toStrictEqual(palette)
store.deleteCustomPalette('custom')
expect(store.isCustomPalette('custom')).toBe(false)
expect(store.activePaletteId).toBe(CORE_COLOR_PALETTES.dark.id)
})
it('rejects duplicate and missing custom palette operations', () => {
const store = useColorPaletteStore()
expect(() =>
store.addCustomPalette(
createPalette({
id: CORE_COLOR_PALETTES.dark.id
})
)
).toThrow(`Palette with id ${CORE_COLOR_PALETTES.dark.id} already exists`)
expect(() => store.deleteCustomPalette('missing')).toThrow(
'Palette with id missing does not exist'
)
})
it('completes dark palettes and mirrors menu background when secondary is missing', () => {
const store = useColorPaletteStore()
const completed = store.completePalette(
createPalette({
colors: {
node_slot: {},
litegraph_base: {},
comfy_base: {
'comfy-menu-bg': '#101010'
}
}
})
)
expect(completed.colors.comfy_base['comfy-menu-secondary-bg']).toBe(
'#101010'
)
expect(completed.colors.node_slot.CLIP).toBe(
DEFAULT_DARK_COLOR_PALETTE.colors.node_slot.CLIP
)
})
it('completes light palettes without overwriting an existing secondary menu background', () => {
const store = useColorPaletteStore()
const completed = store.completePalette(
createPalette({
light_theme: true,
colors: {
node_slot: {},
litegraph_base: {},
comfy_base: {
'comfy-menu-bg': '#ffffff',
'comfy-menu-secondary-bg': '#eeeeee'
}
}
})
)
expect(completed.colors.comfy_base['comfy-menu-secondary-bg']).toBe(
'#eeeeee'
)
expect(completed.colors.node_slot.CLIP).toBe(
DEFAULT_LIGHT_COLOR_PALETTE.colors.node_slot.CLIP
)
})
})

View File

@@ -1,302 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { toNodeId } from '@/types/nodeId'
const { mockState } = vi.hoisted(() => ({
mockState: {
graph: null as { extra: Record<string, unknown> } | null,
nodes: {} as Record<string, unknown>,
setDirty: vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({
app: {
get rootGraph() {
return mockState.graph
}
}
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: undefined,
nodeToNodeLocatorId: (node: { id: unknown }) => String(node.id),
nodeIdToNodeLocatorId: (id: unknown) => String(id)
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: { setDirty: mockState.setDirty } })
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByLocatorId: (_graph: unknown, id: string) =>
mockState.nodes[id] ?? null
}))
vi.mock('@/utils/nodeTitleUtil', () => ({
resolveNodeDisplayName: (node: { title?: string }) => node.title ?? 'Node'
}))
vi.mock('@/i18n', () => ({
st: (_key: string, fallback: string) => fallback
}))
interface FakeWidget {
name: string
label?: string
}
function makeWidget({ name, label }: FakeWidget): IBaseWidget {
return {
name,
label,
options: {},
type: 'number',
y: 0
} as IBaseWidget
}
function makeNode(id: number, widgets: FakeWidget[] = [], title = 'My Node') {
const node = new LGraphNode(title)
node.id = toNodeId(id)
node.title = title
node.widgets = widgets.map(makeWidget)
return node
}
function registerNode(node: { id: unknown }) {
mockState.nodes[String(node.id)] = node
}
beforeEach(() => {
setActivePinia(createPinia())
mockState.graph = { extra: {} }
mockState.nodes = {}
mockState.setDirty = vi.fn()
})
describe('favoritedWidgetsStore', () => {
it('adds a favorite, marks workflow dirty, and persists to graph.extra', () => {
const store = useFavoritedWidgetsStore()
const node = makeNode(1, [{ name: 'seed' }])
registerNode(node)
store.addFavorite(node, 'seed')
expect(store.isFavorited(node, 'seed')).toBe(true)
expect(mockState.setDirty).toHaveBeenCalledWith(true, true)
expect(mockState.graph?.extra.favoritedWidgets).toEqual({
favorites: [{ nodeLocatorId: '1', widgetName: 'seed' }]
})
})
it('does not add the same favorite twice', () => {
const store = useFavoritedWidgetsStore()
const node = makeNode(1, [{ name: 'seed' }])
registerNode(node)
store.addFavorite(node, 'seed')
const persisted = structuredClone(mockState.graph?.extra.favoritedWidgets)
const dirtyCalls = mockState.setDirty.mock.calls.length
store.addFavorite(node, 'seed')
expect(store.favoritedWidgets).toHaveLength(1)
expect(mockState.graph?.extra.favoritedWidgets).toEqual(persisted)
expect(mockState.setDirty).toHaveBeenCalledTimes(dirtyCalls)
})
it('removes a favorite and treats removing an absent one as a no-op', () => {
const store = useFavoritedWidgetsStore()
const node = makeNode(1, [{ name: 'seed' }])
registerNode(node)
store.addFavorite(node, 'seed')
const persisted = structuredClone(mockState.graph?.extra.favoritedWidgets)
const dirtyCalls = mockState.setDirty.mock.calls.length
store.removeFavorite(node, 'missing')
expect(store.isFavorited(node, 'seed')).toBe(true)
expect(mockState.graph?.extra.favoritedWidgets).toEqual(persisted)
expect(mockState.setDirty).toHaveBeenCalledTimes(dirtyCalls)
store.removeFavorite(node, 'seed')
expect(store.isFavorited(node, 'seed')).toBe(false)
})
it('toggles favorite state in both directions', () => {
const store = useFavoritedWidgetsStore()
const node = makeNode(1, [{ name: 'seed' }])
registerNode(node)
store.toggleFavorite(node, 'seed')
expect(store.isFavorited(node, 'seed')).toBe(true)
store.toggleFavorite(node, 'seed')
expect(store.isFavorited(node, 'seed')).toBe(false)
})
it('resolves a valid favorite to a node/widget with a composed label', () => {
const store = useFavoritedWidgetsStore()
const node = makeNode(7, [{ name: 'cfg', label: 'CFG Scale' }], 'KSampler')
registerNode(node)
store.addFavorite(node, 'cfg')
const [resolved] = store.favoritedWidgets
expect(resolved.label).toBe('KSampler / CFG Scale')
expect(store.validFavoritedWidgets).toHaveLength(1)
})
it('labels favorites whose node was deleted and excludes them from valid', () => {
const store = useFavoritedWidgetsStore()
const node = makeNode(2, [{ name: 'seed' }])
registerNode(node)
store.addFavorite(node, 'seed')
delete mockState.nodes['2']
expect(store.favoritedWidgets[0].label).toContain('(node deleted)')
expect(store.validFavoritedWidgets).toHaveLength(0)
})
it('labels favorites whose widget no longer exists', () => {
const store = useFavoritedWidgetsStore()
const node = makeNode(3, [{ name: 'seed' }])
registerNode(node)
store.addFavorite(node, 'seed')
mockState.nodes['3'] = makeNode(3, [], 'My Node')
expect(store.favoritedWidgets[0].label).toContain('(widget not found)')
})
it('prunes invalid favorites while keeping valid ones', () => {
const store = useFavoritedWidgetsStore()
const valid = makeNode(1, [{ name: 'seed' }])
const stale = makeNode(2, [{ name: 'steps' }])
registerNode(valid)
registerNode(stale)
store.addFavorite(valid, 'seed')
store.addFavorite(stale, 'steps')
delete mockState.nodes['2']
store.pruneInvalidFavorites()
expect(store.favoritedWidgets).toHaveLength(1)
expect(store.isFavorited(valid, 'seed')).toBe(true)
})
it('reorders favorites to match the provided order', () => {
const store = useFavoritedWidgetsStore()
const a = makeNode(1, [{ name: 'seed' }])
const b = makeNode(2, [{ name: 'steps' }])
registerNode(a)
registerNode(b)
store.addFavorite(a, 'seed')
store.addFavorite(b, 'steps')
store.reorderFavorites([...store.validFavoritedWidgets].reverse())
expect(store.favoritedWidgets.map((fw) => fw.nodeLocatorId)).toEqual([
'2',
'1'
])
})
it('clears all favorites', () => {
const store = useFavoritedWidgetsStore()
const node = makeNode(1, [{ name: 'seed' }])
registerNode(node)
store.addFavorite(node, 'seed')
store.clearFavorites()
expect(store.favoritedWidgets).toHaveLength(0)
})
it('loads favorites from graph.extra on init, normalizing legacy nodeId entries', () => {
mockState.graph = {
extra: {
favoritedWidgets: {
favorites: [
{ nodeLocatorId: '1', widgetName: 'seed' },
{ nodeId: 2, widgetName: 'steps' },
{ widgetName: 'no-node' }
]
}
}
}
registerNode(makeNode(1, [{ name: 'seed' }]))
registerNode(makeNode(2, [{ name: 'steps' }]))
const store = useFavoritedWidgetsStore()
expect(store.favoritedWidgets.map((fw) => fw.nodeLocatorId)).toEqual([
'1',
'2'
])
})
it('ignores malformed favorites when loading from graph.extra', () => {
mockState.graph = {
extra: {
favoritedWidgets: {
favorites: [
{ nodeLocatorId: '1', widgetName: 'seed' },
{ nodeLocatorId: 'bad:locator', widgetName: 'bad-locator' },
{ nodeLocatorId: 42, widgetName: 'number-locator' },
{ nodeLocatorId: '2', widgetName: '' },
{ nodeId: '', widgetName: 'bad-node' },
null,
{ widgetName: 'missing-node' }
]
}
}
}
registerNode(makeNode(1, [{ name: 'seed' }]))
const store = useFavoritedWidgetsStore()
expect(store.favoritedWidgets.map((fw) => fw.nodeLocatorId)).toEqual(['1'])
})
it('loads an empty list when the graph is not available', () => {
mockState.graph = null
const store = useFavoritedWidgetsStore()
expect(store.favoritedWidgets).toHaveLength(0)
})
it('does not save when pruning already valid favorites', () => {
const store = useFavoritedWidgetsStore()
const node = makeNode(1, [{ name: 'seed' }])
registerNode(node)
store.addFavorite(node, 'seed')
const dirtyCalls = mockState.setDirty.mock.calls.length
store.pruneInvalidFavorites()
expect(store.favoritedWidgets).toHaveLength(1)
expect(mockState.setDirty).toHaveBeenCalledTimes(dirtyCalls)
})
it('labels existing favorites when the graph is not loaded', () => {
const node = makeNode(1, [{ name: 'seed' }])
registerNode(node)
const store = useFavoritedWidgetsStore()
store.addFavorite(node, 'seed')
mockState.graph = null
expect(store.favoritedWidgets[0].label).toContain('(graph not loaded)')
store.clearFavorites()
expect(store.favoritedWidgets).toHaveLength(0)
})
})

View File

@@ -5,21 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const {
mockCommands,
mockGetSetting,
mockRegisterCommand,
mockRegisterCommands,
mockT,
mockTe
} = vi.hoisted(() => ({
mockCommands: [] as Array<{ id: string; function?: () => void }>,
mockGetSetting: vi.fn(),
mockRegisterCommand: vi.fn(),
mockRegisterCommands: vi.fn(),
mockT: vi.fn((key: string) => `translated:${key}`),
mockTe: vi.fn((_key: string) => false)
}))
const { mockGetSetting, mockRegisterCommand, mockRegisterCommands } =
vi.hoisted(() => ({
mockGetSetting: vi.fn(),
mockRegisterCommand: vi.fn(),
mockRegisterCommands: vi.fn()
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
@@ -30,7 +21,7 @@ vi.mock('@/platform/settings/settingStore', () => ({
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
registerCommand: mockRegisterCommand,
commands: mockCommands
commands: []
})
}))
@@ -41,8 +32,8 @@ vi.mock('@/stores/menuItemStore', () => ({
}))
vi.mock('@/i18n', () => ({
t: mockT,
te: mockTe
t: (key: string) => key,
te: () => false
}))
vi.mock('@/composables/sidebarTabs/useAssetsSidebarTab', () => ({
@@ -105,11 +96,7 @@ vi.mock('@/platform/workflow/management/composables/useAppsSidebarTab', () => ({
describe('useSidebarTabStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockCommands.length = 0
mockGetSetting.mockReset()
mockT.mockClear()
mockTe.mockReset()
mockTe.mockReturnValue(false)
mockRegisterCommand.mockClear()
mockRegisterCommands.mockClear()
})
@@ -133,22 +120,6 @@ describe('useSidebarTabStore', () => {
expect(mockRegisterCommand).toHaveBeenCalledTimes(6)
})
it('removes the job history tab when QPO V2 is toggled off', async () => {
const qpoV2Enabled = ref(true)
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' ? qpoV2Enabled.value : undefined
)
const store = useSidebarTabStore()
store.registerCoreSidebarTabs()
expect(store.sidebarTabs[0].id).toBe('job-history')
qpoV2Enabled.value = false
await nextTick()
expect(store.sidebarTabs.map((tab) => tab.id)).not.toContain('job-history')
})
it('does not register the job history tab when QPO V2 is disabled', () => {
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
@@ -189,96 +160,4 @@ describe('useSidebarTabStore', () => {
])
expect(mockRegisterCommand).toHaveBeenCalledTimes(6)
})
it('registers command metadata and toggles a custom sidebar tab', async () => {
mockTe.mockImplementation((key: string) => key === 'custom.title')
const store = useSidebarTabStore()
store.registerSidebarTab({
id: 'custom',
title: 'custom.title',
tooltip: 'custom.tooltip',
icon: { render: () => null },
type: 'vue',
component: {}
})
const command = mockRegisterCommand.mock.calls[0][0]
expect(command.icon).toBeUndefined()
expect(command.label()).toBe('Toggle translated:custom.title Sidebar')
expect(command.tooltip).toBe('custom.tooltip')
expect(command.menubarLabel()).toBe('custom.title')
await command.function()
expect(store.activeSidebarTabId).toBe('custom')
expect(command.active()).toBe(true)
await command.function()
expect(store.activeSidebarTabId).toBeNull()
})
it('uses translated menubar labels for known core tabs', () => {
mockTe.mockImplementation((key: string) => key === 'sideToolbar.assets')
const store = useSidebarTabStore()
store.registerSidebarTab({
id: 'assets',
title: 'assets',
type: 'vue',
component: {}
})
const command = mockRegisterCommand.mock.calls[0][0]
expect(command.menubarLabel()).toBe('translated:sideToolbar.assets')
})
it('delegates model library command to BrowseModelAssets when asset API is enabled', async () => {
const browseModelAssets = vi.fn()
mockCommands.push({
id: 'Comfy.BrowseModelAssets',
function: browseModelAssets
})
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Assets.UseAssetAPI' ? true : undefined
)
const store = useSidebarTabStore()
store.registerSidebarTab({
id: 'model-library',
title: 'Models',
type: 'vue',
component: {}
})
const command = mockRegisterCommand.mock.calls[0][0]
await command.function()
expect(browseModelAssets).toHaveBeenCalledOnce()
expect(store.activeSidebarTabId).toBeNull()
})
it('destroys custom tabs and clears active state on unregister', () => {
const destroy = vi.fn()
const store = useSidebarTabStore()
store.registerSidebarTab({
id: 'custom',
title: 'Custom',
type: 'custom',
render: vi.fn(),
destroy
})
store.toggleSidebarTab('custom')
store.unregisterSidebarTab('custom')
expect(destroy).toHaveBeenCalledOnce()
expect(store.sidebarTabs).toHaveLength(0)
expect(store.activeSidebarTabId).toBeNull()
})
it('ignores unregister requests for missing tabs', () => {
const store = useSidebarTabStore()
store.unregisterSidebarTab('missing')
expect(store.sidebarTabs).toHaveLength(0)
})
})

View File

@@ -1,115 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const storeMocks = vi.hoisted(() => ({
apiKeyAuthStore: {
isAuthenticated: false
},
authStore: {
currentUser: null as null | { uid: string }
},
commandStore: {
commands: [],
execute: vi.fn()
},
executionErrorStore: {
lastExecutionError: null,
lastNodeErrors: null
},
queueSettingsStore: {},
settingStore: {
settingsById: {},
get: vi.fn(),
set: vi.fn()
},
sidebarTabStore: {
registerSidebarTab: vi.fn(),
unregisterSidebarTab: vi.fn(),
sidebarTabs: []
},
toastStore: {},
workflowStore: {}
}))
vi.mock('@vueuse/core', () => ({
useMagicKeys: () => ({ shift: false })
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => storeMocks.settingStore
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => storeMocks.toastStore
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => storeMocks.workflowStore
}))
vi.mock('@/services/colorPaletteService', () => ({
useColorPaletteService: () => ({})
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({})
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => storeMocks.apiKeyAuthStore
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => storeMocks.authStore
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => storeMocks.commandStore
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => storeMocks.executionErrorStore
}))
vi.mock('@/stores/queueStore', () => ({
useQueueSettingsStore: () => storeMocks.queueSettingsStore
}))
vi.mock('@/stores/workspace/bottomPanelStore', () => ({
useBottomPanelStore: () => ({})
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => storeMocks.sidebarTabStore
}))
describe('useWorkspaceStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
storeMocks.apiKeyAuthStore.isAuthenticated = false
storeMocks.authStore.currentUser = null
})
it('reports logged out when neither auth source is active', () => {
const store = useWorkspaceStore()
expect(store.user.isLoggedIn).toBe(false)
})
it('reports logged in for API-key auth', () => {
storeMocks.apiKeyAuthStore.isAuthenticated = true
const store = useWorkspaceStore()
expect(store.user.isLoggedIn).toBe(true)
})
it('reports logged in for Firebase auth', () => {
storeMocks.authStore.currentUser = { uid: 'user-1' }
const store = useWorkspaceStore()
expect(store.user.isLoggedIn).toBe(true)
})
})

View File

@@ -25,13 +25,15 @@ export enum ServerFeatureFlag {
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth'
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled'
}
export function useFeatureFlags() {
return {
flags: {
teamWorkspacesEnabled: true
teamWorkspacesEnabled: true,
consolidatedBillingEnabled: true
}
}
}

147
src/utils/fuseUtil.test.ts Normal file
View File

@@ -0,0 +1,147 @@
import { describe, expect, it, vi } from 'vitest'
import type { FuseSearchable } from '@/utils/fuseUtil'
import { FuseFilter, FuseSearch } from '@/utils/fuseUtil'
interface SearchItem extends Partial<FuseSearchable> {
name: string
}
interface FilterItem {
options: string[]
}
const makeSearch = <T>(data: T[] = []) =>
new FuseSearch<T>(data, {
fuseOptions: {
keys: ['name'],
includeScore: true,
threshold: 0.6,
shouldSort: false
},
advancedScoring: true
})
describe('FuseSearch', () => {
it('assigns stable ranking tiers for exact, prefix, word, substring, and multi-part matches', () => {
const search = new FuseSearch<string>([], {})
const cases = [
{ query: 'load image', item: 'load image', tier: 0 },
{ query: 'load', item: 'Load Image', tier: 1 },
{ query: 'image', item: 'LoadImage', tier: 2 },
{ query: 'cast', item: 'broadcast', tier: 3 },
{ query: 'batch latent', item: 'LatentBatch', tier: 4 },
{ query: 'ten bat', item: 'LatentBatch', tier: 5 },
{ query: 'vae', item: 'KSampler', tier: 9 }
]
for (const { query, item, tier } of cases) {
expect(search.calcAuxSingle(query, item, 0)[0]).toBe(tier)
}
})
it('penalizes deprecated non-exact matches without penalizing exact matches', () => {
const search = makeSearch<SearchItem>()
expect(
search.calcAuxScores('image', { name: 'Image Deprecated' }, 0)[0]
).toBe(6)
expect(
search.calcAuxScores('deprecated node', { name: 'Deprecated Node' }, 0)[0]
).toBe(0)
})
it('lets searchable entries post-process their auxiliary scores', () => {
const search = makeSearch<SearchItem>()
const entry: SearchItem = {
name: 'Image Loader',
postProcessSearchScores: (scores) => [scores[0] + 2, ...scores.slice(1)]
}
expect(search.calcAuxScores('image', entry, 0)[0]).toBe(3)
})
it('sorts advanced search results by auxiliary ranking instead of Fuse order', () => {
const exact = { name: 'Image' }
const prefix = { name: 'Image Loader' }
const camelCaseWord = { name: 'LoadImage' }
const substring = { name: 'PreimageNode' }
const deprecated = { name: 'Image Deprecated' }
const search = makeSearch([
substring,
deprecated,
camelCaseWord,
prefix,
exact
])
expect(search.search('image')).toEqual([
exact,
prefix,
camelCaseWord,
substring,
deprecated
])
})
it('returns data in original order for an empty query without calling Fuse', () => {
const data = [{ name: 'B' }, { name: 'A' }]
const search = makeSearch(data)
const fuseSearchSpy = vi.spyOn(search.fuse, 'search')
expect(search.search('')).toEqual(data)
expect(fuseSearchSpy).not.toHaveBeenCalled()
})
it('compares auxiliary scores by the first differing value and then length', () => {
const search = new FuseSearch<string>([], {})
expect(
[
[1, 4],
[1, 2],
[0, 99]
].sort(search.compareAux)
).toEqual([
[0, 99],
[1, 2],
[1, 4]
])
expect(
[
[1, 2, 0],
[1, 2]
].sort(search.compareAux)
).toEqual([
[1, 2],
[1, 2, 0]
])
})
})
describe('FuseFilter', () => {
it('matches single values, comma-separated values, and wildcard fallbacks', () => {
const imageItem = { options: ['IMAGE', 'LATENT'] }
const modelItem = { options: ['MODEL'] }
const filter = new FuseFilter<FilterItem, string>([imageItem, modelItem], {
id: 'type',
name: 'Type',
invokeSequence: 't',
getItemOptions: (item) => item.options
})
expect(filter.getAllNodeOptions([imageItem, modelItem, imageItem])).toEqual(
['IMAGE', 'LATENT', 'MODEL']
)
expect(filter.matches(imageItem, 'IMAGE')).toBe(true)
expect(filter.matches(imageItem, 'MODEL')).toBe(false)
expect(filter.matches(imageItem, 'MODEL,IMAGE')).toBe(true)
expect(filter.matches(modelItem, '*', { wildcard: '*' })).toBe(true)
expect(filter.matches(imageItem, 'MODEL', { wildcard: 'IMAGE' })).toBe(true)
expect(filter.matches(modelItem, 'MODEL', { wildcard: 'IMAGE' })).toBe(
false
)
})
})

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import {
clampRectToBounds,
computeUnionBounds,
denormalize,
gcd,
@@ -137,4 +138,29 @@ describe('mathUtil', () => {
expect(result!.height).toBe(242)
})
})
describe('clampRectToBounds', () => {
const bounds = { left: 0, top: 0, right: 100, bottom: 100 }
it('returns the rect unchanged when fully inside the bounds', () => {
expect(
clampRectToBounds({ left: 10, top: 10, right: 40, bottom: 40 }, bounds)
).toEqual({ left: 10, top: 10, right: 40, bottom: 40 })
})
it('clamps every edge that extends past the bounds', () => {
expect(
clampRectToBounds(
{ left: -20, top: -10, right: 150, bottom: 130 },
bounds
)
).toEqual({ left: 0, top: 0, right: 100, bottom: 100 })
})
it('clamps only the overflowing side', () => {
expect(
clampRectToBounds({ left: 10, top: 10, right: 200, bottom: 40 }, bounds)
).toEqual({ left: 10, top: 10, right: 100, bottom: 40 })
})
})
})

View File

@@ -1,6 +1,27 @@
import { clamp } from 'es-toolkit/math'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import type { Bounds } from '@/renderer/core/layout/types'
/** A rectangle's viewport edges: the DOMRect subset, so a DOMRect is directly assignable. */
export type RectEdges = Pick<DOMRect, 'left' | 'top' | 'right' | 'bottom'>
/**
* Clamps a rectangle so every edge stays within `bounds`. Both the rect and the
* bounds use viewport-style edges (left/top/right/bottom), e.g. a DOMRect.
*/
export function clampRectToBounds(
rect: RectEdges,
bounds: RectEdges
): RectEdges {
return {
left: clamp(rect.left, bounds.left, bounds.right),
top: clamp(rect.top, bounds.top, bounds.bottom),
right: clamp(rect.right, bounds.left, bounds.right),
bottom: clamp(rect.bottom, bounds.top, bounds.bottom)
}
}
/**
* Linearly maps a value from [min, max] to [0, 1].
* Returns 0 when min equals max to avoid division by zero.

View File

@@ -0,0 +1,227 @@
import { describe, expect, it } from 'vitest'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { JobState } from '@/types/queue'
import type { BuildJobDisplayCtx } from '@/utils/queueDisplay'
import { buildJobDisplay, iconForJobState } from '@/utils/queueDisplay'
type QueueDisplayTask = Parameters<typeof buildJobDisplay>[0]
type PreviewOutput = NonNullable<QueueDisplayTask['previewOutput']>
function createJob(
status: JobListItem['status'],
overrides: Partial<JobListItem> = {}
): JobListItem {
return {
id: 'job-123456',
status,
create_time: 1_710_000_000_000,
priority: 12,
...overrides
}
}
function createTask({
job,
jobId = 'job-123456',
createTime = 1_710_000_000_000,
executionTime,
executionTimeInSeconds,
previewOutput
}: {
job?: Partial<JobListItem>
jobId?: string
createTime?: number
executionTime?: number
executionTimeInSeconds?: number
previewOutput?: PreviewOutput
} = {}): QueueDisplayTask {
return {
job: createJob(job?.status ?? 'pending', job),
jobId,
createTime,
executionTime,
executionTimeInSeconds,
previewOutput
} as QueueDisplayTask
}
function createCtx(
overrides: Partial<BuildJobDisplayCtx> = {}
): BuildJobDisplayCtx {
return {
t: (key, values) => {
const entries = Object.entries(values ?? {})
if (!entries.length) return key
return `${key}(${entries
.map(([name, value]) => `${name}=${String(value)}`)
.join(',')})`
},
locale: 'en-US',
formatClockTimeFn: (ts, locale) => `${locale}:${ts}`,
isActive: false,
...overrides
}
}
describe('iconForJobState', () => {
it.for<[JobState, string]>([
['pending', 'icon-[lucide--loader-circle]'],
['initialization', 'icon-[lucide--server-crash]'],
['running', 'icon-[lucide--zap]'],
['completed', 'icon-[lucide--check-check]'],
['failed', 'icon-[lucide--alert-circle]']
])('maps %s to its icon', ([state, icon]) => {
expect(iconForJobState(state)).toBe(icon)
})
})
describe('buildJobDisplay', () => {
it('shows the added hint for pending jobs when requested', () => {
expect(
buildJobDisplay(
createTask(),
'pending',
createCtx({ showAddedHint: true })
)
).toEqual({
iconName: 'icon-[lucide--check]',
primary: 'queue.jobAddedToQueue',
secondary: 'en-US:1710000000000',
showClear: true
})
})
it('shows queued time for pending and initializing jobs', () => {
expect(buildJobDisplay(createTask(), 'pending', createCtx())).toMatchObject(
{
iconName: 'icon-[lucide--loader-circle]',
primary: 'queue.inQueue',
secondary: 'en-US:1710000000000',
showClear: true
}
)
expect(
buildJobDisplay(createTask(), 'initialization', createCtx())
).toMatchObject({
iconName: 'icon-[lucide--server-crash]',
primary: 'queue.initializingAlmostReady',
secondary: 'en-US:1710000000000',
showClear: true
})
})
it('formats active running progress from the injected context', () => {
expect(
buildJobDisplay(
createTask({ job: { status: 'in_progress' } }),
'running',
createCtx({
isActive: true,
totalPercent: 42.7,
currentNodePercent: -10,
currentNodeName: 'KSampler'
})
)
).toEqual({
iconName: 'icon-[lucide--zap]',
primary: 'sideToolbar.queueProgressOverlay.total(percent=43%)',
secondary:
'KSampler sideToolbar.queueProgressOverlay.colonPercent(percent=0%)',
showClear: true
})
})
it('uses a compact running label when the job is not active', () => {
expect(
buildJobDisplay(
createTask({ job: { status: 'in_progress' } }),
'running',
createCtx()
)
).toEqual({
iconName: 'icon-[lucide--zap]',
primary: 'g.running',
secondary: '',
showClear: true
})
})
it('shows local completed jobs as the preview filename', () => {
expect(
buildJobDisplay(
createTask({
job: {
status: 'completed'
},
executionTimeInSeconds: 3.51,
previewOutput: {
filename: 'preview.png',
isImage: true,
url: '/api/view?filename=preview.png&type=output&subfolder='
} as PreviewOutput
}),
'completed',
createCtx()
)
).toEqual({
iconName: 'icon-[lucide--check-check]',
iconImageUrl: '/api/view?filename=preview.png&type=output&subfolder=',
primary: 'preview.png',
secondary: '3.51s',
showClear: false
})
})
it('shows cloud completed jobs as elapsed time', () => {
expect(
buildJobDisplay(
createTask({
job: {
status: 'completed'
},
executionTime: 64_000,
executionTimeInSeconds: 64
}),
'completed',
createCtx({ isCloud: true })
)
).toMatchObject({
iconName: 'icon-[lucide--check-check]',
primary: 'queue.completedIn(duration=1m 4s)',
secondary: '64.00s',
showClear: false
})
})
it('falls back to job title for completed jobs without a preview filename', () => {
expect(
buildJobDisplay(
createTask({
job: {
status: 'completed',
priority: 42
}
}),
'completed',
createCtx()
)
).toMatchObject({
iconName: 'icon-[lucide--check-check]',
primary: 'g.job #42',
secondary: '',
showClear: false
})
})
it('shows failed jobs as clearable failures', () => {
expect(buildJobDisplay(createTask(), 'failed', createCtx())).toEqual({
iconName: 'icon-[lucide--alert-circle]',
primary: 'g.failed',
secondary: 'g.failed',
showClear: true
})
})
})