Compare commits

..

14 Commits

Author SHA1 Message Date
jaeone94
41a6192b35 test: simplify TemplateHelper route mocks 2026-07-03 17:11:26 +09:00
jaeone94
1cb83f711a Merge branch 'main' into jaeone/template-helper-route-cleanup 2026-07-03 10:14:23 +09: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
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
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
jaeone94
722be415e1 Merge branch 'main' into jaeone/template-helper-route-cleanup 2026-06-25 20:30:24 +09:00
jaeone94
84f97165d6 test: clean up template helper routes 2026-06-20 03:06:59 +09:00
61 changed files with 1669 additions and 2175 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

@@ -6,6 +6,10 @@ import type {
} from '@/platform/workflow/templates/types/template'
import { mockTemplateIndex } from '@e2e/fixtures/data/templateFixtures'
const ROUTE_PATTERN_WORKFLOW_TEMPLATES = /\/api\/workflow_templates(?:\?.*)?$/
const ROUTE_PATTERN_TEMPLATE_INDEX = /\/templates\/index\.json(?:\?.*)?$/
const ROUTE_PATTERN_TEMPLATE_THUMBNAILS = /\/templates\/.*\.webp(?:\?.*)?$/
interface TemplateConfig {
readonly templates: readonly TemplateInfo[]
readonly index: readonly WorkflowTemplates[] | null
@@ -41,10 +45,6 @@ export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
export class TemplateHelper {
private templates: TemplateInfo[]
private index: WorkflowTemplates[] | null
private routeHandlers: Array<{
pattern: string
handler: (route: Route) => Promise<void>
}> = []
constructor(
private readonly page: Page,
@@ -64,29 +64,30 @@ export class TemplateHelper {
}
async mock(): Promise<void> {
await this.mockCustomTemplates()
await this.mockIndex()
await this.mockThumbnails()
}
async mockIndex(): Promise<void> {
async mockCustomTemplates(): Promise<void> {
const customTemplatesHandler = async (route: Route) => {
const customTemplates: Record<string, string[]> = {}
await route.fulfill({
status: 200,
body: JSON.stringify(customTemplates),
body: '{}',
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
}
const customTemplatesPattern = '**/api/workflow_templates'
this.routeHandlers.push({
pattern: customTemplatesPattern,
handler: customTemplatesHandler
})
await this.page.route(customTemplatesPattern, customTemplatesHandler)
await this.page.route(
ROUTE_PATTERN_WORKFLOW_TEMPLATES,
customTemplatesHandler
)
}
async mockIndex(): Promise<void> {
const indexHandler = async (route: Route) => {
const payload = this.index ?? mockTemplateIndex(this.templates)
await route.fulfill({
@@ -98,9 +99,8 @@ export class TemplateHelper {
}
})
}
const indexPattern = '**/templates/index.json'
this.routeHandlers.push({ pattern: indexPattern, handler: indexHandler })
await this.page.route(indexPattern, indexHandler)
await this.page.route(ROUTE_PATTERN_TEMPLATE_INDEX, indexHandler)
}
async mockThumbnails(): Promise<void> {
@@ -114,12 +114,8 @@ export class TemplateHelper {
}
})
}
const thumbnailPattern = '**/templates/**.webp'
this.routeHandlers.push({
pattern: thumbnailPattern,
handler: thumbnailHandler
})
await this.page.route(thumbnailPattern, thumbnailHandler)
await this.page.route(ROUTE_PATTERN_TEMPLATE_THUMBNAILS, thumbnailHandler)
}
getTemplates(): TemplateInfo[] {
@@ -129,15 +125,6 @@ export class TemplateHelper {
get templateCount(): number {
return this.templates.length
}
async clearMocks(): Promise<void> {
for (const { pattern, handler } of this.routeHandlers) {
await this.page.unroute(pattern, handler)
}
this.routeHandlers = []
this.templates = []
this.index = null
}
}
export function createTemplateHelper(

View File

@@ -7,10 +7,6 @@ export const templateApiFixture = base.extend<{
templateApi: TemplateHelper
}>({
templateApi: async ({ page }, use) => {
const templateApi = createTemplateHelper(page)
await use(templateApi)
await templateApi.clearMocks()
await use(createTemplateHelper(page))
}
})

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

@@ -17,7 +17,7 @@ test.describe(
'Template distribution filtering count',
{ tag: '@cloud' },
() => {
test.beforeEach(async ({ comfyPage, templateApi }) => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
await comfyPage.settings.setSetting(
'Comfy.Templates.SelectedUseCases',
@@ -25,8 +25,6 @@ test.describe(
)
await comfyPage.settings.setSetting('Comfy.Templates.SelectedRunsOn', [])
await comfyPage.settings.setSetting('Comfy.Templates.SortBy', 'default')
await templateApi.mockThumbnails()
})
test('displayed count matches visible cards when distribution filter excludes templates', async ({
@@ -56,7 +54,7 @@ test.describe(
})
])
)
await templateApi.mockIndex()
await templateApi.mock()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -101,7 +99,7 @@ test.describe(
})
])
)
await templateApi.mockIndex()
await templateApi.mock()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -143,7 +141,7 @@ test.describe(
})
])
)
await templateApi.mockIndex()
await templateApi.mock()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -184,7 +182,7 @@ test.describe(
})
])
)
await templateApi.mockIndex()
await templateApi.mock()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -222,7 +220,7 @@ test.describe(
})
])
)
await templateApi.mockIndex()
await templateApi.mock()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()

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

@@ -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,132 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { createApp, h, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useJobActions } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { TaskItemImpl } from '@/stores/queueStore'
import type { Ref } from 'vue'
const { cancelJob, removeFailedJob, wrapWithErrorHandlingAsync } = vi.hoisted(
() => ({
cancelJob: vi.fn(),
removeFailedJob: vi.fn(),
wrapWithErrorHandlingAsync: vi.fn(
<T extends (...args: never[]) => Promise<unknown>>(fn: T) => fn
)
})
)
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({ wrapWithErrorHandlingAsync })
}))
vi.mock('@/composables/queue/useJobMenu', () => ({
useJobMenu: () => ({ cancelJob, removeFailedJob })
}))
function mountJobActions(job: Ref<JobListItem | null | undefined>) {
let result: ReturnType<typeof useJobActions> | undefined
const app = createApp({
setup() {
result = useJobActions(job)
return () => h('div')
}
})
app.use(
createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
)
app.mount(document.createElement('div'))
if (!result) throw new Error('useJobActions did not initialize')
return {
result,
unmount: () => app.unmount()
}
}
function job(overrides: Partial<JobListItem> = {}): JobListItem {
return {
id: 'job-1',
title: 'Job 1',
meta: '',
state: 'pending',
...overrides
}
}
beforeEach(() => {
cancelJob.mockReset().mockResolvedValue(undefined)
removeFailedJob.mockReset().mockResolvedValue(undefined)
wrapWithErrorHandlingAsync.mockClear()
})
describe('useJobActions', () => {
it('exposes localized action metadata', () => {
const { result, unmount } = mountJobActions(ref(job()))
expect(result.cancelAction).toMatchObject({
icon: 'icon-[lucide--x]',
label: 'sideToolbar.queueProgressOverlay.cancelJobTooltip',
variant: 'destructive'
})
expect(result.deleteAction).toMatchObject({
icon: 'icon-[lucide--circle-minus]',
label: 'queue.jobMenu.removeJob',
variant: 'destructive'
})
expect(wrapWithErrorHandlingAsync).toHaveBeenCalledTimes(2)
unmount()
})
it('cancels active jobs unless clearing is hidden', async () => {
const currentJob = ref(job({ state: 'running' }))
const { result, unmount } = mountJobActions(currentJob)
expect(result.canCancelJob.value).toBe(true)
await result.runCancelJob()
expect(cancelJob).toHaveBeenCalledWith(currentJob.value)
currentJob.value = job({ state: 'pending', showClear: false })
expect(result.canCancelJob.value).toBe(false)
unmount()
})
it('ignores cancel and delete requests without a current job or task', async () => {
const currentJob = ref<JobListItem | null>(null)
const { result, unmount } = mountJobActions(currentJob)
expect(result.canCancelJob.value).toBe(false)
expect(result.canDeleteJob.value).toBe(false)
await result.runCancelJob()
await result.runDeleteJob()
currentJob.value = job({ state: 'failed' })
await result.runDeleteJob()
expect(cancelJob).not.toHaveBeenCalled()
expect(removeFailedJob).not.toHaveBeenCalled()
unmount()
})
it('removes failed jobs through their queue task', async () => {
const task = fromPartial<TaskItemImpl>({ job: { id: 'prompt-1' } })
const { result, unmount } = mountJobActions(
ref(job({ state: 'failed', taskRef: task }))
)
expect(result.canDeleteJob.value).toBe(true)
await result.runDeleteJob()
expect(removeFailedJob).toHaveBeenCalledWith(task)
unmount()
})
})

View File

@@ -280,20 +280,6 @@ describe('useJobMenu', () => {
expect(copyToClipboardMock).not.toHaveBeenCalled()
})
it('uses an empty menu with the default current item getter', async () => {
const { jobMenuEntries, openJobWorkflow, copyJobId, cancelJob } =
useJobMenu()
await openJobWorkflow()
await copyJobId()
await cancelJob()
expect(jobMenuEntries.value).toEqual([])
expect(getJobWorkflowMock).not.toHaveBeenCalled()
expect(copyToClipboardMock).not.toHaveBeenCalled()
expect(queueStoreMock.update).not.toHaveBeenCalled()
})
it.for([['running'], ['initialization'], ['pending']])(
'cancels %s job via the state-agnostic jobs-namespace endpoint',
async ([state]) => {
@@ -407,26 +393,6 @@ describe('useJobMenu', () => {
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
})
it('ignores failed report action when item disappears before click', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: {
errorMessage: 'Job failed with error'
} as Partial<TaskItemImpl>
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
setCurrentItem(null)
await entry?.onClick?.()
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
expect(dialogServiceMock.showErrorDialog).not.toHaveBeenCalled()
})
it('ignores error actions when message missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
@@ -578,74 +544,6 @@ describe('useJobMenu', () => {
expect(createAnnotatedPathMock).not.toHaveBeenCalled()
})
it('uses no root folder when preview type is not an API result type', async () => {
const node = {
widgets: [{ name: 'image', value: null, callback: vi.fn() }],
graph: { setDirtyCanvas: vi.fn() }
}
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(node)
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {
previewOutput: {
isImage: true,
filename: 'foo.png',
subfolder: 'bar',
type: 'archive',
url: 'http://asset'
}
}
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
await entry?.onClick?.()
expect(createAnnotatedPathMock).toHaveBeenCalledWith(
{
filename: 'foo.png',
subfolder: 'bar',
type: undefined
},
{ rootFolder: undefined },
undefined
)
expect(node.graph.setDirtyCanvas).toHaveBeenCalledWith(true, true)
})
it('marks graph dirty when created loader node has no matching widget', async () => {
const node = {
widgets: [{ name: 'other', value: null, callback: vi.fn() }],
graph: { setDirtyCanvas: vi.fn() }
}
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(node)
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {
previewOutput: {
isImage: true,
filename: 'foo.png',
subfolder: '',
type: 'output'
}
}
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
await entry?.onClick?.()
expect(node.widgets[0].value).toBeNull()
expect(node.widgets[0].callback).not.toHaveBeenCalled()
expect(node.graph.setDirtyCanvas).toHaveBeenCalledWith(true, true)
})
it('ignores add-to-current entry when preview missing entirely', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
@@ -696,45 +594,6 @@ describe('useJobMenu', () => {
expect(downloadFileMock).not.toHaveBeenCalled()
})
it('ignores completed asset actions when item disappears before click', async () => {
const inspectSpy = vi.fn()
const { jobMenuEntries } = mountJobMenu(inspectSpy)
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {
previewOutput: {
isImage: true,
filename: 'foo.png',
subfolder: '',
type: 'output',
url: 'https://asset'
}
}
})
)
await nextTick()
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
const addEntry = findActionEntry(jobMenuEntries.value, 'add-to-current')
const downloadEntry = findActionEntry(jobMenuEntries.value, 'download')
const exportEntry = findActionEntry(jobMenuEntries.value, 'export-workflow')
const deleteEntry = findActionEntry(jobMenuEntries.value, 'delete')
setCurrentItem(null)
void inspectEntry?.onClick?.()
await addEntry?.onClick?.()
void downloadEntry?.onClick?.()
await exportEntry?.onClick?.()
await deleteEntry?.onClick?.()
expect(inspectSpy).not.toHaveBeenCalled()
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
expect(downloadFileMock).not.toHaveBeenCalled()
expect(getJobWorkflowMock).not.toHaveBeenCalled()
expect(mediaAssetActionsMock.deleteAssets).not.toHaveBeenCalled()
})
it('exports workflow with default filename when prompting disabled', async () => {
const workflow = { foo: 'bar' }
getJobWorkflowMock.mockResolvedValue(workflow)
@@ -759,17 +618,6 @@ describe('useJobMenu', () => {
)
})
it('does not export workflow when workflow data is unavailable', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'completed' }))
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
await entry?.onClick?.()
expect(downloadBlobMock).not.toHaveBeenCalled()
})
it('prompts for filename when setting enabled', async () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('custom-name')
@@ -865,24 +713,6 @@ describe('useJobMenu', () => {
expect(queueStoreMock.update).not.toHaveBeenCalled()
})
it('does not delete asset when preview disappears before click', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'delete')
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} }))
await entry?.onClick?.()
expect(mediaAssetActionsMock.deleteAssets).not.toHaveBeenCalled()
expect(queueStoreMock.update).not.toHaveBeenCalled()
})
it('removes failed job via menu entry', async () => {
const taskRef = { id: 'task-1' }
const { jobMenuEntries } = mountJobMenu()

View File

@@ -79,10 +79,12 @@ describe(useQueueNotificationBanners, () => {
isImage?: boolean
} = {}
): MockTask => {
const { state = 'Completed', previewUrl, isImage = true } = options
// Only default the timestamp when the caller omitted the key, so an
// explicit `ts: undefined` really produces a task without a timestamp.
const ts = 'ts' in options ? options.ts : Date.now()
const {
state = 'Completed',
ts = Date.now(),
previewUrl,
isImage = true
} = options
const task: MockTask = {
displayStatus: state,
@@ -184,75 +186,6 @@ describe(useQueueNotificationBanners, () => {
}
})
it('converts a queued-pending notification waiting behind the active one', async () => {
const { unmount, composable } = mountComposable()
try {
mockApi.dispatchEvent(
new CustomEvent('promptQueued', {
detail: { requestId: 1, batchCount: 1 }
})
)
await nextTick()
mockApi.dispatchEvent(
new CustomEvent('promptQueueing', {
detail: { requestId: 2, batchCount: 3 }
})
)
mockApi.dispatchEvent(
new CustomEvent('promptQueued', {
detail: { requestId: 2, batchCount: 5 }
})
)
await nextTick()
expect(composable.currentNotification.value).toEqual({
type: 'queued',
count: 1,
requestId: 1
})
await vi.advanceTimersByTimeAsync(4000)
await nextTick()
expect(composable.currentNotification.value).toEqual({
type: 'queued',
count: 5,
requestId: 2
})
} finally {
unmount()
}
})
it('converts queued-pending notifications without request ids', async () => {
const { unmount, composable } = mountComposable()
try {
mockApi.dispatchEvent(
new CustomEvent('promptQueueing', {
detail: { batchCount: 2 }
})
)
await nextTick()
mockApi.dispatchEvent(
new CustomEvent('promptQueued', {
detail: { batchCount: 3 }
})
)
await nextTick()
expect(composable.currentNotification.value).toEqual({
type: 'queued',
count: 3
})
} finally {
unmount()
}
})
it('falls back to 1 when queued batch count is invalid', async () => {
const { unmount, composable } = mountComposable()
@@ -373,64 +306,6 @@ describe(useQueueNotificationBanners, () => {
}
})
it('shows failed notifications for failed-only batches', async () => {
const { unmount, composable } = mountComposable()
try {
await runBatch({
start: 5_000,
finish: 5_200,
tasks: [createTask({ state: 'Failed', ts: 5_050 })]
})
expect(composable.currentNotification.value).toEqual({
type: 'failed',
count: 1
})
} finally {
unmount()
}
})
it('does not notify for old or unfinished history entries', async () => {
const { unmount, composable } = mountComposable()
try {
await runBatch({
start: 6_000,
finish: 6_200,
tasks: [
createTask({ ts: 5_999 }),
createTask({ state: 'Running', ts: 6_050 }),
createTask({ state: 'Pending', ts: undefined })
]
})
expect(composable.currentNotification.value).toBeNull()
} finally {
unmount()
}
})
it('keeps no notification visible when an idle window has no finished tasks', async () => {
const { unmount, composable } = mountComposable()
try {
vi.setSystemTime(7_000)
executionStore().isIdle = false
await nextTick()
vi.setSystemTime(7_100)
executionStore().isIdle = true
queueStore().historyTasks = []
await nextTick()
expect(composable.currentNotification.value).toBeNull()
} finally {
unmount()
}
})
it('uses up to two completion thumbnails for notification icon previews', async () => {
const { unmount, composable } = mountComposable()

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

@@ -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

@@ -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

@@ -1,120 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useExecutionStore } from '@/stores/executionStore'
const { handlers, openSet } = vi.hoisted(() => ({
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
openSet: new Set<unknown>()
}))
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
handlers[name] = fn
},
removeEventListener: () => {}
}
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
isOpen: (workflow: unknown) => openSet.has(workflow),
openWorkflows: [],
nodeLocatorIdToNodeExecutionId: () => null
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: undefined })
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => ({
clearExecutionStartErrors: () => {},
clearPromptError: () => {}
})
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
}))
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
vi.mock('@/utils/appMode', () => ({
getWorkflowMode: () => 'workflow',
isAppModeValue: () => false
}))
function workflow(path: string): ComfyWorkflow {
return { path } as unknown as ComfyWorkflow
}
function setup() {
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
function promptOutput(): ComfyApiWorkflow {
return {}
}
function startJob(
store: ReturnType<typeof useExecutionStore>,
id: string,
wf: ComfyWorkflow,
nodes: string[] = []
) {
openSet.add(wf)
store.storeJob({ nodes, id, promptOutput: promptOutput(), workflow: wf })
handlers['execution_start']?.({ detail: { prompt_id: id } })
}
beforeEach(() => {
setActivePinia(createPinia())
for (const key of Object.keys(handlers)) delete handlers[key]
openSet.clear()
})
describe('executionStore interrupt and cached', () => {
it('drops the workflow badge and goes idle on interruption', () => {
const store = setup()
const wf = workflow('a.json')
startJob(store, 'job-1', wf)
expect(store.getWorkflowStatus(wf)).toBe('running')
handlers['execution_interrupted']?.({ detail: { prompt_id: 'job-1' } })
expect(store.getWorkflowStatus(wf)).toBeUndefined()
expect(store.isIdle).toBe(true)
})
it('ends the active job when executing resolves to null', () => {
const store = setup()
startJob(store, 'job-2', workflow('b.json'))
expect(store.isIdle).toBe(false)
handlers['executing']?.({ detail: null })
expect(store.isIdle).toBe(true)
})
it('marks cached nodes as executed', () => {
const store = setup()
startJob(store, 'job-3', workflow('c.json'), ['a', 'b', 'c'])
expect(store.nodesExecuted).toBe(0)
handlers['execution_cached']?.({
detail: { prompt_id: 'job-3', nodes: ['a', 'b'] }
})
expect(store.nodesExecuted).toBe(2)
})
})

View File

@@ -1,119 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useExecutionStore } from '@/stores/executionStore'
const { handlers } = vi.hoisted(() => ({
handlers: {} as Record<string, (e: { detail: unknown }) => void>
}))
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
handlers[name] = fn
},
removeEventListener: () => {}
}
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
isOpen: () => false,
openWorkflows: [],
nodeLocatorIdToNodeExecutionId: () => null
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: undefined })
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => ({
clearExecutionStartErrors: () => {},
clearPromptError: () => {}
})
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
}))
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
vi.mock('@/utils/appMode', () => ({
getWorkflowMode: () => 'workflow',
isAppModeValue: () => false
}))
function setup() {
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
function promptOutput(): ComfyApiWorkflow {
return {}
}
function startJob(
store: ReturnType<typeof useExecutionStore>,
id: string,
nodes: string[]
) {
store.storeJob({
nodes,
id,
promptOutput: promptOutput(),
workflow: { path: `${id}.json` } as unknown as ComfyWorkflow
})
handlers['execution_start']?.({ detail: { prompt_id: id } })
}
beforeEach(() => {
setActivePinia(createPinia())
for (const key of Object.keys(handlers)) delete handlers[key]
})
describe('executionStore execution lifecycle', () => {
it('reports zero progress while idle', () => {
const store = setup()
expect(store.totalNodesToExecute).toBe(0)
expect(store.nodesExecuted).toBe(0)
expect(store.executionProgress).toBe(0)
})
it('counts the queued nodes once a job starts', () => {
const store = setup()
startJob(store, 'job-1', ['a', 'b', 'c'])
expect(store.totalNodesToExecute).toBe(3)
expect(store.nodesExecuted).toBe(0)
expect(store.executionProgress).toBe(0)
})
it('advances progress as executed events arrive', () => {
const store = setup()
startJob(store, 'job-1', ['a', 'b', 'c'])
handlers['executed']?.({ detail: { node: 'a' } })
expect(store.nodesExecuted).toBe(1)
expect(store.executionProgress).toBeCloseTo(1 / 3)
handlers['executed']?.({ detail: { node: 'b' } })
handlers['executed']?.({ detail: { node: 'c' } })
expect(store.nodesExecuted).toBe(3)
expect(store.executionProgress).toBe(1)
})
it('ignores executed events when there is no active job', () => {
const store = setup()
handlers['executed']?.({ detail: { node: 'a' } })
expect(store.nodesExecuted).toBe(0)
})
})

View File

@@ -1,129 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { NodeProgressState } from '@/schemas/apiSchema'
import { useExecutionStore } from '@/stores/executionStore'
const { handlers } = vi.hoisted(() => ({
handlers: {} as Record<string, (e: { detail: unknown }) => void>
}))
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
handlers[name] = fn
},
removeEventListener: () => {}
}
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
isOpen: () => false,
openWorkflows: [],
nodeLocatorIdToNodeExecutionId: () => null
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: undefined })
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => ({
clearExecutionStartErrors: () => {},
clearPromptError: () => {}
})
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({ revokePreviewsByExecutionId: () => {} })
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
}))
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
vi.mock('@/utils/appMode', () => ({
getWorkflowMode: () => 'workflow',
isAppModeValue: () => false
}))
// Derive from the real schema type so the test shape can't drift; keep the
// non-essential fields optional so cases only spell out what they assert on.
type NodeState = Partial<NodeProgressState> & Pick<NodeProgressState, 'state'>
function progressState(jobId: string, nodes: Record<string, NodeState>) {
handlers['progress_state']?.({ detail: { prompt_id: jobId, nodes } })
}
function setup() {
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
beforeEach(() => {
setActivePinia(createPinia())
for (const key of Object.keys(handlers)) delete handlers[key]
})
describe('executionStore node progress', () => {
it('is idle until an execution starts', () => {
const store = setup()
expect(store.isIdle).toBe(true)
handlers['execution_start']?.({ detail: { prompt_id: 'job-1' } })
expect(store.isIdle).toBe(false)
})
it('derives the running node ids from a progress_state event', () => {
const store = setup()
progressState('job-1', {
n1: { state: 'running', value: 1, max: 4 },
n2: { state: 'finished' },
n3: { state: 'pending' }
})
expect(store.executingNodeIds).toEqual(['n1'])
expect(store.executingNodeId).toBe('n1')
})
it('exposes fractional progress for the executing node', () => {
const store = setup()
progressState('job-1', {
n1: { state: 'running', value: 1, max: 4 }
})
expect(store.executingNodeProgress).toBe(0.25)
})
it('reports no executing node when none are running', () => {
const store = setup()
progressState('job-1', {
n1: { state: 'finished' },
n2: { state: 'pending' }
})
expect(store.executingNodeIds).toEqual([])
expect(store.executingNodeId).toBeNull()
})
it('replaces progress state on each progress_state event', () => {
const store = setup()
progressState('job-1', { n1: { state: 'running', value: 1, max: 4 } })
expect(store.executingNodeId).toBe('n1')
progressState('job-1', { n2: { state: 'running', value: 2, max: 2 } })
expect(store.executingNodeIds).toEqual(['n2'])
})
})

View File

@@ -1,173 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useExecutionStore } from '@/stores/executionStore'
import type { classifyCloudValidationError } from '@/utils/executionErrorUtil'
type CloudValidationResult = ReturnType<typeof classifyCloudValidationError>
const { handlers, errorStore, activeWorkflow, dist, classifyCloud } =
vi.hoisted(() => ({
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
errorStore: {
clearExecutionStartErrors: () => {},
clearPromptError: () => {}
} as Record<string, unknown>,
activeWorkflow: { value: null as { path: string } | null },
dist: { isCloud: false },
classifyCloud: vi.fn<(_: string) => CloudValidationResult>(() => null)
}))
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
handlers[name] = fn
},
removeEventListener: () => {}
}
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
isOpen: () => true,
openWorkflows: [],
nodeLocatorIdToNodeExecutionId: () => null,
get activeWorkflow() {
return activeWorkflow.value
}
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: undefined })
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => errorStore
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({ revokePreviewsByExecutionId: () => {} })
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
}))
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
vi.mock('@/utils/appMode', () => ({
getWorkflowMode: () => 'workflow',
isAppModeValue: () => false
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return dist.isCloud
}
}))
vi.mock('@/platform/errorCatalog/accountPreconditionRouting', () => ({
resolveAccountPrecondition: () => null
}))
vi.mock('@/utils/executionErrorUtil', () => ({
classifyCloudValidationError: classifyCloud
}))
function setup() {
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
function workflow(path: string): ComfyWorkflow {
return { path } as unknown as ComfyWorkflow
}
function promptOutput(): ComfyApiWorkflow {
return {}
}
beforeEach(() => {
setActivePinia(createPinia())
for (const key of Object.keys(handlers)) delete handlers[key]
activeWorkflow.value = null
dist.isCloud = false
classifyCloud.mockReturnValue(null)
for (const k of ['lastPromptError', 'lastNodeErrors', 'lastExecutionError'])
delete errorStore[k]
})
describe('executionStore running state and error edges', () => {
it('lists jobs with a running node and counts running workflows', () => {
const store = setup()
handlers['progress_state']?.({
detail: {
prompt_id: 'job-1',
nodes: { n1: { state: 'running', value: 1, max: 2 } }
}
})
expect(store.runningJobIds).toEqual(['job-1'])
expect(store.runningWorkflowCount).toBe(1)
})
it('does not report the active workflow as running when the path differs', () => {
const store = setup()
expect(store.isActiveWorkflowRunning).toBe(false)
const wf = workflow('w.json')
activeWorkflow.value = { path: 'other.json' }
store.storeJob({
nodes: [],
id: 'job-2',
promptOutput: promptOutput(),
workflow: wf
})
handlers['execution_start']?.({ detail: { prompt_id: 'job-2' } })
expect(store.isActiveWorkflowRunning).toBe(false)
})
it('reports the active workflow as running when job, path and session agree', () => {
const store = setup()
const wf = workflow('w.json')
activeWorkflow.value = { path: 'w.json' }
store.storeJob({
nodes: [],
id: 'job-2',
promptOutput: promptOutput(),
workflow: wf
})
handlers['execution_start']?.({ detail: { prompt_id: 'job-2' } })
expect(store.isActiveWorkflowRunning).toBe(true)
})
it('formats a service-level error message from the exception message alone', () => {
setup()
handlers['execution_error']?.({
detail: { prompt_id: 'job-3', exception_message: 'Job has stagnated' }
})
expect(errorStore.lastPromptError).toEqual({
type: 'error',
message: 'Job has stagnated',
details: ''
})
})
it('stores a classified cloud prompt error on the prompt-error branch', () => {
dist.isCloud = true
classifyCloud.mockReturnValue({
kind: 'promptError',
promptError: { type: 'validation', message: 'bad input', details: '' }
})
setup()
handlers['execution_error']?.({
detail: { prompt_id: 'job-4', exception_message: '{}' }
})
expect(errorStore.lastPromptError).toEqual({
type: 'validation',
message: 'bad input',
details: ''
})
})
})

View File

@@ -22,8 +22,7 @@ const {
mockShowTextPreview,
mockTrackExecutionError,
mockTrackExecutionSuccess,
mockTrackSharedWorkflowRun,
mockRevokePreviewsByExecutionId
mockTrackSharedWorkflowRun
} = await vi.hoisted(async () => {
const { shallowRef } = await import('vue')
return {
@@ -35,8 +34,7 @@ const {
mockShowTextPreview: vi.fn(),
mockTrackExecutionError: vi.fn(),
mockTrackExecutionSuccess: vi.fn(),
mockTrackSharedWorkflowRun: vi.fn(),
mockRevokePreviewsByExecutionId: vi.fn()
mockTrackSharedWorkflowRun: vi.fn()
}
})
@@ -131,7 +129,7 @@ vi.mock('@/scripts/api', () => ({
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({
revokePreviewsByExecutionId: mockRevokePreviewsByExecutionId
revokePreviewsByExecutionId: vi.fn()
})
}))
@@ -425,124 +423,6 @@ describe('useExecutionStore - nodeLocationProgressStates caching', () => {
'running'
)
})
it('keeps an existing error state when later progress maps to the same locator', () => {
store.nodeProgressStates = {
node1: {
display_node_id: '123',
state: 'error',
value: 0,
max: 100,
prompt_id: 'test',
node_id: 'node1'
},
node2: {
display_node_id: '123:456',
state: 'running',
value: 50,
max: 100,
prompt_id: 'test',
node_id: 'node2'
}
}
expect(
store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))]
.state
).toBe('error')
})
it('ignores finished progress when current state is already running', () => {
store.nodeProgressStates = {
node1: {
display_node_id: '123',
state: 'running',
value: 5,
max: 10,
prompt_id: 'test',
node_id: 'node1'
},
node2: {
display_node_id: '123',
state: 'finished',
value: 10,
max: 10,
prompt_id: 'test',
node_id: 'node2'
}
}
expect(
store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))]
).toMatchObject({ state: 'running', value: 5 })
})
it('keeps later running progress from moving a locator backwards', () => {
store.nodeProgressStates = {
node1: {
display_node_id: '123',
state: 'running',
value: 6,
max: 10,
prompt_id: 'test',
node_id: 'node1'
},
node2: {
display_node_id: '123',
state: 'running',
value: 8,
max: 10,
prompt_id: 'test',
node_id: 'node2'
}
}
expect(
store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))]
).toMatchObject({ state: 'running', value: 6, max: 10 })
})
it('merges zero-max running progress without dividing by zero', () => {
store.nodeProgressStates = {
node1: {
display_node_id: '123',
state: 'pending',
value: 0,
max: 0,
prompt_id: 'test',
node_id: 'node1'
},
node2: {
display_node_id: '123',
state: 'running',
value: 0,
max: 0,
prompt_id: 'test',
node_id: 'node2'
}
}
expect(
store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))]
).toMatchObject({ state: 'running', value: 0, max: 0 })
})
it('skips nested progress when the execution id cannot be resolved', () => {
vi.mocked(app.rootGraph.getNodeById).mockReturnValue(null)
store.nodeProgressStates = {
node1: {
display_node_id: '404:1',
state: 'running',
value: 5,
max: 10,
prompt_id: 'test',
node_id: 'node1'
}
}
expect(store.nodeLocationProgressStates).toHaveProperty('404')
expect(store.nodeLocationProgressStates).not.toHaveProperty('404:1')
})
})
describe('useExecutionStore - nodeProgressStatesByJob eviction', () => {
@@ -671,33 +551,6 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
expect(store.initializingJobIds).toEqual(new Set())
})
it('clears initialization ids directly', () => {
store.initializingJobIds = new Set(['job-1'])
store.clearInitializationByJobId(null)
store.clearInitializationByJobId('missing')
store.clearInitializationByJobId('job-1')
expect(store.initializingJobIds).toEqual(new Set())
})
it('checks initializing jobs by stringified id', () => {
store.initializingJobIds = new Set(['7'])
expect(store.isJobInitializing(undefined)).toBe(false)
expect(store.isJobInitializing(7)).toBe(true)
})
it('does not rewrite initializing state when no requested ids are tracked', () => {
store.initializingJobIds = new Set(['job-1'])
const before = store.initializingJobIds
store.clearInitializationByJobIds(['missing'])
expect(store.initializingJobIds).toBe(before)
expect(store.initializingJobIds).toEqual(new Set(['job-1']))
})
})
describe('useExecutionStore - workflowStatus', () => {
@@ -822,16 +675,6 @@ describe('useExecutionStore - workflowStatus', () => {
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
})
it('leaves workflowStatus unchanged when open workflows are unchanged', async () => {
callStoreJob('job-a', workflowA)
fireExecutionSuccess('job-a')
mockOpenWorkflows.value = [workflowA, workflowB]
await nextTick()
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
})
it('sets failed on execution_error', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
@@ -848,14 +691,6 @@ describe('useExecutionStore - workflowStatus', () => {
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('handles interrupt for a queued workflow with no active job', () => {
callStoreJob('job-1', workflowA)
fireExecutionInterrupted('job-1')
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('evicts the oldest pending status once the buffer cap is exceeded', () => {
// Each start with no matching storeJob buffers a 'running' status. One
// past the cap evicts the oldest so the buffer can't grow unbounded.
@@ -1065,35 +900,6 @@ describe('useExecutionStore - progress_text startup guard', () => {
expect(mockShowTextPreview).toHaveBeenCalledWith(mockNode, 'warming up')
})
it('should ignore progress_text for another active prompt', async () => {
const mockNode = createMockLGraphNode({ id: 1 })
const { useCanvasStore } =
await import('@/renderer/core/canvas/canvasStore')
useCanvasStore().canvas = {
graph: { getNodeById: vi.fn(() => mockNode) }
} as unknown as LGraphCanvas
store.activeJobId = 'job-1'
fireProgressText({
nodeId: toNodeId('1'),
text: 'warming up',
prompt_id: 'job-2'
})
expect(mockShowTextPreview).not.toHaveBeenCalled()
})
it('should ignore progress_text without text or node id', () => {
fireProgressText({ nodeId: toNodeId('1'), text: '' })
fireProgressText({
nodeId: '' as ReturnType<typeof toNodeId>,
text: 'warming up'
})
expect(mockShowTextPreview).not.toHaveBeenCalled()
})
it('should ignore nested progress_text when the execution ID cannot be mapped', async () => {
const { useCanvasStore } =
await import('@/renderer/core/canvas/canvasStore')
@@ -1109,19 +915,6 @@ describe('useExecutionStore - progress_text startup guard', () => {
expect(mockExecutionIdToCurrentId).toHaveBeenCalledWith('1:2')
expect(mockShowTextPreview).not.toHaveBeenCalled()
})
it('should ignore progress_text when the current node id cannot be parsed', async () => {
const { useCanvasStore } =
await import('@/renderer/core/canvas/canvasStore')
useCanvasStore().canvas = {
graph: { getNodeById: vi.fn() }
} as unknown as LGraphCanvas
mockExecutionIdToCurrentId.mockReturnValue({})
fireProgressText({ nodeId: toNodeId('1:2'), text: 'warming up' })
expect(mockShowTextPreview).not.toHaveBeenCalled()
})
})
describe('useExecutionErrorStore - Node Error Lookups', () => {
@@ -1582,21 +1375,6 @@ describe('useExecutionStore - WebSocket event handlers', () => {
expect(store.initializingJobIds.has('job-1')).toBe(false)
expect(store.initializingJobIds.has('job-2')).toBe(true)
})
it('captures a queued workflow path when the start event wins the race', () => {
store.queuedJobs = {
'job-1': {
nodes: {},
workflow: createQueuedWorkflow('/workflows/race.json')
}
}
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe(
'/workflows/race.json'
)
})
})
describe('execution_cached', () => {
@@ -1784,35 +1562,9 @@ describe('useExecutionStore - WebSocket event handlers', () => {
is_app_mode: true
})
})
it('uses current mode when shared queued job has no queued mode snapshot', () => {
mockAppModeState.mode.value = 'app'
mockAppModeState.isAppMode.value = true
store.queuedJobs = {
'job-1': {
nodes: {},
shareId: 'share-1'
}
}
fire('execution_success', { prompt_id: 'job-1', timestamp: 0 })
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
job_id: 'job-1',
share_id: 'share-1',
view_mode: 'app',
is_app_mode: true
})
})
})
describe('executing', () => {
it('is a no-op when there is no active job', () => {
fire('executing', null)
expect(store.activeJobId).toBeNull()
})
it('clears _executingNodeProgress and activeJobId when detail is null', () => {
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
store._executingNodeProgress = {
@@ -1838,34 +1590,7 @@ describe('useExecutionStore - WebSocket event handlers', () => {
})
})
describe('progress_state', () => {
it('does not revoke previews when the node execution id is invalid', () => {
mockRevokePreviewsByExecutionId.mockClear()
fire('progress_state', {
prompt_id: 'job-1',
nodes: {
'': {
value: 1,
max: 2,
state: 'running',
node_id: '',
display_node_id: '',
prompt_id: 'job-1'
}
}
})
expect(mockRevokePreviewsByExecutionId).not.toHaveBeenCalled()
expect(store.nodeProgressStates).toHaveProperty('')
})
})
describe('progress', () => {
it('reports null executing node progress before progress events arrive', () => {
expect(store.executingNodeProgress).toBeNull()
})
it('sets _executingNodeProgress from the event payload', () => {
const payload = { value: 3, max: 10, prompt_id: 'job-1', node: 'n1' }
@@ -1885,24 +1610,6 @@ describe('useExecutionStore - WebSocket event handlers', () => {
expect(store.clientId).toBe('test-client')
expect(removeSpy).toHaveBeenCalledWith('status', expect.any(Function))
})
it('keeps listening when status arrives before clientId is available', async () => {
const apiModule = await import('@/scripts/api')
const removeSpy = vi.mocked(apiModule.api.removeEventListener)
apiModule.api.clientId = ''
try {
fire('status', { exec_info: { queue_remaining: 0 } })
expect(store.clientId).toBeNull()
expect(removeSpy).not.toHaveBeenCalledWith(
'status',
expect.any(Function)
)
} finally {
apiModule.api.clientId = 'test-client'
}
})
})
describe('execution_error', () => {
@@ -1924,40 +1631,6 @@ describe('useExecutionStore - WebSocket event handlers', () => {
})
})
it('uses the message directly for service-level errors without a type', () => {
const errorStore = useExecutionErrorStore()
fire('execution_error', {
prompt_id: 'job-1',
node_id: null,
exception_message: 'Job failed before node execution',
traceback: []
})
expect(errorStore.lastPromptError).toMatchObject({
type: 'error',
message: 'Job failed before node execution',
details: ''
})
})
it('uses an empty prompt message for service-level errors without backend copy', () => {
const errorStore = useExecutionErrorStore()
fire('execution_error', {
prompt_id: 'job-1',
node_id: null,
exception_message: '',
traceback: []
})
expect(errorStore.lastPromptError).toMatchObject({
type: 'error',
message: '',
details: ''
})
})
it('routes a runtime error (with node_id) to lastExecutionError', () => {
const errorStore = useExecutionErrorStore()
@@ -2071,12 +1744,6 @@ describe('useExecutionStore - WebSocket event handlers', () => {
expect(store.initializingJobIds.has('job-9')).toBe(false)
})
it('ignores notifications without text', () => {
fire('notification', { id: 'job-9' })
expect(store.initializingJobIds.has('job-9')).toBe(false)
})
})
describe('unbindExecutionEvents', () => {
@@ -2146,45 +1813,6 @@ describe('useExecutionStore - storeJob and workflow path tracking', () => {
)
})
it('storeJob works without workflow metadata', () => {
const workflow = {} as Parameters<typeof store.storeJob>[0]['workflow']
const missingWorkflow = undefined as unknown as Parameters<
typeof store.storeJob
>[0]['workflow']
store.storeJob({
nodes: ['a'],
id: 'job-1',
promptOutput: {
a: createPromptNode('Node A', 'NodeA')
},
workflow
})
expect(store.queuedJobs['job-1']?.nodes).toEqual({ a: false })
expect(store.jobIdToWorkflowId.has('job-1')).toBe(false)
expect(store.jobIdToSessionWorkflowPath.has('job-1')).toBe(false)
store.storeJob({
nodes: ['b'],
id: 'job-2',
promptOutput: {
b: createPromptNode('Node B', 'NodeB')
},
workflow: missingWorkflow
})
expect(store.queuedJobs['job-2']?.nodes).toEqual({ b: false })
expect(store.queuedJobs['job-2']?.workflow).toBeUndefined()
})
it('reports zero execution progress for an active job with no nodes', () => {
store.activeJobId = 'job-1'
store.queuedJobs = { 'job-1': { nodes: {} } }
expect(store.executionProgress).toBe(0)
})
it('registerJobWorkflowIdMapping ignores empty inputs', () => {
store.registerJobWorkflowIdMapping('job-1', 'wf-1')
store.registerJobWorkflowIdMapping('', 'wf-2')
@@ -2201,58 +1829,4 @@ describe('useExecutionStore - storeJob and workflow path tracking', () => {
expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe('/b.json')
})
it('evicts the oldest workflow paths when the session map exceeds capacity', () => {
for (let i = 0; i < 4001; i++) {
store.ensureSessionWorkflowPath(`job-${i}`, `/workflow-${i}.json`)
}
expect(store.jobIdToSessionWorkflowPath.size).toBe(4000)
expect(store.jobIdToSessionWorkflowPath.has('job-0')).toBe(false)
expect(store.jobIdToSessionWorkflowPath.get('job-4000')).toBe(
'/workflow-4000.json'
)
})
it('reports whether the active workflow is running', () => {
mockActiveWorkflow.value = { path: '/workflows/foo.json' }
store.activeJobId = 'job-1'
store.ensureSessionWorkflowPath('job-1', '/workflows/foo.json')
expect(store.isActiveWorkflowRunning).toBe(true)
store.ensureSessionWorkflowPath('job-1', '/workflows/bar.json')
expect(store.isActiveWorkflowRunning).toBe(false)
mockActiveWorkflow.value = {}
expect(store.isActiveWorkflowRunning).toBe(false)
})
it('counts running jobs from progress state', () => {
store.nodeProgressStatesByJob = {
'job-1': {
a: {
value: 1,
max: 10,
state: 'running',
node_id: 'a',
display_node_id: 'a',
prompt_id: 'job-1'
}
},
'job-2': {
b: {
value: 10,
max: 10,
state: 'finished',
node_id: 'b',
display_node_id: 'b',
prompt_id: 'job-2'
}
}
}
expect(store.runningJobIds).toEqual(['job-1'])
expect(store.runningWorkflowCount).toBe(1)
})
})

View File

@@ -1,153 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useExecutionStore } from '@/stores/executionStore'
const { handlers, openSet } = vi.hoisted(() => ({
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
openSet: new Set<unknown>()
}))
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
handlers[name] = fn
},
removeEventListener: () => {}
}
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
isOpen: (workflow: unknown) => openSet.has(workflow),
openWorkflows: [],
nodeLocatorIdToNodeExecutionId: () => null
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: undefined })
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => ({
clearExecutionStartErrors: () => {},
clearPromptError: () => {}
})
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
}))
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
vi.mock('@/utils/appMode', () => ({
getWorkflowMode: () => 'workflow',
isAppModeValue: () => false
}))
function workflow(path: string): ComfyWorkflow {
return { path } as unknown as ComfyWorkflow
}
function promptOutput(): ComfyApiWorkflow {
return {}
}
function storeJob(
store: ReturnType<typeof useExecutionStore>,
id: string,
wf: ComfyWorkflow
) {
store.storeJob({ nodes: [], id, promptOutput: promptOutput(), workflow: wf })
}
function fire(event: string, jobId: string) {
handlers[event]?.({ detail: { prompt_id: jobId } })
}
function setup() {
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
beforeEach(() => {
setActivePinia(createPinia())
for (const key of Object.keys(handlers)) delete handlers[key]
openSet.clear()
})
describe('executionStore workflow status', () => {
it('marks an open workflow running on execution_start and completed on success', () => {
const store = setup()
const wf = workflow('a.json')
openSet.add(wf)
storeJob(store, 'job-1', wf)
fire('execution_start', 'job-1')
expect(store.getWorkflowStatus(wf)).toBe('running')
fire('execution_success', 'job-1')
expect(store.getWorkflowStatus(wf)).toBe('completed')
})
it('buffers a status that arrives before the job is attached, then flushes on storeJob', () => {
const store = setup()
const wf = workflow('b.json')
openSet.add(wf)
fire('execution_start', 'job-2')
expect(store.getWorkflowStatus(wf)).toBeUndefined()
storeJob(store, 'job-2', wf)
expect(store.getWorkflowStatus(wf)).toBe('running')
})
it('does not apply status to a workflow that is not open', () => {
const store = setup()
const wf = workflow('c.json')
storeJob(store, 'job-3', wf)
fire('execution_start', 'job-3')
expect(store.getWorkflowStatus(wf)).toBeUndefined()
})
it('clears a workflow status', () => {
const store = setup()
const wf = workflow('d.json')
openSet.add(wf)
storeJob(store, 'job-4', wf)
fire('execution_start', 'job-4')
expect(store.getWorkflowStatus(wf)).toBe('running')
store.clearWorkflowStatus(wf)
expect(store.getWorkflowStatus(wf)).toBeUndefined()
})
it('does not let a late buffered running overwrite a terminal status', () => {
const store = setup()
const wf = workflow('e.json')
openSet.add(wf)
storeJob(store, 'job-5', wf)
fire('execution_success', 'job-5')
expect(store.getWorkflowStatus(wf)).toBe('completed')
fire('execution_start', 'job-6')
storeJob(store, 'job-6', wf)
expect(store.getWorkflowStatus(wf)).toBe('completed')
})
it('returns undefined for a null or unknown workflow', () => {
const store = setup()
expect(store.getWorkflowStatus(null)).toBeUndefined()
expect(store.getWorkflowStatus(workflow('unknown.json'))).toBeUndefined()
})
})

View File

@@ -1,6 +1,6 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { ref } from 'vue'
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
import { releaseSharedObjectUrl } from '@/utils/objectUrlUtil'
@@ -71,20 +71,6 @@ describe('jobPreviewStore', () => {
expect(store.previewsByPromptId).toEqual({ p2: 'blob:b' })
})
it('ignores clearPreview without a prompt id', () => {
const store = useJobPreviewStore()
store.setPreviewUrl('p1', 'blob:a', 'node-1')
vi.mocked(releaseSharedObjectUrl).mockClear()
store.clearPreview(undefined)
expect(store.nodePreviewsByPromptId['p1']).toEqual({
url: 'blob:a',
nodeId: 'node-1'
})
expect(releaseSharedObjectUrl).not.toHaveBeenCalled()
})
it('clears all previews', () => {
const store = useJobPreviewStore()
store.setPreviewUrl('p1', 'blob:a', 'node-1')
@@ -105,24 +91,6 @@ describe('jobPreviewStore', () => {
expect(releaseSharedObjectUrl).not.toHaveBeenCalled()
})
it('ignores missing prompt ids', () => {
const store = useJobPreviewStore()
store.setPreviewUrl(undefined, 'blob:a', 'node-1')
expect(store.nodePreviewsByPromptId).toEqual({})
})
it('releases the old url when replacing a preview', () => {
const store = useJobPreviewStore()
store.setPreviewUrl('p1', 'blob:a', 'node-1')
store.setPreviewUrl('p1', 'blob:b', 'node-1')
expect(releaseSharedObjectUrl).toHaveBeenCalledWith('blob:a')
expect(store.nodePreviewsByPromptId['p1']?.url).toBe('blob:b')
})
it('ignores setPreviewUrl when previews are disabled', () => {
previewMethodRef.value = 'none'
const store = useJobPreviewStore()
@@ -131,15 +99,4 @@ describe('jobPreviewStore', () => {
expect(store.nodePreviewsByPromptId).toEqual({})
})
it('clears previews when previews are disabled after storage', async () => {
const store = useJobPreviewStore()
store.setPreviewUrl('p1', 'blob:a', 'node-1')
previewMethodRef.value = 'none'
await nextTick()
expect(store.nodePreviewsByPromptId).toEqual({})
expect(releaseSharedObjectUrl).toHaveBeenCalledWith('blob:a')
})
})

View File

@@ -1,141 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { SerializedNodeId } from '@/types/nodeId'
import { ResultItemImpl } from '@/stores/queueStore'
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (path: string) => `http://localhost:8188${path}`,
addEventListener: () => {}
}
}))
// Importing ResultItemImpl transitively loads @/scripts/app, whose module-level
// ComfyApp singleton wires real listeners. Stub it; ResultItemImpl needs none of it.
vi.mock('@/scripts/app', () => ({ app: {} }))
// Keep preview-url assertions deterministic: don't append cloud params.
vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
appendCloudResParam: () => {}
}))
interface ItemOverrides {
filename?: string
mediaType?: string
format?: string
frame_rate?: number
}
function item(over: ItemOverrides = {}) {
return new ResultItemImpl({
filename: over.filename ?? 'out.png',
subfolder: 'sub',
type: 'output',
nodeId: '1' as SerializedNodeId,
mediaType: over.mediaType ?? 'images',
format: over.format,
frame_rate: over.frame_rate
})
}
describe('ResultItemImpl', () => {
it('builds view url params and omits absent vhs fields', () => {
const params = item({ filename: 'a.png' }).urlParams
expect(params.get('filename')).toBe('a.png')
expect(params.get('type')).toBe('output')
expect(params.get('subfolder')).toBe('sub')
expect(params.has('format')).toBe(false)
expect(params.has('frame_rate')).toBe(false)
})
it('includes vhs format and frame_rate params when present', () => {
const params = item({ format: 'video/h264-mp4', frame_rate: 24 }).urlParams
expect(params.get('format')).toBe('video/h264-mp4')
expect(params.get('frame_rate')).toBe('24')
})
it('returns an empty url for a nameless item and a view url otherwise', () => {
expect(item({ filename: '' }).url).toBe('')
expect(item({ filename: 'a.png' }).url).toContain('/view?')
})
it('routes image preview urls through /view', () => {
expect(
item({ filename: 'a.png', mediaType: 'images' }).previewUrl
).toContain('/view?')
})
it('falls back to url directly for non-image preview urls', () => {
const nonImage = item({ filename: 'a.mp3', mediaType: 'audio' })
expect(nonImage.previewUrl).toBe(nonImage.url)
})
it('exposes the vhs advanced preview endpoint', () => {
expect(item().vhsAdvancedPreviewUrl).toContain('/viewvideo?')
})
it('maps html video mime types by suffix and vhs format', () => {
expect(item({ filename: 'a.webm' }).htmlVideoType).toBe('video/webm')
expect(item({ filename: 'a.mp4' }).htmlVideoType).toBe('video/mp4')
expect(item({ filename: 'a.mov' }).htmlVideoType).toBe('video/quicktime')
expect(
item({ filename: 'a.bin', format: 'video/mp4', frame_rate: 24 })
.htmlVideoType
).toBe('video/mp4')
expect(item({ filename: 'a.txt' }).htmlVideoType).toBeUndefined()
})
it('maps html audio mime types by suffix', () => {
expect(item({ filename: 'a.mp3' }).htmlAudioType).toBe('audio/mpeg')
expect(item({ filename: 'a.wav' }).htmlAudioType).toBe('audio/wav')
expect(item({ filename: 'a.ogg' }).htmlAudioType).toBe('audio/ogg')
expect(item({ filename: 'a.flac' }).htmlAudioType).toBe('audio/flac')
expect(item({ filename: 'a.png' }).htmlAudioType).toBeUndefined()
})
it('treats vhs format as such only with both format and frame_rate', () => {
expect(item({ format: 'video/mp4', frame_rate: 24 }).isVhsFormat).toBe(true)
expect(item({ format: 'video/mp4' }).isVhsFormat).toBe(false)
expect(item({ frame_rate: 24 }).isVhsFormat).toBe(false)
expect(item().isVhsFormat).toBe(false)
})
it('classifies video by suffix and by media type', () => {
expect(item({ filename: 'a.webm' }).isVideo).toBe(true)
expect(item({ filename: 'a.bin', mediaType: 'video' }).isVideo).toBe(true)
expect(item({ filename: 'a.png', mediaType: 'video' }).isVideo).toBe(false)
})
it('classifies image only when not contradicted by a media suffix', () => {
expect(item({ filename: 'a.png', mediaType: 'images' }).isImage).toBe(true)
expect(item({ filename: 'a.webm', mediaType: 'images' }).isImage).toBe(
false
)
})
it('classifies audio by suffix and by media type', () => {
expect(item({ filename: 'a.mp3' }).isAudio).toBe(true)
expect(item({ filename: 'a.bin', mediaType: 'audio' }).isAudio).toBe(true)
expect(item({ filename: 'a.png', mediaType: 'audio' }).isAudio).toBe(false)
})
it('reports text and preview support', () => {
expect(item({ mediaType: 'text' }).isText).toBe(true)
expect(item({ filename: 'a.png' }).supportsPreview).toBe(true)
expect(
item({ filename: 'a.bin', mediaType: 'binary' }).supportsPreview
).toBe(false)
})
it('filters previewable outputs and finds an item by url', () => {
const png = item({ filename: 'a.png' })
const bin = item({ filename: 'a.bin', mediaType: 'binary' })
expect(ResultItemImpl.filterPreviewable([png, bin])).toEqual([png])
// A genuine match returns the matched index (1 here, distinguishing it
// from the index-0 fallback used for no-match and missing-url cases).
expect(ResultItemImpl.findByUrl([bin, png], png.url)).toBe(1)
expect(ResultItemImpl.findByUrl([bin, png], 'no-match')).toBe(0)
expect(ResultItemImpl.findByUrl([bin, png])).toBe(0)
})
})

View File

@@ -1,216 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ResultItemType, TaskOutput } from '@/schemas/apiSchema'
import type { SerializedNodeId } from '@/types/nodeId'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (path: string) => `http://localhost:8188${path}`,
addEventListener: () => {}
}
}))
vi.mock('@/scripts/app', () => ({ app: {} }))
vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
appendCloudResParam: () => {}
}))
const { parseTaskOutput } = vi.hoisted(() => ({ parseTaskOutput: vi.fn() }))
vi.mock('@/stores/resultItemParsing', () => ({ parseTaskOutput }))
type JobStatus =
| 'in_progress'
| 'pending'
| 'completed'
| 'failed'
| 'cancelled'
function executionError(
overrides: Partial<NonNullable<JobListItem['execution_error']>> = {}
): NonNullable<JobListItem['execution_error']> {
return {
node_id: '1',
node_type: 'KSampler',
exception_message: 'boom',
exception_type: 'Error',
traceback: [],
current_inputs: {},
current_outputs: {},
...overrides
}
}
function job(over: Partial<JobListItem> = {}): JobListItem {
return {
id: 'job-1',
status: 'completed',
create_time: 1000,
priority: 0,
...over
}
}
function result(filename: string, type: ResultItemType = 'output') {
return new ResultItemImpl({
filename,
subfolder: '',
type,
nodeId: '1' as SerializedNodeId,
mediaType: 'images'
})
}
describe('TaskItemImpl', () => {
it('maps job status to taskType and apiTaskType', () => {
expect(new TaskItemImpl(job({ status: 'in_progress' })).taskType).toBe(
'Running'
)
expect(new TaskItemImpl(job({ status: 'pending' })).taskType).toBe(
'Pending'
)
expect(new TaskItemImpl(job({ status: 'completed' })).taskType).toBe(
'History'
)
expect(new TaskItemImpl(job({ status: 'pending' })).apiTaskType).toBe(
'queue'
)
expect(new TaskItemImpl(job({ status: 'completed' })).apiTaskType).toBe(
'history'
)
})
it('exposes displayStatus for every backend status', () => {
const statuses: [JobStatus, string][] = [
['in_progress', 'Running'],
['pending', 'Pending'],
['completed', 'Completed'],
['failed', 'Failed'],
['cancelled', 'Cancelled']
]
for (const [status, display] of statuses) {
expect(new TaskItemImpl(job({ status })).displayStatus).toBe(display)
}
})
it('derives history/running flags and a status-qualified key', () => {
const running = new TaskItemImpl(job({ id: 'a', status: 'in_progress' }))
expect(running.isRunning).toBe(true)
expect(running.isHistory).toBe(false)
expect(running.key).toBe('aRunning')
expect(new TaskItemImpl(job({ status: 'completed' })).isHistory).toBe(true)
})
it('uses explicitly provided flat outputs', () => {
const outputs = [result('a.png')]
const task = new TaskItemImpl(job(), undefined, outputs)
expect(task.flatOutputs).toBe(outputs)
})
it('parses outputs lazily when flat outputs are not supplied', () => {
const parsed = [result('p.png')]
parseTaskOutput.mockReturnValueOnce(parsed)
const outputs: TaskOutput = { '1': { images: [] } }
const task = new TaskItemImpl(job(), outputs)
expect(parseTaskOutput).toHaveBeenCalled()
expect(task.flatOutputs).toBe(parsed)
})
it('synthesizes outputs from preview_output when none are provided', () => {
parseTaskOutput.mockReturnValueOnce([])
const preview = { nodeId: '5', mediaType: 'images', filename: 'prev.png' }
new TaskItemImpl(job({ preview_output: preview }))
expect(parseTaskOutput).toHaveBeenCalledWith({
'5': { images: [preview] }
})
})
it('prefers the last saved output over temp previews for previewOutput', () => {
const temp = result('temp.png', 'temp')
const saved = result('saved.png', 'output')
const task = new TaskItemImpl(job(), undefined, [temp, saved])
expect(task.previewOutput).toBe(saved)
const onlyTemp = new TaskItemImpl(job(), undefined, [temp])
expect(onlyTemp.previewOutput).toBe(temp)
})
it('reports interrupted only for an interrupt-typed failure', () => {
expect(
new TaskItemImpl(
job({
status: 'failed',
execution_error: executionError({
exception_type: 'InterruptProcessingException'
})
})
).interrupted
).toBe(true)
expect(
new TaskItemImpl(
job({
status: 'failed',
execution_error: executionError({ exception_type: 'Other' })
})
).interrupted
).toBe(false)
expect(
new TaskItemImpl(
job({
status: 'completed',
execution_error: executionError({
exception_type: 'InterruptProcessingException'
})
})
).interrupted
).toBe(false)
})
it('surfaces error message and passthrough job fields', () => {
const task = new TaskItemImpl(
job({
status: 'failed',
outputs_count: 3,
workflow_id: 'wf-9',
execution_error: executionError({ exception_message: 'boom' })
})
)
expect(task.errorMessage).toBe('boom')
expect(task.outputsCount).toBe(3)
expect(task.workflowId).toBe('wf-9')
})
it('computes execution time only when both timestamps exist', () => {
expect(
new TaskItemImpl(
job({ execution_start_time: 1000, execution_end_time: 3000 })
).executionTimeInSeconds
).toBe(2)
expect(
new TaskItemImpl(job({ execution_start_time: 1000 })).executionTime
).toBeUndefined()
})
it('flatten returns itself when not completed', () => {
const running = new TaskItemImpl(job({ status: 'in_progress' }))
expect(running.flatten()).toEqual([running])
})
it('flatten expands a completed task into one task per output', () => {
const outputs = [result('a.png'), result('b.png')]
const task = new TaskItemImpl(
job({ id: 'j', status: 'completed' }),
undefined,
outputs
)
const flattened = task.flatten()
expect(flattened).toHaveLength(2)
expect(flattened.map((t) => t.jobId)).toEqual(['j-0', 'j-1'])
})
})

View File

@@ -1,4 +1,4 @@
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it } from 'vitest'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
@@ -154,22 +154,6 @@ describe(parseNodeOutput, () => {
expect(result).toHaveLength(1)
expect(result[0].filename).toBe('valid.png')
})
it('excludes non-object and invalid-type items', () => {
const output = fromAny<NodeExecutionOutput, unknown>({
images: [
null,
'not-an-item',
{ filename: 'bad.png', type: 'invalid' },
{ filename: 'valid.png', type: 'output' }
]
})
const result = parseNodeOutput('1', output)
expect(result).toHaveLength(1)
expect(result[0].filename).toBe('valid.png')
})
})
describe(parseTaskOutput, () => {

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

@@ -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
})
})
})