Compare commits

...

24 Commits

Author SHA1 Message Date
GitHub Action
3e91a60cf9 [automated] Apply ESLint and Oxfmt fixes 2026-06-26 00:59:21 +00:00
Connor Byrne
06cbb44a57 feat(agent): CRDT room layer + Immer session, wired to layout store
Adds the long-term CRDT peer path alongside the V0 server-draft path, both
as TDD-as-code (ADR-0011). Not wired into the chat UI yet.

CRDT (src/platform/agent/crdt):
- agentRoom: Y.Doc-backed room, real state-vector reconciliation + presence
- roomSync: transport-agnostic two-phase Yjs sync (y-protocols/sync style)
- agentRoomManager: room lifecycle (join/leave/pin) kept alive across tabs
- roomDocBinding: generic bidirectional Y.Doc binding with echo guards

Wiring (renderer layer):
- bindRoomToLayoutStore: binds a room to the real layoutStore singleton via
  its getYDoc/applyUpdate surface; test proves an agent edit bumps the store

Session (src/platform/agent/session):
- agentSessionStore: Immer reducer for local chat state (deltas, tool calls)

Keeps platform/ free of renderer/ imports per the layer architecture. Adds
immer dep; ignores the agent wire-contract module in knip pending UI wiring.
2026-06-25 17:55:09 -07:00
GitHub Action
5237112114 [automated] Apply ESLint and Oxfmt fixes 2026-06-26 00:22:24 +00:00
Connor Byrne
289ea0562c feat(agent): prototype draft sync + protocol for in-app agent (TDD-as-code)
Prototype supporting ADR-0011. No UI wiring yet.

- agentProtocol.ts: typed FE<->agent contract + parseAgentEvent() decoder
  for untrusted WS payloads
- draftReconciler.ts: pure version-CAS reconcile (apply/conflict/stale)
  per ADR-0004/0005
- useAgentDraftSync.ts: composable tracking per-workflow base versions and
  driving the three merge-dialog outcomes via injected canvas ports
- unit tests for all three (17 tests)
2026-06-25 17:18:47 -07:00
GitHub Action
ce4a53c498 [automated] Apply ESLint and Oxfmt fixes 2026-06-26 00:07:56 +00:00
Connor Byrne
9ed93698a0 docs: add ADR-0011 for in-app agent graph-state integration
Records the V0 frontend graph-state and sync design for the server-side
in-app agent: server workflow_draft as authority, full-document draft_patch
over the existing Redis->WS bridge, version-CAS + merge dialog for conflicts,
and a room-per-graph framing that bridges to the CRDT end-state (ADR-0003,
issue #4661). Design-record only; no functional code.
2026-06-25 16:45:03 -07:00
Dante
cfbc378df3 feat(billing): align workspace Plan & Credits panel to DES-186 (FE-768) (#12761)
## Summary

Aligns the Settings ▸ Workspaces **Plan & Credits** panel to DES-186:
state-driven subtitle, team/personal header variants, design perks, and
a shared footer help bar. Stacked on FE-964 (#12734), which owns the
CreditsTile content.

## Changes

- **What**:
- Subtitle per design variants (Figma 3255-21472): Active → "Renews on
{date}", Ending → "Ends on {date}". `subscription_status: 'scheduled'`
falls back to the Active treatment — the facade exposes no
scheduled-plan target/date fields yet, so "Changes to {plan} on {date}"
cannot be rendered (template reserved in i18n-ready form; see Linear
note).
- Team-active header: plan name "Team" + seat-aware workspace total
(`seat_summary.total_cost_cents` from `/billing/plans`) as "$X USD /
mo"; the Next-month-invoice card reads the same computed so the two
can't disagree. Per-member tier price + "USD / mo / member" kept as the
plans-unresolved fallback.
- CTAs per design + designer annotation: "Manage billing" + "Change
plan" on a team plan, "Upgrade plan" on personal.
- Team perks (Figma 2993-14789): "Your plan includes everything in
**Pro**, plus:" (i18n-t, plan name emphasized) + invite members /
concurrent runs / shared credit pool / role-based permissions.
- Personal no-subscription variant (Figma 2993-14604): "Free · $0 USD /
mo" header + primary Subscribe CTA + "What's included:" with the
max-runtime perk → "10 min max runtime" (`subscription.maxDuration.free`
set to 10 min per DES 3253-16079).
- Footer help bar (Learn more / Partner Nodes pricing table / Message
support / Invoice history) extracted into `SubscriptionFooterLinks.vue`,
shared by workspace + legacy panels; new surface-specific key for
"Partner Nodes pricing table".
- **Breaking**: none.

## Review Focus

- Seat-aware price source (`currentPlan.seat_summary.total_cost_cents`)
vs per-member fallback — fixture locks $320 vs $80.
- `'scheduled'` → Active fallback (adapter-level test in
`useWorkspaceBilling.test.ts`).
- Free-state perk copy: `subscription.maxDuration.free` set to **10
min** per DES 3253-16079 (design confirmed; was 30 min).
- Free-state overflow (`⋯`) button intentionally omitted: the only
existing menu item (Cancel Subscription) doesn't apply to a free plan.

Linear:
[FE-768](https://linear.app/comfyorg/issue/FE-768/updates-to-workspaces-tab-of-settings)
(Plan & Credits half; Members invite UI ships in #12759)

## Screenshots

Captured on `local.comfy.org` dev (cloud-prod backend, authenticated
session). Team and free states use client-side API stubs (XHR-level for
`/api/billing/*` + `/api/workspace/*`, fetch-level for legacy
`/customers/*`) since the test workspaces are unsubscribed;
personal-active rows are real account data.

| State | Before (FE-964 base) | After (this PR) |
|---|---|---|
| Team plan — active | <img width="400" alt="before: Pro, $100
USD/mo/member, Renews, Manage Payment/Upgrade Plan"
src="https://github.com/user-attachments/assets/622a9a27-1875-4c08-92b7-9e43a8067c59"
/> | <img width="400" alt="after: Team, $300 USD/mo, Renews on, Manage
billing/Change plan, design perks"
src="https://github.com/user-attachments/assets/adb0f767-d508-4455-ad8e-ee2d6ac419dc"
/> |
| Team plan — ending (cancelled) | <img width="400" alt="before: Expires
Jul 10, 2026"
src="https://github.com/user-attachments/assets/cb4bb978-4e8b-4372-8ecc-7265f477a828"
/> | <img width="400" alt="after: Ends on Jul 10, 2026"
src="https://github.com/user-attachments/assets/5466b99a-d016-4eba-a60f-99b87bd2693e"
/> |
| Personal — active (real data) | <img width="400" alt="before: Renews
Mar 10 2027, Manage Payment"
src="https://github.com/user-attachments/assets/34018400-930b-4147-bd0d-398fb4d159ee"
/> | <img width="400" alt="after: Renews on Mar 10 2027, Manage billing"
src="https://github.com/user-attachments/assets/87af7fa7-28cf-4cdf-b9a2-158b2a6eb979"
/> |
| Personal — no subscription | <img width="400" alt="before: generic
not-on-a-subscription prompt"
src="https://github.com/user-attachments/assets/7ee5b36e-1e07-4630-a9b7-680f4fad349b"
/> | <img width="400" alt="after: Free $0 USD/mo header + Subscribe +
What's included"
src="https://github.com/user-attachments/assets/1eda1791-e3de-46c3-bb69-df0365545211"
/> |



---

## Perk descriptions — designer QA (DES `3253-16079`)

CDP-verified the Plan & Credits **perk list** per current plan against
Figma. Captured at the full 1280px settings layout (width fix: #12849;
the perk text/content is identical at the current 960px).

| Plan | "Includes" header | Perks shown |
|---|---|---|
| Free (no subscription) | What's included: | 10 min max runtime |
| Personal — Pro | Your plan includes: | 1 hr max run duration · RTX
6000 Pro (96GB VRAM) · Add more credits whenever · Import your own LoRAs
|
| Team | Your plan includes everything in **Pro**, plus: | Invite
members · Members can run workflows concurrently · Shared credit pool
for all members · Role-based permissions |

**Free — Figma `3253-16079` (left) vs implementation (right):**

| Figma | Implementation |
|---|---|
| <img width="480" alt="Figma DES 3253-16079 free settings"
src="https://github.com/user-attachments/assets/cfe79570-f3ba-4627-a8eb-348b9158f6ac"
/> | <img width="480" alt="App free settings — What's included: 10 min
max runtime"
src="https://github.com/user-attachments/assets/d89898e4-d819-486c-9b4f-c2fd61916783"
/> |

**Personal — Pro:**

<img width="640" alt="App personal Pro — Your plan includes"
src="https://github.com/user-attachments/assets/adc2fd9f-d249-469f-b947-1ec8f674cbb0"
/>

**Team:**

<img width="640" alt="App team — Your plan includes everything in Pro,
plus"
src="https://github.com/user-attachments/assets/e7378067-11a2-411b-b37b-98c8aecb82b1"
/>

Open items (design):
- Free perk now reads **"10 min max runtime"**
(`subscription.maxDuration.free` set to 10 min) per Figma `3253-16079` —
 applied in this PR.
- Personal-plan perk **stacking** (show lower-tier perks under the
current tier) is an unresolved Figma thread on this node — not
implemented.

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-25 23:19:35 +00:00
Wei Hai
db085eb7a1 feat(auth): Cloudflare Turnstile on email signup (origin-gated) (#12924)
Adds a Cloudflare Turnstile widget to the email/password signup form
(web + desktop). The frontend renders the widget and attaches its token
to the signup request; the verification decision is made server-side.

## Design — config-driven, no origin sniffing

* The widget renders **iff** the `signup_turnstile` mode is `shadow` or
`enforce` **and** a `turnstile_sitekey` is present — both delivered via
cloud remote config. OSS / local builds receive no remote config, so it
never renders. Gating is a pure `isTurnstileEnabled(mode, siteKey)`; an
unknown mode normalizes to `off`.
* Submit is blocked only in **enforce**; **shadow** never blocks.
* The token is sent as `turnstile_token` (snake_case, optional) on the
customer-creation request.
* **OAuth** never renders the widget or sends a token (federated
providers are exempt).

## Behavior

* **Decision is server-side** — the frontend only renders the widget and
attaches the token; the backend verifies it and decides allow/block.
* **Mode-driven** — `off` (no-op) / `shadow` (render + attach, never
blocks) / `enforce` (blocks submit until solved).
* **Config-gated** — no `isCloud`/origin check in the client; the widget
is driven purely by the presence of the mode flag + sitekey in remote
config.
* **Fail-safe to off** — an unknown/missing mode or a missing sitekey
resolves to "don't render", so the feature is a no-op until both are
configured.
* The sitekey is a public, client-side value delivered per environment
via remote config; in dev it falls back to Cloudflare's always-pass test
sitekey.

## Files

New: `config/turnstile.ts`, `composables/auth/useTurnstile.ts` (+ test),
`composables/auth/turnstileScript.ts`,
`components/dialog/content/signin/TurnstileWidget.vue`. Edited:
`SignUpForm.vue`, `SignInContent.vue`, `useAuthActions.ts`,
`authStore.ts` (+ test), `remoteConfig/types.ts`,
`locales/en/main.json`.

## Flow

```mermaid
sequenceDiagram
    actor U as User
    participant FE as Signup form
    participant CF as Cloudflare Turnstile
    participant API as Backend signup API

    Note over FE: renders only when mode is shadow or enforce<br/>and a sitekey is present
    U->>FE: open email/password signup
    FE->>CF: load widget with sitekey
    CF-->>U: challenge (usually invisible)
    U-->>CF: solve
    CF-->>FE: token (single-use, short-lived)
    U->>FE: submit
    FE->>API: signup request with turnstile_token
    Note over API: verifies the token server-side and<br/>decides allow/block (shadow never blocks)
    API-->>FE: allowed, or blocked in enforce
```

## Rollout

Config-driven and a no-op until enabled:

1. **Merge + deploy** the FE — no visible change while the mode is `off`
/ no sitekey.
2. **Set** the `turnstile_sitekey` in remote config per environment.
3. **`signup_turnstile=shadow`** — the widget renders and attaches the
token; the server observes and never blocks.
4. → **`enforce`** — the FE blocks submit until the challenge is solved.

Kill switch: set the mode back to `off` and the widget stops rendering.

## Refactor: shared script loader

The Turnstile script loader was extracted to
`utils/loadExternalScript.ts` (`createScriptLoader`) and now also backs
the existing Typeform embed loader, removing duplicated
singleton/timeout/cleanup logic. Minor behavioral change: when a
matching `<script>` tag already exists in the DOM, the loader polls for
the global to become ready instead of attaching a `load` listener (which
may have already fired).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-25 23:03:48 +00:00
Simon Pinfold
48d429bd13 Allow Glary Bot through CLA (#13149)
## Summary

- Add `Glary Bot` to the CLA Assistant allowlist.

## Context

PR #13146 is blocked because its commit author is `Glary Bot
<bot@glary.dev>`, which GitHub does not resolve to a GitHub user. The
CLA action checks the unresolved commit author name after failing to
find a linked GitHub account, so the existing `*[bot]` GitHub App
allowlist does not apply.

## Validation

- Ran `git diff --check`.
2026-06-25 22:48:19 +00:00
AustinMroz
6eaad99502 Add center dividing line to image compare node (#13132)
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/37afb473-161c-4350-881e-0ea908e28777"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/d5acca61-3687-4c15-8029-ef2c88a06944"
/>|
2026-06-25 20:38:59 +00:00
Alexander Brown
2ced8a25d4 Add assertive profile to review settings (#13120)
## Summary
Rabbit too nice.
2026-06-25 20:28:49 +00:00
AustinMroz
9209a4b923 Add long widget values to tooltips (#12864)
If a widget value is long (> 10 characters) and on a known single-line
widget (`number`, `combo`, or `string), then the widget's full value
will be added to the tooltip.

Additionally, margins on combo widgets are slightly tweaked so more of
the text displays before truncation occurs.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/fefd76e9-6511-4e98-80f6-030a6dc34fb8"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/0cbc100d-066e-4272-afe9-795e56c12353"
/>|
2026-06-25 19:25:02 +00:00
AustinMroz
90cb8021df Update CLA allowlist (#13141)
It's `github-actions` without the `[bot]`. This was blocking every PR
that contained updated browser test expectations.

Additionally, the action already included an allow list for every
account ending in `[bot]`. This made half the entries redundant.
2026-06-25 18:30:48 +00:00
jaeone94
5a01c5b3b4 Remove redundant Enter Subgraph action from Errors tab (#13136)
## Summary

Remove the redundant **Enter Subgraph** action from Errors tab node
cards. This button should not be part of the updated Errors tab design;
it remained from the previous implementation and its removal was missed
when the new interaction model was introduced.

## Changes

- **What**: Removed the Errors tab `Enter Subgraph` button from
`ErrorNodeCard`, along with the `enterSubgraph` event plumbing in
`TabErrors`.
- Removed the now-unused `useFocusNode().enterSubgraph()` helper path,
since the Errors tab no longer has a separate subgraph-only action.
- Removed the `ErrorCardData.isSubgraphNode` flag and its population in
`useErrorGroups`, because it only existed to decide whether to show this
button.
- Removed the Storybook story and unit-test expectations that were
specifically tied to the removed button/flag.
- Removed the now-unused English `rightSidePanel.enterSubgraph` i18n
entry. Non-English locale files are intentionally left untouched per the
repo's localization update policy.

## Why

The Errors tab already has a **Locate node on canvas** action. For
errors inside subgraphs, that action navigates into the relevant
subgraph and centers the target node on the canvas. The removed **Enter
Subgraph** action was therefore a weaker duplicate: it entered the
subgraph and fit the view, but did not provide the same direct
target-node positioning.

Keeping both actions made the card UI more crowded and exposed two very
similar navigation paths with overlapping intent. The updated design
should only keep the more useful locate action, so this PR removes the
stale duplicate surface rather than adding another hidden/negative
assertion around it.

## Review Focus

Please verify that this only removes the Errors tab-specific action. The
normal node footer/canvas subgraph navigation behavior remains
untouched.

Validation run locally:

- `pnpm exec vitest run
src/components/rightSidePanel/errors/ErrorNodeCard.test.ts
src/components/rightSidePanel/errors/TabErrors.test.ts
src/components/rightSidePanel/errors/useErrorGroups.test.ts`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm format:check`
- `pnpm knip`


## Screenshot 

Before
<img width="335" height="595" alt="스크린샷 2026-06-25 오후 5 33 37"
src="https://github.com/user-attachments/assets/545f80e9-68bb-45ef-a4da-0a41012269f6"
/>

After
<img width="344" height="591" alt="스크린샷 2026-06-25 오후 5 34 24"
src="https://github.com/user-attachments/assets/7c1f1bf6-c5fd-4a43-9b5c-1392246070a8"
/>
2026-06-25 11:25:49 +00:00
Dante
e3049e7c31 feat(billing): single billing path — collapse personal/team dispatch to flag-only (B1 / FE-966) (#12953)
## What this does

Collapses the personal-vs-team billing dispatch so it keys ONLY on the
build/flag (`teamWorkspacesEnabled ? 'workspace' : 'legacy'`). Personal
now flows through `useWorkspaceBilling` (`/api/billing/*`), same as team
("personal plan = single-seat workspace"). This converges status /
balance / subscribe / preview / cancel / portal in one change.

Dispatch sites collapsed:
- `useBillingContext.ts` — `type` computed: dropped the
`store.isInPersonalWorkspace` branch → flag-only.
- `useBillingContext.ts` — D3 subscription→store mirror watch: dropped
the `isInPersonalWorkspace` early-return guard so personal also mirrors
into the workspace store.
- `useSubscriptionDialog.ts` — `useWorkspaceVariant` compound predicate
→ flag-only (personal + flag-on now uses the workspace required-dialog
variant).
- `SubscriptionPanel.vue` — already flag-only
(`v-if="teamWorkspacesEnabled"`); no change needed.

## Kept (Risk #6)

- The ~11 raw `workspace.type === 'personal'` checks in
`teamWorkspaceStore.ts` — workspace-TYPE membership logic
(can-delete/leave, fetch-members, switcher), NOT billing dispatch.
Untouched.
- `useLegacyBilling` / `useSubscription` / authStore billing methods
kept intact for the flag-OFF (OSS/Desktop) path.

## Flag-off unchanged

Flag-OFF (OSS/Desktop) still selects `legacy` (`/customers/*`). Verified
by unit test.

## Tests

- `useBillingContext`: flag-ON → personal selects `workspace`; flag-OFF
→ `legacy`; D3 mirror now fires for personal under flag-on.
- `useSubscriptionDialog`: flag-ON → workspace required-dialog variant
for personal; flag-OFF → legacy personal variant.

## Follow-up (deferred, not in this PR)

Post-flip cutover deletion of `useLegacyBilling`-only components:
`PricingTable.vue`, `SubscriptionPanelContentLegacy.vue`,
`TopUpCreditsDialogContentLegacy.vue`, `CurrentUserPopoverLegacy.vue`,
`subscriptionCheckoutUtil.ts`, `useSubscriptionCancellationWatcher.ts`.

- Fixes part of FE-903 (B1)
2026-06-25 06:59:00 +00:00
Dante
87e84e7280 feat(billing): inline Invite-your-team block on team-upgrade success (FE-965 / DES-394) (#12954)
Renders the inline **"Invite your team"** block in the team variant of
FE-934's "You're all set" success card
(`SubscriptionSuccessWorkspace.vue`), so a buyer can invite teammates
right after a team-plan upgrade (DES-394, Figma 3084-18651).

- New shared `InviteMembersForm.vue` (chips / `TagsInput` multi-email
form); seats capped via `useBillingContext.getMaxSeats(tierKey)`; submit
via `workspaceApi.createInvite`.
- Team upgrades only — personal / single-seat plans show the plain
success card; gated on `teamWorkspacesEnabled` + a team plan.
- `workspace_invite_sent` telemetry distinguishing a post-upgrade invite
from a Settings invite; success-card i18n + Storybook story.

**Stacked on FE-934 #12975** (`jaewon/fe-934-team-subscribe-wire`, the
success-card host). The PR base is that branch, so this diff is FE-965's
delta only. Re-using the same form in the Settings invite dialog is out
of scope here (belongs with FE-768 / a follow-up).

## Screenshots

| Team upgrade — invite block | Personal / non-team — no invite block |
|---|---|
| <img
src="https://github.com/user-attachments/assets/be5450fe-2b83-46bd-afbc-00e6d33590b7"
width="420" /> | <img
src="https://github.com/user-attachments/assets/a91909c7-7629-42ef-80b6-45fdb070a0e8"
width="420" /> |

Storybook: `Components/SubscriptionCheckoutSteps` →
`TeamSuccessWithInvite` (with block) / `SuccessAllSet` (without).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-25 05:54:04 +00:00
Dante
67009dcda2 feat(workspace): promote/demote members via Change role menu (FE-770) (#12782)
Promote / demote workspace members ↔ owners in Settings ▸ Members, per
[DES-222 / Figma
2993-15512](https://www.figma.com/design/CkFTD4c20PyRGpNVAJgpfV/Team-Plan---Workspaces?node-id=2993-15512)
and the [permissions section
3343-22966](https://www.figma.com/design/CkFTD4c20PyRGpNVAJgpfV/Team-Plan---Workspaces?node-id=3343-22966).

- Fixes
[FE-770](https://linear.app/comfyorg/issue/FE-770/promote-demote-workspace-members-owners-settings-members)
- Stacked on #12759 (`jaewon/fe-768-members-invite-ui`)

## Changes

- Per-member row (…) menu → **Change role** submenu (Owner / Member,
current role check-marked) + existing **Remove member**, replacing the
shared PrimeVue `Menu` with the Reka `DropdownMenu`/`DropdownItem`
(submenu opens right of parent, flips on collision; scalable for future
roles).
- **Make [name] an owner?** / **Demote [name] to member?** confirm
dialogs (single `ChangeMemberRoleDialogContent`, copy 1:1 from Figma).
- `workspaceApi.updateMemberRole` → `PATCH
/api/workspace/members/:userId {role}` +
`teamWorkspaceStore.changeMemberRole` (local role map update; Role
column re-sorts).
- **Original-owner guards** (Figma annotations): creator pinned to the
top of the list, no row actions for anyone on that row; own row also has
no actions. Creator inferred as earliest `joined_at` until BE exposes an
explicit flag (tracked as the FE-770 BE blocker — same applies to the
endpoint itself, which does not exist yet; UI is wired to the proposed
contract).
- `DropdownMenu` raised to `z-3000` so the row menu sits above the
Settings modal (the Reka popper wrapper copies the content's computed
z-index; static `z-1700` lost to dialogs in the `@primeuix` modal
sequence). Also drops the always-rendered icon slot in `DropdownItem` so
icon-less items (Change role / Remove member) align flush-left.

## User stories verified

Viewer = an **owner** (promoted, not the workspace creator), so the
creator guard and the self guard are exercised separately.

| # | Click → action → expected |
| --- | --- |
| US1 | Member row (…) → menu shows **Change role ›** + **Remove
member** |
| US2 | Hover **Change role** → Owner / Member submenu, **current role
check-marked** |
| US3 | Click the current role (✓) → no dialog, no PATCH (no-op) |
| US4 | Member row → **Owner** → "Make {name} an owner?" + "They'll have
the same access as you — managing members, billing, and workspace
settings." + Cancel / **Make owner** |
| US5 | **Cancel** (or ✕) → dialog closes, role unchanged, no PATCH |
| US6 | **Make owner** → `PATCH /api/workspace/members/:id
{role:'owner'}` → Role column → Owner, row **re-sorts under the
creator**, "Role updated" toast, the promoted row keeps its (…) menu |
| US7 | Promoted owner row → **Member** → "Demote {name} to member?" +
"They'll lose admin access." → **Demote to member** → Role column back
to Member |
| US8 | **Creator row (earliest joined) has no (…) button** — even for
another owner |
| US9 | **Own (You) row has no (…) button** — even when not the creator
|
| US10 | PATCH 500 → "Failed to update role" toast, **dialog stays
open**, role unchanged |
| US11 | Viewer with `member` role → no row actions anywhere |
| US12 | **Remove member** → existing FE-768 "Remove this member?"
dialog |

## Tests

Each user story is covered by automated tests and confirmed by a manual
CDP pass driving the real cloud app (mocked auth + boot +
workspace/billing API).

| Story | Unit / Component | E2E (Playwright) | CDP (live app) |
| --- | :---: | :---: | :---: |
| US1 row menu shows Change role + Remove member |  |  |  |
| US2 submenu checkmark follows current role |  |  |  |
| US3 picking the current role is a no-op |  |  |  |
| US4 promote dialog copy (Make owner) |  |  |  |
| US5 Cancel leaves role unchanged, no PATCH |  |  |  |
| US6 Make owner → PATCH, re-sort under creator, toast, stays demotable
|  |  |  |
| US7 demote dialog (Demote to member) → role reverts |  |  |  |
| US8 creator row has no (…) menu |  |  |  |
| US9 own (You) row has no (…) menu |  |  |  |
| US10 PATCH 500 → error toast, dialog stays open |  |  |  |
| US11 member-role viewer sees no row actions |  | — | — |
| US12 Remove member → FE-768 remove dialog |  |  |  |

| Layer | File | What it covers | Result |
| --- | --- | --- | --- |
| E2E (`@cloud`) |
`browser_tests/tests/dialogs/memberRoleChange.spec.ts` | 3 tests — guard
rows (US1/US8/US9/US12), promote→re-sort→demote round trip (US3–US7),
failed PATCH (US10). FE-964 boot pattern: `CloudAuthHelper` +
remote-config flag mock + stateful route mocks capturing PATCH args.
Reka submenu driven via `ArrowRight` (synthetic hover doesn't open it).
| 3 / 3 green |
| Component | `ChangeMemberRoleDialogContent.test.ts` | promote/demote
copy, confirm → store + success toast + close, error keeps dialog open,
cancel | green |
| Component | `MembersPanelContent.test.ts` | creator/self rows hide the
menu (US8/US9), member-viewer gating (US11) | green |
| Composable | `useMembersPanel.test.ts` | menu factory
labels/checkmarks/commands, same-role no-op, creator pin in
`sortMembers`, `isOriginalOwner` | green |
| Store | `teamWorkspaceStore.test.ts` | `changeMemberRole`
success/failure, `originalOwnerId` inference | green |
| CDP live | full cloud app on `local.comfy.org` (mocked auth + boot) |
promote→re-sort→demote round trip with PATCH applied to mock state,
guard rows, submenu checkmark, dialog copy, menu/dialog z-index above
Settings, forced PATCH 500 → error toast | verified |

⚠️ Merge-gated on the BE role-change endpoint (no `PATCH
/workspace/members/:userId` in cloud OpenAPI as of 2026-06-10; see
FE-770 BE-blocker comment).

## Screenshots (local dev, workspace/billing API stubbed; vs Figma
2993-15512)

| Members (before) | Change role submenu |
| --- | --- |
| <img alt="members"
src="https://github.com/user-attachments/assets/686fec86-fcb5-4942-a745-50f367022ab0"
/> | <img alt="submenu"
src="https://github.com/user-attachments/assets/d6adeea8-7001-4c8d-91b7-f5bfc47a50d6"
/> |

| Promote dialog | After promote (Jane → Owner, still demotable) |
Demote dialog |
| --- | --- | --- |
| <img alt="promote"
src="https://github.com/user-attachments/assets/af638cde-2fd6-4c37-b203-78801eeb2785"
/> | <img alt="after"
src="https://github.com/user-attachments/assets/f47dc7af-6b1b-422c-8a9a-5ec889b9af11"
/> | <img alt="demote"
src="https://github.com/user-attachments/assets/9a861d04-a23b-4cd4-bc54-1ed3a66c6429"
/> |
2026-06-25 05:04:48 +00:00
Dante
026b2c4795 feat(billing): unify credits into a facade-driven CreditsTile (FE-964) (#12734)
## Summary

### AS IS 
<img width="1340" height="798" alt="Screenshot 2026-06-10 at 12 22 36
AM"
src="https://github.com/user-attachments/assets/61636fa3-e80c-427b-855b-499e1eca67da"
/>

### TO BE

<img width="1301" height="793" alt="Screenshot 2026-06-10 at 12 22 39
AM"
src="https://github.com/user-attachments/assets/62d9f5a6-da92-45df-94e7-cd3c244249f9"
/>

### Empty states ([added to DES-247 on
2026-06-11](https://www.figma.com/design/CkFTD4c20PyRGpNVAJgpfV/Team-Plan---Workspaces?node-id=3349-29750))

| 0 monthly credits | 0 credits |
| --- | --- |
| <img alt="credits-empty-monthly"
src="https://github.com/user-attachments/assets/b3c55d3b-79b0-47b1-9795-c8bf69d5efe2"
/> | <img alt="credits-empty-all"
src="https://github.com/user-attachments/assets/919081d6-64e1-483b-9c04-6b085243ebc1"
/> |

Consolidate the divergent Settings credits surfaces into one
facade-driven **CreditsTile**, implementing the DES-247 redesign so
personal and team modes always render the same balance from
`useBillingContext`.

## Changes

- **What**:
- New `CreditsTile.vue` — total + `remaining`, a stacked
monthly/additional progress bar, colored breakdown rows (`Monthly
(refills …)` / `Additional`), refresh, and a permission-gated *Add
credits* / *Upgrade to add credits* action. Owns the post-checkout
(`focus` / `pending_topup`) balance refresh.
- Extracted the duplicated inline credits card out of
`SubscriptionPanelContentWorkspace.vue` **and**
`SubscriptionPanelContentLegacy.vue` onto the shared tile.
- Replaced `LegacyCreditsPanel.vue` (read `authStore.balance` directly)
with `CreditsPanel.vue` routed through the tile; repointed
`useSettingUI` and deleted the legacy panel.
- `creditsProgress.ts` pure helper for the bar math + numeric credit
getters on `useSubscriptionCredits`.
  - i18n keys for the unified tile labels.
- DES-247 responsive variants via CSS container queries: below ~350px
tile width the `{used} used` label, `remaining` suffix, and breakdown
subtitle drop and the additional-credits value stacks under its label;
below ~230px the monthly summary compacts (`105K left of 200K`).
Additional-credits tooltip copy aligned with the updated design (per
design feedback).
- Empty states (added to DES-247 via Slack on 2026-06-11, low priority):
once the monthly allowance is depleted, an info notice renders under the
total (`Monthly credits are used up. Refills {date}` / `You're now
spending additional credits.`), the monthly bar section dims to 30%
opacity, and an `IN USE` pill marks *Additional credits*; once
everything is depleted the notice switches to `You're out of credits.
Credits refill {date}` and *Add credits* swaps to the `inverted`
(filled-white) Button variant. Gated on a loaded balance so the notice
never flashes while fetching.
- **Dependencies**: Stacked on **#12622 (FE-904 / B2)** for the facade
`tier` / `renewalDate` fields — base this PR against that branch;
retarget to `main` once FE-904 merges.

## Review Focus

- The tile reads everything from the facade (`balance.*Micros` as cents
→ credits, `subscription.tier`/`renewalDate`), so legacy and workspace
modes share one source.
- Monthly allowance still comes from `getTierCredits` (hardcoded tier
nominal). With real data the monthly *remaining* can exceed the nominal
(rolled-over credits), so the bar clamps to a full segment — same
semantics as the prior `{monthly} / {planTotal}` display; the canonical
allowance is a BE-1047 follow-up.
- `LegacyCreditsPanel` deletion: `CreditsPanel.vue` retains the
usage-history table + help links and reads the facade.

## Testing

- Unit/component (36 green): `CreditsTile.test.ts` (render, zero-state,
free-tier, permission gating, add-credits, mount+manual refresh, plus
the empty states: depletion notice copy, `IN USE` badge, `inverted`
button when fully out, no-flash-while-loading guard),
`creditsProgress.test.ts` (clamping/stacking math),
`useSubscriptionCredits.test.ts` (`*_micros`-as-cents), and
`SubscriptionPanel.test.ts` updated for the extracted tile.
- E2E (`@cloud`): `browser_tests/tests/dialogs/creditsTile.spec.ts`
boots the cloud app against mocked Firebase auth + stubbed boot
endpoints (no backend) and asserts the tile's total / progress bar /
monthly+additional breakdown / add-credits in Settings ▸ Workspace ▸
Plan & Credits, then resizes to a narrow viewport and asserts the
responsive variant (labels hidden, compacted `11K left of 21K`). A
second test boots with a drained monthly balance (0-monthly notice + `IN
USE` badge), then re-mocks a fully drained balance and refreshes the
tile in place to assert the out-of-credits state. Both pass locally
against a cloud dev server; runs in the `cloud` CI project. Drives a raw
page because the shared `comfyPage` fixture expects the OSS devtools
backend.
- Screenshot-verified the tile at the three DES-247 reference widths
(448 / 235 / 204px) against the Figma Responsiveness section — 1:1.
- Verified live in the running app (Settings ▸ Workspace ▸ Plan &
Credits) against the authenticated backend — renders 1:1 with DES-247.
The empty-state screenshots above were captured the same way
(authenticated app, real Pro subscription, balance endpoint stubbed to
the depleted values via CDP).
- `pnpm typecheck` / `typecheck:browser` / `lint` / `knip` green.

Implements FE-964 (DES-247).

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-25 04:44:59 +00:00
Dante
d60260ac3c feat(billing): team-plan subscribe + checkout/confirm screen redesign (FE-934) (#12975)
## Summary

Two related streams of FE-934 checkout/billing work on this branch:



https://github.com/user-attachments/assets/af629def-543e-4bcd-894d-b35aa032fe0a



1. **Team-plan subscribe** wired to the BE-1254 credit-stop contract
(replaces the "coming soon" toast stub).
2. **Checkout / confirm screen redesign** aligned to the updated DES
mockup — yearly pricing display, dialog navigation, and the plan-change
confirm.

<img width="1078" height="427" alt="team subscribe"
src="https://github.com/user-attachments/assets/2b5f7192-3c91-4e2d-b495-832ca5a26657"
/>

## Changes

### Team-plan subscribe (credit-stop contract)

- Team subscribe sends `{ plan_slug: team_per_credit_monthly |
team_per_credit_annual, team_credit_stop_id, billing_cycle }` to `POST
/api/billing/subscribe` and handles the response like the personal path
(`subscribed` → success, `needs_payment_method` → payment URL,
`pending_payment` → poll). Slider stops come from `GET
/api/billing/plans → team_credit_stops` via `mapApiTeamCreditStops`,
falling back to the hardcoded DES-197 stops so OSS / pre-deploy still
render. `preview-subscribe` is unchanged — the team confirm step is
display-only.
- **Internal API change**: `workspaceApi.subscribe(planSlug, returnUrl?,
cancelUrl?)` → `subscribe(planSlug, options?: SubscribeOptions)`; the
billing facade (`useBillingContext` / `useWorkspaceBilling` /
`useLegacyBilling`) and callers were updated to match.

### Checkout / confirm screen redesign (DES mockup)

- **Yearly confirm**: headline is now the ÷12 monthly-equivalent with a
`{total} Billed yearly` (yearly) / `Billed monthly` (monthly) subtitle;
credits show `Each year credits refill to` (×12) for yearly; `Starting
today` → `Starts today`. The team confirm now receives the active
billing cycle (the subtitle was missing because it wasn't passed), and
the redundant header credits/month line was dropped.
- **Navigation**: the pricing table stays mounted (`v-show`, not `v-if`)
so the plan / billing-cycle / credit-stop selection survives a round
trip to the confirm step and back; **Backspace** mirrors the back arrow
(ignored while an input/textarea/contenteditable is focused).
- **Plan-change confirm**: rewritten from the 2-column current→new
comparison to the **single-plan layout**, branching on
`previewData.is_immediate` — immediate upgrades show prorated line items
(`new_plan.price_cents − cost_today_cents` = credit) + upfront (yearly)
/ monthly credit refill + a prorated total ("Confirm upgrade");
scheduled downgrades show `Starts {date}`, $0 due today, and an "After
that" block ("Confirm change").
- **Storybook**: `SubscriptionCheckoutSteps` stories for each new-sub /
upgrade / downgrade variation (props-driven, no API).

## Review Focus

- **Merge gate (team subscribe)** —  **resolved**: BE-1254 is now
merged in cloud `main`. The live `GET /api/billing/plans` returns
`team_credit_stops` (`TeamCreditStop` struct + validation in
`common/billing/catalog/catalog.go`, served for every workspace,
asserted by the `billing_credit_stops_contract` smoke test). The gate is
lifted.
- **Fallback safety**: with the contract live, the hardcoded DES-197
stops are now purely the OSS / pre-deploy fallback — they render only
when the API doesn't supply `team_credit_stops`. In that window a real
subscribe is still impossible (no stop `id`) and surfaces a
`teamPlan.unavailable` toast. Open question retained: hide/disable the
team CTA in that window instead of toasting?
- **Plan-change scope**: the single-plan confirm redesign covers
**personal** changes (the real `previewSubscribe` path). **Team** plan
changes route through the display-only team confirm and aren't wired to
`previewSubscribe` (BE left `PreviewSubscribeRequest` as `plan_slug`
only) — team-change proration is a follow-up.
- **Dead locale keys**: the old 2-column transition keys
(`everyMonthStarting`, `youllBeCharged`, `proratedRefund`,
`proratedCharge`, `creditsRefillTo`, `switchToPlan`, `starting`, `ends`,
`confirmPlanChange`) are now unused — can be removed in a follow-up
cleanup.
- **Out of scope**: the success "Your change is scheduled" variant from
the mockup.
- Based on `fe-934-unified-pricing-table`; base + `main` merged in to
resolve conflicts (PR is now mergeable).

## Verification

- Confirm / transition component tests green
(`SubscriptionAddPaymentPreviewWorkspace`,
`SubscriptionTransitionPreviewWorkspace`); oxlint / oxfmt clean locally;
full typecheck runs in CI.
- Each variation viewable in Storybook →
`Components/SubscriptionCheckoutSteps`.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-25 04:27:44 +00:00
Dante
0c89f5a3a7 feat: route cloud auth through the single Cloud JWT under unified_cloud_auth (FE-950) - step 3 (#12708)
## Summary

Behind `unified_cloud_auth` (default OFF), flip the two token accessors
so every cloud request rides the single Cloud JWT minted in PR2. This is
the consumer-flip phase of FE-950: PR2 built the dormant `unifiedToken`
slot; this PR makes consumers read it — and surfaces the
permanent-auth-failure path that the flip turns from graceful
degradation into a hard stop.

Stacked on #12704 (PR2), now merged; base is `main`.

## Changes

- **What**:
- `getAuthHeader()` — flag ON returns `{ Authorization: Bearer
<unifiedToken> }` (or `null` if unminted), with **no** Firebase/API-key
fallback. Flag OFF keeps the exact workspace → Firebase → API-key
cascade.
- `getAuthToken()` — flag ON returns the unified Cloud JWT (or
`undefined`); flag OFF keeps workspace → Firebase.
- Both accessors are the single seam every cloud consumer already routes
through, so the flip propagates automatically with **no edits** to
`fetchApi` (`scripts/api.ts`), `/customers/*` (authStore),
`workspaceApi`, the WebSocket (`api.ts:568`), or backend-node auth
(`app.ts:1593`).
- **Surface permanent auth failures** (answers @pythongosssss's review
on PR2). Under the flag there is no Firebase fallback, so a silent
`clearUnifiedContext()` wipe would strand every cloud request until
manual re-login — unlike the legacy path, which degrades to the Firebase
token. `refreshUnified()` and `mintAtLogin()` now emit a user-facing
error toast (keyed by error code off the existing `workspaceAuth.errors`
i18n) on the permanent codes (`ACCESS_DENIED` / `WORKSPACE_NOT_FOUND` /
`INVALID_FIREBASE_TOKEN` / `NOT_AUTHENTICATED`). `mintAtLogin()` now
resolves `false` on a permanent failure instead of rejecting an
unhandled `void`ed promise. Transient failures stay silent (proactive
refresh still retries). Also trims the verbose unified-lifecycle
comments flagged in review.
- **Breaking**: None. Flag OFF is byte-for-byte the current cascade.

## Review Focus

- **Single token, no fallback under the flag.** Tests assert
`getAuthHeader`/`getAuthToken` return only the unified token and never
call `getIdToken` or the API-key store; they return `null`/`undefined`
(not a fallback) when the token is unminted.
- **Surfacing, not recovery.** This PR makes the terminal state
*visible* (toast); the existing router auth-guard still redirects to
login on the next navigation. Active recovery (automatic re-mint on 401)
stays in the deferred safety-net PR so the toast is never a dead-end
"please re-login" with the fix one PR away.
- **Flag-OFF parity.** The full existing cascade suite runs with
`unifiedCloudAuthEnabled = false` (the `beforeEach` default) and stays
green.

## Deferred (intentional)

- **`acceptInvite` is left unchanged — still Firebase-authed.** It is
the one cloud call that intentionally keeps the raw Firebase token,
because the invite is accepted *before* the user is a member of the
target workspace. Promoting it to the unified Cloud JWT first needs a
quick check that `POST /invites/:token/accept` accepts a personal-scoped
Cloud JWT for a not-yet-member; deferred until that is verified.
`getFirebaseAuthHeader` / `getFirebaseAuthHeaderOrThrow` stay defined
(their removal belongs to the later cleanup ticket, FE-951). No
`workspaceApi.ts` change in this PR.
- **The reactive 401 re-mint + retry safety net is a follow-up.** A
clean place to intercept a `401` and re-mint once does not exist yet:
cloud requests use raw `fetch` (`/customers/*`, `/auth/token`) plus
several independent axios clients (`workspaceApi`,
`customerEventsService`, registry, manager), with no shared response
interceptor. PR2's `remintUnifiedOnce()` primitive is ready, and the
proactive buffer-based refresh (`refreshUnified`) already covers the
common token-*expiry* case, so this cross-cutting safety net (plus
deciding whether the surfacing toast escalates to a guided re-login CTA
once remint exists) lands in its own focused PR before any production
rollout. Note this is orthogonal to the surfacing above: proactive
refresh prevents expiry; it cannot prevent *revocation*, which is
exactly what triggers the now-surfaced permanent-error path.

## Tests

- Extended `authTokenPriority.test.ts`: flag-ON `getAuthHeader` returns
only the unified JWT (Firebase + API-key + workspace untouched) and
`null` when unminted; flag-ON `getAuthToken` returns the unified JWT
(not Firebase) and `undefined` when unminted. Existing cascade tests
prove flag-OFF parity.
- Added to `useWorkspaceAuth.test.ts` (red-green + regression lock): a
permanent refresh error toasts the **correct i18n key for each of the
four permanent codes** (`it.for` over 403/404/401 + a
lost-Firebase-token `NOT_AUTHENTICATED` case) and clears the slot; a
permanent login-mint error toasts and resolves `false`. Negative guards
prove the surfacing is **error-only and flag-scoped**: a transient (5xx)
refresh does **not** toast and keeps the slot, a **successful** re-mint
does not toast, and the unified lifecycle **never toasts when the flag
is OFF** (even against a rejecting backend).

## Red-Green Verification

| Commit | CI | Purpose |
|--------|-----|---------|
| `test: cover permanent unified-auth error surfacing` | 🔴
Red
([run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27455949404))
| Proves the tests catch the silent-failure gap |
| `fix: surface permanent unified-auth errors instead of failing
silently` | 🟢 Green
([run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27456200098))
| Proves the surfacing resolves it |

Part of FE-950 (single Cloud-JWT provider at login, Phase 1).
2026-06-25 03:42:57 +00:00
Dante
f19597ce81 feat(billing): deep link to open the pricing table (FE-1104) (#13001)
## Summary

Adds an in-app deep link that opens the pricing table directly, for
driving pilot users straight to subscribe (request from nav/Alex).
Resolves [FE-1104](https://linear.app/comfyorg/issue/FE-1104).

- `/?pricing=1` — on app load, open the pricing table.
- `/?pricing=team` / `/?pricing=personal` — open it on the Team /
Personal plan tab (via the existing `UnifiedPricingTable`
`initialPlanMode`).
- Gated to the **original owner** via
`useWorkspaceUI().permissions.canManageSubscriptionLifecycle` (personal
user, or a team workspace's original owner). A member or a promoted
owner is a **silent no-op**: the app loads normally, the param is
stripped, no 404 / error / toast.
- Off-cloud (OSS): the loader isn't instantiated, so the param is
ignored.
- Survives the login redirect via the preserved-query system, same as
`?invite` / `?create_workspace`.

## How

Mirrors the established URL-loader pattern (`useInviteUrlLoader` /
`useCreateWorkspaceUrlLoader`):

- `preservedQueryNamespaces.ts` / `router.ts` — register the `pricing`
namespace + tracker key.
- New `usePricingTableUrlLoader.ts` — hydrate preserved query, read
`pricing`, strip the param + `clearPreservedQuery` in a single replace
before any await, then `await fetchMembers()` (resolves the
original-owner gate; no-ops for personal) and open the table only when
the gate allows.
- `GraphCanvas.vue` — call the loader in `onMounted` after the
create-workspace loader (cloud only; not gated on the team-workspaces
flag so it also drives personal/legacy users).
- `useSubscriptionDialog.ts` — new `'deep_link'` value on
`SubscriptionDialogReason`.

## Telemetry

Eligible opens emit the existing `subscription_required_modal_opened`
PostHog event with the new `reason: 'deep_link'`. Ineligible-click
bounce rate is derivable from the autocaptured pageview URL
(`?pricing=…`), so no new event plumbing.

## Stacking / dependencies

This feature needs two sibling stacks off `main`:

- **FE-934** (`#12666`, base of this PR) — the `UnifiedPricingTable` +
`showPricingTable({ planMode })`.
- **FE-770** (`#12829`) — the `canManageSubscriptionLifecycle` gate.
**Merged into this branch**, so the diff against the FE-934 base
includes FE-770's changes until it lands. Review the single
`feat(billing): deep link…` commit. Once both land on `main`, rebase
onto `main` and the diff collapses to just this feature.

Do not merge before FE-770 and FE-934. Post-Billing-V1 follow-up.
End-state: swap the FE original-owner heuristic for the BE
workspace-level `is_original_owner` flag when it lands (removes the
members-fetch).

## Tests

- Unit (`usePricingTableUrlLoader.test.ts`, 12 cases): opens for an
original owner; `team`/`personal` tab preselect; silent no-op +
param-strip for a member/promoted owner; proves the gate is read only
after `fetchMembers` resolves; preserved-query restore;
empty/non-string/absent/unrecognized param; members-fetch failure
strips+clears without opening.
- E2E (`browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts`,
`@cloud`, 4 cases, verified locally): personal owner opens + URL
stripped; `?pricing=team` lands on the active Team tab; team original
owner opens (real `is_original_owner` + email gate); team member is a
silent no-op + URL stripped.
- Typecheck + related unit suites (`useSubscriptionDialog`,
`useWorkspaceUI`, `teamWorkspaceStore`) green.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-25 02:30:07 +00:00
Dante
988dc71955 feat(workspace): redesign Members invite UI to DES-186 (FE-768) (#12759)
## Summary

Redesigns the Settings ▸ Workspaces Members invite flow to DES-186:
email-chips invite dialog, Resend/Cancel pending-invite actions, and
explicit team-plan gating.

## Changes

- **What**:
- `InviteMemberDialogContent`: single-email link-generation flow →
comma/Enter/paste-separated email chips (`TagsInput`); batch
`createInvite` via `Promise.allSettled` — on partial failure, failed
emails stay as chips with an error toast; success shows an Invited
state.
- `PendingInvitesList`: inline Copy-link/Revoke buttons → `⋯` overflow
menu with **Resend invite** / **Cancel invite**. New
`teamWorkspaceStore.resendInvite` issues the fresh invite *before*
revoking the old one, so a failed resend never destroys the original.
- FE-built invite links removed
(`getInviteLink`/`createInviteLink`/`copyInviteLink`,
`PendingInvite.token`) — invite delivery is BE email per DES-186.
- New `useTeamPlan` composable: explicit `isOnTeamPlan` (seat-based
`maxSeats > 1` proxy until BE-1254 exposes the plan signal) replaces
`isSingleSeatPlan` branching in
`WorkspacePanelContent`/`useMembersPanel`.
- Personal/no-team-plan state per Figma 2993-14604: "To add teammates,
upgrade your plan." banner + **Upgrade to Team**; "Need more members?
Contact us" footer; upsell dialog copy → Team-plan framing; members sort
by Role column (was Join date).
- **Header layout per design (2nd commit)**: `[Search][Invite +][⋯
workspace menu]` moved from the dialog tab row into the members card
header row (the "N of M members" row) — the tab row right side is empty
in every DES-186 frame. Workspace menu extracted to
`WorkspaceMenuButton.vue`.
- **Single-member gating per design annotations**: when the owner is
alone — search hidden ("Show if more than 1 members"), Active/Pending
segmented tabs hidden, role column hidden, and the Members tab label
drops its count ("Remove '(0)' as well if it's just 1 member"). Tabs
reappear if pending invites exist so they stay manageable.
- **Breaking**: `teamWorkspaceStore` invite-link API removed (no
external consumers found in-repo).

## Review Focus

- Resend semantics (create-first, then revoke) and pending-invites state
update in `teamWorkspaceStore.resendInvite`.
- Invite-link removal assumes BE sends invite emails (DES-186 annotation
"Resends the invite email"). If BE email delivery is not yet live, this
PR should wait on that confirmation.
- Gating matrix: team-active owner → enabled; team at seat cap →
disabled+tooltip; non-team plan → upsell dialog; personal workspace →
disabled.
- Workspace `⋯` menu (Edit/Delete/Leave) now lives only on the Members
tab card header, matching DES-186 — the Plan & Credits tab no longer
exposes it (design shows the plan-card `⋯` there instead, shipped via
FE-964/FE-768b).

## Screenshots

Captured on `local.comfy.org` dev (cloud-prod backend, authenticated
session; team-plan states use client-side XHR-level API stubs for
subscription/members/invites since the test workspaces are
unsubscribed).

| Flow | Before (main) | After (this PR) |
|---|---|---|
| Members list — team plan | <img width="400" alt="before members"
src="https://github.com/user-attachments/assets/be214b95-4783-47ff-9539-59c8a33b5eb9"
/> | <img width="400" alt="after: Search/Invite/menu in the card header
row, Role column, design demo data"
src="https://github.com/user-attachments/assets/841c89d5-2a29-4eed-9c72-4a5ee8bee9f4"
/> |
| Pending invites — row actions | <img width="400" alt="before pending:
inline copy-link/revoke icons"
src="https://github.com/user-attachments/assets/1703850e-86bc-4735-81b3-7530c01ad46f"
/> | <img width="400" alt="after pending: overflow menu with
Resend/Cancel invite"
src="https://github.com/user-attachments/assets/9b1bbe03-d82b-4cf6-86f2-e01d7b898ae7"
/> |
| Team workspace with 1 member | (same chrome as members list: search +
tabs always shown) | <img width="400" alt="after: no search/tabs/role,
tab label without count, Invite + menu in card row"
src="https://github.com/user-attachments/assets/22c7f68a-eb0f-49a9-a203-516cd9b7e02d"
/> |
| Invite dialog | <img width="400" alt="before: single email, Create
link"
src="https://github.com/user-attachments/assets/c19d8bbb-feb5-4fe4-8541-6d52e6ab6600"
/> | <img width="400" alt="after: comma-separated email chips"
src="https://github.com/user-attachments/assets/101fdc7b-d6e0-4f7d-8966-894bbf16b4aa"
/> |
| Invite dialog — submit result | (copies a link) | <img width="400"
alt="after: Invited success state"
src="https://github.com/user-attachments/assets/f539a88c-0250-434c-bf4a-6ce714b30398"
/> |
| Upsell — invite without team plan | <img width="400" alt="before:
subscription required, Creator plan copy"
src="https://github.com/user-attachments/assets/bb2cb9fd-f298-4cb0-b39a-6d59061dcea1"
/> | <img width="400" alt="after: Team plan required, Upgrade to Team"
src="https://github.com/user-attachments/assets/45170ed5-63bd-469a-af12-197a8b7e09ee"
/> |
| Personal workspace — Members tab | (create-workspace hint text) | <img
width="400" alt="after: upgrade banner + disabled Invite"
src="https://github.com/user-attachments/assets/a0ae664b-2d20-4d87-900d-7a36872ecde3"
/> |

Linear:
[FE-768](https://linear.app/comfyorg/issue/FE-768/updates-to-workspaces-tab-of-settings)
(Members half; Plan & Credits panel ships separately stacked on FE-964)
2026-06-25 02:29:46 +00:00
Benjamin Lu
da55529d23 GTM-93 point Windows download at comfy.org proxy (#12974)
## Summary

- Point the website Windows desktop download URL at
`https://comfy.org/download/windows/nsis/x64`.
- Keep macOS on the existing ToDesktop URL.
- Update the download page smoke test to expect the new Windows href.

## Context

This is the frontend leg of the GTM-93 Windows MVP. ToDesktop still
controls `download.comfy.org`; instead of changing DNS, the website
sends Windows users to a controlled `comfy.org` proxy path that the
router PR handles. The proxy forwards to ToDesktop and adds a tokenized
`Content-Disposition` filename for Desktop to consume on Windows.

Linear:
https://linear.app/comfyorg/issue/GTM-93/fix-posthog-identify-call-unblock-funnel-attribution-desktop-funnel
Router PR: https://github.com/Comfy-Org/comfy-router/pull/33
Desktop PR: https://github.com/Comfy-Org/Comfy-Desktop/pull/1149

## Validation

- `pnpm --filter @comfyorg/website run typecheck`
- `pnpm --filter @comfyorg/website run build`
- `pnpm --filter @comfyorg/website exec playwright test
e2e/download.spec.ts`
- pre-commit: `pnpm typecheck`, `pnpm typecheck:website`
2026-06-24 17:29:53 -07:00
Dante
52d430d1b6 fix(billing): repoint direct-bypass billing consumers to the facade (B3) (FE-933) (#12643)
## What
**B3 — Repoint direct-bypass billing consumers to the facade.** Billing
data was read from the legacy `useSubscription` store / `authStore`
directly (empty or personal-only for team workspaces) instead of the
workspace-aware `useBillingContext` facade.

FE-933 (parent FE-903).

> **Stacked on #12622 (B2 / FE-904)** — depends on the facade `tier` /
`renewalDate` fields added there. Base is the B2 branch; retarget to
`main` once B2 merges.

## Repointed consumers
- **T3 — `SubscribeButton.vue`**: `subscribe_clicked` telemetry
`current_tier` ← facade `tier` (was wrong/empty for team users)
- **T4 — `PostHogTelemetryProvider.ts`**: PostHog `subscription_tier`
person property ← facade `tier` watch (tier-segmented analytics was
polluted for team users)
- **T5 — `FreeTierDialogContent.vue`**: next-refresh date ← facade raw
ISO `renewalDate`, formatted at the display site (the line silently
disappeared for team users)
- **`useSubscriptionActions.handleRefresh` + `SettingDialog`
credits-nav**: balance refresh ← facade `fetchBalance()` (was legacy
`/customers`-only `authActions.fetchBalance`)
- **`CurrentUserPopoverLegacy.vue`**: tier badge / balance / skeleton /
refreshes ← facade (`tier`, `balance`, `isLoading`, `fetchStatus`,
`fetchBalance`); tier name via shared `useWorkspaceTierLabel` instead of
a duplicated mapping
- **`PricingTable.vue`**: `isActiveSubscription` / `isFreeTier` / `tier`
/ yearly-vs-monthly ← facade; the billing-portal flow
(`accessBillingPortal` deep-links + proration) is intentionally
unchanged — facade `manageSubscription` is not behavior-identical

## Out of scope (triaged)
- `TopUpCreditsDialogContentLegacy` / `SubscriptionPanelContentLegacy` /
`useSubscriptionDialog` / cancellation watcher — legacy-mode-only
surfaces decommissioned by B1 (FE-966); repointing is churn, and
`useSubscriptionDialog` would create a legacy↔facade cycle
- `LegacyCreditsPanel` / `UserCredit` — deleted/orphaned by FE-964
(#12734); its successor `CreditsPanel.vue` keeps an
`authStore.lastBalanceUpdateTime` watch (no facade equivalent yet) —
follow-up after FE-964 lands

## Known semantic deltas (intentional, match shipped facade consumers)
- Balance-refresh failures no longer toast: legacy
`authActions.fetchBalance` wrapped errors with a toast; facade
`fetchBalance` rejections are void-ed, same as
`CurrentUserPopoverWorkspace` / `SubscriptionPanelContentWorkspace`.
Facade-level error surfacing is a follow-up.
- Popover skeleton keys on facade `isLoading` (init-time) rather than
per-fetch `isFetchingBalance`, matching the workspace popover.

## Tests
- New behavioral coverage: FreeTier renewal-date render/disappear,
popover tier badge + balance from facade, current-plan highlight from
facade tier+duration, facade-vs-legacy fetchBalance tripwire, PostHog
`subscription_tier` from facade tier.
- Local gates clean (typecheck / lint / format / dead-code); touched
unit files 71/71 pass.

## E2E coverage
Browser regression tests live in the stacked #12760
(`billingFacadeConsumers.spec.ts`, `@cloud`): avatar popover tier badge
+ balance, and the free-tier dialog renewal-date line (T5) rendered from
the facade. The team-user telemetry fixes (PostHog person property,
telemetry payload) are non-UI observables covered by unit tests that
mock only the facade and fail on revert.

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-25 00:09:14 +00:00
224 changed files with 15084 additions and 2937 deletions

View File

@@ -2,6 +2,7 @@ issue_enrichment:
auto_enrich:
enabled: true
reviews:
profile: assertive
high_level_summary: false
request_changes_workflow: true
auto_review:

View File

@@ -133,3 +133,24 @@ jobs:
exit 1
fi
echo '✅ No Customer.io references found'
- name: Scan dist for Cloudflare Turnstile sitekey references
run: |
set -euo pipefail
echo '🔍 Scanning for Cloudflare Turnstile sitekeys...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e '0x4AAAAAADnYZPVOpFCL_zeo' \
-e '0x4AAAAAADnYY4_Q0qxHZ5a7' \
-e '1x00000000000000000000AA' \
dist; then
echo '❌ ERROR: Cloudflare Turnstile sitekey found in dist assets!'
echo 'The per-env Turnstile sitekeys are cloud-only and must be tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Gate sitekey selection on the __DISTRIBUTION__ build define, not the runtime isCloud const'
echo '2. See getTurnstileSiteKey() in src/config/turnstile.ts'
exit 1
fi
echo '✅ No Turnstile sitekey references found'

View File

@@ -41,7 +41,7 @@ jobs:
# Allowlist bots so they don't need to sign (optional, comma-separated).
# *[bot] is a catch-all for any GitHub App bot account.
allowlist: actions-user,ampagent,claude,coderabbitai[bot],comfy-pr-bot,dependabot[bot],github-actions[bot],copilot-swe-agent[bot],devin-ai-integration[bot],*[bot]
allowlist: actions-user,ampagent,claude,comfy-pr-bot,github-actions,*[bot],Glary Bot
# Custom PR comment messages
custom-notsigned-prcomment: |

View File

@@ -83,6 +83,16 @@ const config: StorybookConfig = {
replacement:
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
},
{
find: '@/composables/useFeatureFlags',
replacement:
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
},
{
find: '@/platform/workspace/stores/teamWorkspaceStore',
replacement:
process.cwd() + '/src/storybook/mocks/teamWorkspaceStore.ts'
},
{
find: '@/utils/formatUtil',
replacement:

View File

@@ -47,6 +47,11 @@ test.describe('Download page @smoke', () => {
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).toHaveAttribute('target', '_blank')
await expect(downloadBtn).toHaveAttribute(
'href',
'https://comfy.org/download/windows/nsis/x64'
)
await expect(downloadBtn).toHaveAttribute('data-astro-prefetch', 'false')
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(githubBtn).toBeVisible()
@@ -73,7 +78,7 @@ test.describe('Download page @smoke', () => {
})
const windowsBtn = hero.locator(
'a[href="https://download.comfy.org/windows/nsis/x64"]'
'a[href="https://comfy.org/download/windows/nsis/x64"]'
)
await expect(windowsBtn).toBeVisible()
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)

View File

@@ -72,6 +72,7 @@ const buttons = computed<ButtonSpec[]>(() => {
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
:data-astro-prefetch="btn.key === 'windows' ? 'false' : undefined"
@click="captureDownloadClick(btn.key)"
>
<span class="inline-flex items-center gap-2">

View File

@@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { externalLinks } from '@/config/routes'
export const downloadUrls = {
windows: 'https://download.comfy.org/windows/nsis/x64',
windows: 'https://comfy.org/download/windows/nsis/x64',
macArm: 'https://download.comfy.org/mac/dmg/arm64'
} as const

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,7 @@ import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
/**
* getSurveyCompletedStatus fails safe: a transient 401 on `/` must not bounce a
@@ -16,51 +15,12 @@ import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
function jsonRoute(body: unknown) {
return {
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
}
}
async function mockCloudBoot(page: Page) {
// `/api/features` is the remote-config source: production builds resolve
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
// dev-only). Enable the survey so the gate is actually live.
await page.route('**/api/features', (r) =>
r.fulfill(
jsonRoute({ onboarding_survey_enabled: true } satisfies RemoteConfig)
)
)
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// Cloud user status (getUserCloudStatus) — an active account so the gate
// proceeds to the survey check instead of bouncing back to login.
await page.route('**/api/user', (r) =>
r.fulfill(jsonRoute({ status: 'active' }))
)
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
}
// `/api/features` is the remote-config source: production builds resolve
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
// dev-only). Enable the survey so the gate is actually live.
const BOOT_FEATURES = {
onboarding_survey_enabled: true
} satisfies RemoteConfig
// Genuine "not completed": the cloud backend returns 404 for a survey key that
// was never stored. This is the response that must still route to the survey.
@@ -89,22 +49,13 @@ async function mockSurveyTransient401(page: Page) {
)
}
async function bootCloud(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
// Pre-select the mock user to skip the user-select screen.
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
}
test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
test('a transient 401 on the survey check does not bounce a working user to the survey', async ({
page
}) => {
test.setTimeout(60_000)
test.slow()
await mockCloudBoot(page)
await mockCloudBoot(page, { features: BOOT_FEATURES })
await mockSurveyTransient401(page)
await bootCloud(page)
@@ -122,9 +73,9 @@ test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
test('a not-completed (404) user landing on / is routed to the survey', async ({
page
}) => {
test.setTimeout(60_000)
test.slow()
await mockCloudBoot(page)
await mockCloudBoot(page, { features: BOOT_FEATURES })
await mockSurveyNotCompleted(page)
await bootCloud(page)

View File

@@ -2,7 +2,10 @@ import { expect } from '@playwright/test'
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
import type {
BillingStatusResponse,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
import type { operations } from '@/types/comfyRegistryTypes'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
@@ -51,6 +54,20 @@ const mockSubscriptionStatus: CloudSubscriptionStatusResponse = {
end_date: FUTURE_DATE
}
// With team workspaces enabled, the facade routes a personal workspace through
// `/api/billing/*`. The cancelled-but-active state maps to `is_active: true`
// with `subscription_status: 'canceled'`; a paid tier keeps "Add credits"
// visible (free tier would swap it for "Upgrade to add credits").
const mockBillingStatus: BillingStatusResponse = {
is_active: true,
subscription_status: 'canceled',
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
has_funds: true,
cancel_at: FUTURE_DATE,
renewal_date: FUTURE_DATE
}
// ~6.3M credits — a 7-digit balance is what pushes the second action button out
// of the popover before the fix.
const mockBalance: CustomerBalanceResponse = {
@@ -105,6 +122,32 @@ const test = comfyPageFixture.extend({
})
)
// Flag-on (team workspaces enabled) routes a personal workspace through the
// workspace billing endpoints, so the popover sources its data from here.
await page.route('**/api/billing/status', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockBillingStatus)
})
)
await page.route('**/api/billing/balance', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockBalance)
})
)
await page.route('**/api/billing/plans', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ plans: [] })
})
)
await use(page)
}
})

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,26 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
test.describe('tooltips', { tag: '@vue-nodes' }, async () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
await comfyPage.settings.setSetting('LiteGraph.Node.TooltipDelay', 0)
})
test('widget value tooltips', async ({ comfyPage }) => {
const tooltip = comfyPage.page.locator('.p-tooltip-text')
await comfyPage.vueNodes.getWidgetByName('load check', 'ckpt_name').hover()
await expect(tooltip, 'displays for combos').toContainText('v1-5-pruned')
await comfyPage.vueNodes.getWidgetByName('ksampler', 'seed').hover()
await expect(tooltip, 'displays for numbers').toContainText('15668')
await comfyPage.vueNodes.getNodeLocator('6').getByLabel('text').hover()
await expect(tooltip).toBeVisible()
await expect(tooltip, "doesn't display for prompts").not.toContainText(
'purple galaxy bottle'
)
})
})

View File

@@ -0,0 +1,209 @@
# 11. In-App Agent Graph-State Integration
Date: 2026-06-25
## Status
Proposed
## Context
The In-App Agent (V0, target ~2026-07-13) adds a server-side ComfyUI agent the user chats with
from a side panel. A hard product requirement is that the agent can **read the user's live
workflow** and **write workflow changes back onto the canvas**. This ADR records the frontend
graph-state and synchronization design for that capability. It is intentionally written as
"TDD-as-code": a design record, ahead of the implementation PRs.
This decision builds directly on:
- [ADR-0001 (Merge LiteGraph)](0001-merge-litegraph-into-frontend.md) and
[ADR-0003 (Centralized Layout Management with CRDT)](0003-crdt-based-layout-system.md), which
established a Yjs CRDT store as the single source of truth for spatial state and a command/
observer model for mutations.
- The long-term architectural direction RFC ([issue #4661]), which makes a CRDT-mediated state
layer the foundation for multiplayer — and frames the agent as **just another client** of a
per-graph room.
### Forces at play
1. **A second writer.** Until now the only writer to a workflow is the local user. The agent
introduces a second, remote writer to the _same_ graph. We need conflict handling, not
last-write-wins.
2. **The backend is server-authoritative.** The cloud backend (`Comfy-Org/cloud` PR #4432)
introduces a mutable server-side **`workflow_draft`** (full save-format JSON + integer
`version`), commits edits with **compare-and-swap on `version`**, and pushes results to the
browser as a **full-document replace** over an existing **Redis-PubSub → WebSocket** bridge
(`channel:ws:{workspaceId}:u:{userId}`). Inbound chat turns go through ingest `/api/agent/*`.
3. **The frontend is moving toward decentralized CRDT.** Per ADR-0003 / #4661 the end-state is
per-graph rooms with mutation relay and CRDT merge — _not_ server-authoritative full-document
replace. The V0 backend model and the FE end-state are different shapes.
4. **Timeline.** V0 ships in ~3 weeks. The CRDT migration of the _data-model_ class of state
(node existence, widget values) is still in progress; only the _layout_ class is fully on the
Yjs store today. A true per-mutation CRDT sync for the agent is not ready for V0.
5. **No throwaway work.** Whatever we ship for V0 must be a strict subset of the #4661 end-state.
### What V0 is NOT
- The agent does **not** draw, animate, or incrementally lay out nodes on the canvas.
- The agent does **not** submit/run the workflow — the user clicks the existing Run button.
- The agent is **not** aware of viewport state (zoom/pan/cursor) — that is FE-only ephemeral
state and is never synced.
## Decision
For V0 the frontend treats the **server `workflow_draft` as the authority**, integrates agent
writes as **full-document replaces guarded by `version`**, and frames the whole interaction as a
**room-per-graph** model so it is forward-compatible with the CRDT end-state.
### Graph-state model
| State class | Source of truth (V0) | Synced to agent? |
| ---------------------------------------------------------- | ------------------------------------------------------ | ---------------- |
| Save-format **data model** (nodes, links, widgets, groups) | server `workflow_draft.content` | read + write |
| **Layout** (positions/sizes/reroutes) | within `content`; mirrors Yjs `layoutStore` (ADR-0003) | within content |
| **Selection** (selected node ids) | browser, sent per turn | per-turn input |
| **Viewport** (zoom/pan/cursor) | browser only | never |
- Each `workflow_draft` is a **room**. V0 has up to two writers: the human (via autosave-to-draft)
and the agent. The browser keeps a draft's tab **alive in memory** while connected so agent
pushes apply even when the tab is unfocused (lazy apply on refocus) — the CRDT room behavior,
minus true merge.
- The browser **autosaves canvas edits into the draft** so the server copy reflects unsaved work
before a turn. Agent awareness then reduces to "read the draft + read the selection ids".
### Synchronization & conflict handling
The agent→browser push is a `draft_patch { workflow_id, content, version, base_version }`.
```
agent commits draft (CAS version N → N+1)
→ draft_patch { content, version: N+1, base_version: N } over Redis → WS
browser:
tab.version == base_version → apply full replace, adopt new version (happy path)
tab.version != base_version → MERGE DIALOG: [Accept agent's] [Keep mine] [Open in new tab]
```
- **Apply** = load the full save-format graph into the target tab (a destructive variant of the
existing `loadGraphData` path) and adopt `version` as the tab's new base.
- **Conflict** (user edited the graph during the agent's turn) surfaces a dialog rather than
silently clobbering. We explicitly reject **graph-locking** as the primary mechanism: a
lost/duplicated backend message could leave the graph _permanently_ locked. A presentational
"agent editing…" hint MAY be driven by the optional backend edit-turn lease, but it is never on
the correctness path.
- The agent can also target a **new tab** (`target: "new_tab"`) for unrelated requests — a
non-destructive load, no conflict possible.
### Awareness & run
- A chat turn carries `{ content, selection?: NodeId[], attachments?, target }`. `selection` is
the set of node ids from the canvas (the panel's `@`-tag chips). The agent reads the draft data
model server-side; the browser does not scrape the live graph.
- The agent never runs the workflow in V0; after a write it tells the user the graph is loaded and
to click Run. Submit is gated off for the in-app client.
### Migration path to the CRDT end-state (#4661)
This is the load-bearing reason the V0 shape is acceptable. The transition is a payload swap, not
a rewrite:
1. **V0:** full-document `draft_patch`; convergence via `version` CAS + merge dialog.
2. **When the data-model class finishes migrating to the Yjs store:** `draft_patch` gains a
**mutation-list** variant applied via `layoutStore.applyOperation(op)` tagged
`LayoutSource.External` with a dedicated agent actor id (the store already tracks
source/actor). Full replace remains the fallback for large rewrites / new tabs.
3. **Multiplayer:** server relays mutations; both client and server write + reconcile via CRDT
merge, retiring the merge dialog for fine-grained edits. The room model, actor/source tracking,
and the Redis channel are unchanged.
### Prototype implementation (this PR)
This PR ships TDD-as-code for **both** layers so the V0 shape and the end-state are exercised, not
just described. Nothing is wired into the chat UI yet.
**Layer A — V0 server-draft path** (`src/platform/agent/common/`)
- `agentProtocol.ts` — typed FE⇄agent wire contract + `parseAgentEvent()` decoder for untrusted
WS payloads.
- `draftReconciler.ts` — pure `version`-CAS decision (`apply` / `conflict` / `stale`).
- `useAgentDraftSync.ts` — composable tracking per-workflow base `version` and the three merge
outcomes via injected canvas ports.
**Layer B — CRDT peer path (the #4661 migration target)** (`src/platform/agent/crdt/`)
- `agentRoom.ts` — a `Y.Doc`-backed room whose top-level types (`nodes`/`links`/`reroutes`) mirror
the layout store; real `encodeStateVector`/`diffSince`/`applyRemoteUpdate` + presence.
- `roomSync.ts` — transport-agnostic two-phase Yjs sync (state-vector handshake + live updates),
modelled on `y-protocols/sync`; runs over the existing Redis→WS bridge.
- `agentRoomManager.ts` — room lifecycle: a tab switch is a `join`/`leave`; rooms stay alive in
memory while referenced or pinned (agent editing a backgrounded workflow).
- `roomDocBinding.ts` — generic, layer-safe bidirectional `Y.Doc` binding with origin-tag echo
guards.
**Wiring (renderer layer)**`src/renderer/core/layout/agent/bindRoomToLayoutStore.ts` binds a
room to the **real** `layoutStore` singleton via its existing (`"future feature"`)
`getYDoc()`/`applyUpdate` surface. A test drives an agent room edit into the live store and asserts
the version bump — concrete proof of the "few lines of code" claim. Layout binds today; data-model
mutations bind the same way once that class finishes migrating to the store.
**Local chat state**`src/platform/agent/session/agentSessionStore.ts` is an **Immer** reducer
for streaming deltas / tool-call lifecycle. Chat is single-client, so it is deliberately _not_ a
CRDT: Yjs owns graph state, Immer owns local UI state, and the two never mix.
A layering constraint surfaced during this work: `platform/` may not import `renderer/`
(base → platform → workbench → renderer). Hence the pure CRDT core lives in `platform/` and only
the thin singleton wiring lives in `renderer/`.
### Alternatives considered
- **Build true CRDT (Yjs) agent sync in V0** — rejected: data-model CRDT migration incomplete;
misses the timeline; high risk.
- **Always open a new tab for agent output** — rejected: simplest, but fails "update what I'm
looking at" and causes tab sprawl.
- **Graph-locking / "agent mode" that blocks user edits** — rejected as primary mechanism:
permanent-lock dead-end risk on message loss.
- **Browser scrapes the live graph per turn** — rejected: invites client/server drift;
autosave-to-draft keeps the canonical server copy current instead.
## Consequences
### Positive
- Hits the V0 timeline by reusing the backend draft + the existing Redis→WS transport; no new
realtime infrastructure on the frontend.
- A clean, documented bridge to the #4661 CRDT end-state: room-per-graph, agent-as-client, and the
ADR-0003 source/actor model all carry forward unchanged.
- Conflicts never silently destroy user work; the dialog appears only in the genuine
concurrent-edit case.
- Awareness is minimal and robust: read the draft + the selection ids.
### Negative
- Full-document replace is coarse-grained: a concurrent user edit during an agent turn collides
and must go through the merge dialog rather than merging automatically.
- Introduces a frontend-owned **base-`version` lifecycle** (obtain on draft open, bump on apply
and on autosave). If this drifts, the merge dialog can mis-fire — this is the main correctness
risk to get right.
- The agent→browser event schema becomes a **cross-repo contract** (Go ⇄ TS); it must be
versioned and drift-guarded.
- A temporary semantic gap with V0 product copy ("Agent generation does not impact the graph")
that must be reconciled now that write-to-graph is in scope.
## Notes
### Open questions
1. **Tab closed mid-edit.** If the user closes the draft's tab while the agent is editing, do
pending changes invalidate (and report back to the agent) or persist server-side and reopen on
a new tab? Affects the room lifecycle.
2. **Base-`version` lifecycle.** Exact points where the tab obtains/bumps its base `version` so
the merge dialog cannot mis-fire or desync.
3. **Event schema home & versioning.** Where the shared `draft_patch` / `agent_message_delta` /
`agent_tool_call` / `agent_message_done` contract lives and how drift is caught.
### References
- [ADR-0001](0001-merge-litegraph-into-frontend.md), [ADR-0003](0003-crdt-based-layout-system.md)
- RFC: Long-Term Architectural Direction for ComfyUI_frontend (issue #4661)
- Backend slice: `Comfy-Org/cloud` PR #4432 (`workflow_draft`, ingest `/api/agent/*`, Redis PubSub)
- Existing FE entry points: `src/renderer/core/layout/store/layoutStore.ts`,
`src/renderer/core/layout/operations/layoutMutations.ts`, `src/scripts/app.ts` (`loadGraphData`)

View File

@@ -20,6 +20,7 @@ An Architecture Decision Record captures an important architectural decision mad
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| [0009](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Subgraph Promoted Widgets Use Linked Inputs | Proposed | 2026-05-05 |
| [0010](0010-remove-nx-orchestration.md) | Remove Nx Orchestration | Accepted | 2026-05-19 |
| [0011](0011-in-app-agent-graph-state-integration.md) | In-App Agent Graph-State Integration | Proposed | 2026-06-25 |
## Creating a New ADR

View File

@@ -57,6 +57,8 @@ const config: KnipConfig = {
// Marketing media tooling — adopted by pages in a follow-up PR
'apps/website/src/components/common/SiteVideo.vue',
'apps/website/src/utils/marketingImage.ts',
// In-app agent wire contract (ADR-0011) — public types pending UI integration
'src/platform/agent/common/agentProtocol.ts',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js',
// Devtools extensions, included dynamically

View File

@@ -111,6 +111,7 @@
"firebase": "catalog:",
"fuse.js": "^7.0.0",
"glob": "catalog:",
"immer": "catalog:",
"jsonata": "catalog:",
"loglevel": "^1.9.2",
"marked": "^15.0.11",

View File

@@ -52,6 +52,8 @@
--color-gold-500: #fdab34;
--color-gold-600: #fd9903;
--color-credit: #fabc25;
--color-coral-500: #f75951;
--color-coral-600: #e04e48;
--color-coral-700: #b33a3a;
@@ -236,6 +238,8 @@
--secondary-background: var(--color-smoke-200);
--secondary-background-hover: var(--color-smoke-400);
--secondary-background-selected: var(--color-smoke-600);
--tertiary-background: var(--color-smoke-400);
--tertiary-background-hover: var(--color-smoke-500);
--base-background: var(--color-white);
--primary-background: var(--color-azure-400);
--primary-background-hover: var(--color-cobalt-800);
@@ -384,6 +388,8 @@
--secondary-background: var(--color-charcoal-600);
--secondary-background-hover: var(--color-charcoal-400);
--secondary-background-selected: var(--color-charcoal-200);
--tertiary-background: var(--color-charcoal-400);
--tertiary-background-hover: var(--color-charcoal-300);
--base-background: var(--color-charcoal-800);
--primary-background: var(--color-azure-600);
--primary-background-hover: var(--color-azure-400);
@@ -554,6 +560,8 @@
--color-secondary-background: var(--secondary-background);
--color-secondary-background-hover: var(--secondary-background-hover);
--color-secondary-background-selected: var(--secondary-background-selected);
--color-tertiary-background: var(--tertiary-background);
--color-tertiary-background-hover: var(--tertiary-background-hover);
--color-primary-background: var(--primary-background);
--color-primary-background-hover: var(--primary-background-hover);
--color-destructive-background: var(--destructive-background);

20
pnpm-lock.yaml generated
View File

@@ -258,6 +258,9 @@ catalogs:
husky:
specifier: ^9.1.7
version: 9.1.7
immer:
specifier: ^11.1.8
version: 11.1.8
jiti:
specifier: 2.6.1
version: 2.6.1
@@ -579,6 +582,9 @@ importers:
glob:
specifier: 'catalog:'
version: 13.0.6
immer:
specifier: 'catalog:'
version: 11.1.8
jsonata:
specifier: 'catalog:'
version: 2.1.0
@@ -654,7 +660,7 @@ importers:
version: 4.5.0(eslint@10.4.0(jiti@2.6.1))(jsonc-eslint-parser@2.4.0)(vue-eslint-parser@10.4.0(eslint@10.4.0(jiti@2.6.1)))(yaml-eslint-parser@1.3.0)
'@lobehub/i18n-cli':
specifier: 'catalog:'
version: 1.26.1(@types/react@19.1.9)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.21.0)(zod@3.25.76)
version: 1.26.1(@types/react@19.1.9)(immer@11.1.8)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.21.0)(zod@3.25.76)
'@pinia/testing':
specifier: 'catalog:'
version: 1.0.3(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))
@@ -6042,6 +6048,9 @@ packages:
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immer@11.1.8:
resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -10521,7 +10530,7 @@ snapshots:
- react-devtools-core
- utf-8-validate
'@lobehub/i18n-cli@1.26.1(@types/react@19.1.9)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.21.0)(zod@3.25.76)':
'@lobehub/i18n-cli@1.26.1(@types/react@19.1.9)(immer@11.1.8)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.21.0)(zod@3.25.76)':
dependencies:
'@lobehub/cli-ui': 1.13.0(@types/react@19.1.9)
'@yutengjing/eld': 0.0.2
@@ -10552,7 +10561,7 @@ snapshots:
unified: 11.0.5
unist-util-visit: 5.1.0
update-notifier: 7.3.1
zustand: 5.0.11(@types/react@19.1.9)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
zustand: 5.0.11(@types/react@19.1.9)(immer@11.1.8)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
transitivePeerDependencies:
- '@types/react'
- bufferutil
@@ -14361,6 +14370,8 @@ snapshots:
immediate@3.0.6: {}
immer@11.1.8: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -17949,9 +17960,10 @@ snapshots:
zod@4.3.6: {}
zustand@5.0.11(@types/react@19.1.9)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
zustand@5.0.11(@types/react@19.1.9)(immer@11.1.8)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
optionalDependencies:
'@types/react': 19.1.9
immer: 11.1.8
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)

View File

@@ -95,6 +95,7 @@ catalog:
gsap: ^3.14.2
happy-dom: ^20.8.9
husky: ^9.1.7
immer: ^11.1.8
jiti: 2.6.1
jsdom: ^27.4.0
jsonata: ^2.1.0

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,6 @@ const singleErrorCard: ErrorCardData = {
title: 'CLIPTextEncode',
nodeId: createNodeExecutionId([10]),
nodeTitle: 'CLIP Text Encode (Prompt)',
isSubgraphNode: false,
errors: [
{
message: 'Required input "text" is missing.',
@@ -40,7 +39,6 @@ const multipleErrorsCard: ErrorCardData = {
title: 'VAEDecode',
nodeId: createNodeExecutionId([24]),
nodeTitle: 'VAE Decode',
isSubgraphNode: false,
errors: [
{
message: 'Required input "samples" is missing.',
@@ -58,7 +56,6 @@ const runtimeErrorCard: ErrorCardData = {
title: 'KSampler',
nodeId: createNodeExecutionId([45]),
nodeTitle: 'KSampler',
isSubgraphNode: false,
errors: [
{
message: 'OutOfMemoryError: CUDA out of memory. Tried to allocate 1.2GB.',
@@ -73,20 +70,6 @@ const runtimeErrorCard: ErrorCardData = {
]
}
const subgraphErrorCard: ErrorCardData = {
id: 'node-3:15',
title: 'KSampler',
nodeId: createNodeExecutionId([3, 15]),
nodeTitle: 'Nested KSampler',
isSubgraphNode: true,
errors: [
{
message: 'Latent input is required.',
details: ''
}
]
}
const promptOnlyCard: ErrorCardData = {
id: '__prompt__',
title: 'Prompt has no outputs.',
@@ -104,13 +87,6 @@ export const SingleValidationError: Story = {
}
}
/** Subgraph node error — shows "Enter subgraph" button */
export const WithEnterSubgraphButton: Story = {
args: {
card: subgraphErrorCard
}
}
/** Multiple validation errors on one node */
export const MultipleErrors: Story = {
args: {

View File

@@ -79,7 +79,6 @@ describe('ErrorNodeCard.vue', () => {
},
rightSidePanel: {
locateNode: 'Locate Node',
enterSubgraph: 'Enter Subgraph',
errorLog: 'Error log',
findOnGithubTooltip: 'Search GitHub issues for related problems',
getHelpTooltip:

View File

@@ -21,15 +21,6 @@
</span>
</span>
<div class="flex shrink-0 items-center">
<Button
v-if="card.isSubgraphNode"
variant="secondary"
size="sm"
class="shrink-0 focus-visible:ring-inset"
@click.stop="handleEnterSubgraph"
>
{{ t('rightSidePanel.enterSubgraph') }}
</Button>
<Button
v-if="hasRuntimeError"
variant="textonly"
@@ -202,7 +193,6 @@ const { card, compact = false } = defineProps<{
const emit = defineEmits<{
locateNode: [nodeId: string]
enterSubgraph: [nodeId: string]
copyToClipboard: [text: string]
}>()
@@ -233,12 +223,6 @@ function handleLocateNode() {
}
}
function handleEnterSubgraph() {
if (card.nodeId) {
emit('enterSubgraph', card.nodeId)
}
}
function handleCopyError(idx: number) {
const details = displayedDetailsMap.value[idx]
const message = getCopyMessage(card.errors[idx])

View File

@@ -11,7 +11,6 @@ import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
const mockFocusNode = vi.hoisted(() => vi.fn())
const mockEnterSubgraph = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/app', () => ({
app: {
@@ -35,16 +34,9 @@ vi.mock('@/composables/useCopyToClipboard', () => ({
}))
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: vi.fn(() => ({
fitView: vi.fn()
}))
}))
vi.mock('@/composables/canvas/useFocusNode', () => ({
useFocusNode: vi.fn(() => ({
focusNode: mockFocusNode,
enterSubgraph: mockEnterSubgraph
focusNode: mockFocusNode
}))
}))

View File

@@ -249,7 +249,6 @@
:card="card"
:compact="isSingleNodeSelected"
@locate-node="handleLocateNode"
@enter-subgraph="handleEnterSubgraph"
@copy-to-clipboard="copyToClipboard"
/>
</div>
@@ -357,7 +356,7 @@ const ErrorPanelSurveyCta =
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { focusNode } = useFocusNode()
const { openGitHubIssues, contactSupport } = useErrorActions()
const rightSidePanelStore = useRightSidePanelStore()
const missingModelStore = useMissingModelStore()
@@ -523,8 +522,4 @@ function handleReplaceGroup(group: SwapNodeGroup) {
function handleReplaceAll() {
replaceAllGroups(swapNodeGroups.value)
}
function handleEnterSubgraph(nodeId: string) {
enterSubgraph(nodeId, errorNodeCache.value)
}
</script>

View File

@@ -16,7 +16,6 @@ export interface ErrorCardData {
nodeId?: NodeExecutionId
nodeTitle?: string
graphNodeId?: string
isSubgraphNode?: boolean
errors: ErrorItem[]
}

View File

@@ -671,30 +671,6 @@ describe('useErrorGroups', () => {
expect(nodeIds).toEqual(['1', '2', '10'])
})
it('marks only nested execution paths as subgraph node cards', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'1:20': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.cards).toMatchObject([
{ nodeId: '1', isSubgraphNode: false },
{ nodeId: '1:20', isSubgraphNode: true }
])
})
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {

View File

@@ -130,7 +130,6 @@ function createErrorCard(
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: nodeId.includes(':'),
errors: []
}
}

View File

@@ -1,37 +1,18 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { h, ref } from 'vue'
import { defineComponent, h, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import type { BalanceInfo, SubscriptionInfo } from '@/composables/billing/types'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
// Mock all firebase modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn()
}))
// Mock pinia
vi.mock('pinia')
// Mock showSettingsDialog and showTopUpCreditsDialog
const mockShowSettingsDialog = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
// Mock the settings dialog composable
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: vi.fn(() => ({
show: mockShowSettingsDialog,
@@ -40,7 +21,6 @@ vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
}))
}))
// Mock window.open
const originalWindowOpen = window.open
beforeEach(() => {
window.open = vi.fn()
@@ -50,7 +30,6 @@ afterAll(() => {
window.open = originalWindowOpen
})
// Mock the useCurrentUser composable
const mockHandleSignOut = vi.fn()
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: vi.fn(() => ({
@@ -61,60 +40,50 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
}))
}))
// Mock the useAuthActions composable
const mockLogout = vi.fn()
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
fetchBalance: vi.fn().mockResolvedValue(undefined),
logout: mockLogout
}))
}))
// Mock the dialog service
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
}))
}))
// Mock the authStore with hoisted state for per-test manipulation
const mockAuthStoreState = vi.hoisted(() => ({
balance: {
amount_micros: 100_000,
effective_balance_micros: 100_000,
currency: 'usd'
} as {
amount_micros?: number
effective_balance_micros?: number
currency: string
},
isFetchingBalance: false
}))
function makeSubscription(
overrides: Partial<SubscriptionInfo> = {}
): SubscriptionInfo {
return {
isActive: true,
tier: 'CREATOR',
duration: 'MONTHLY',
planSlug: null,
renewalDate: null,
endDate: null,
isCancelled: false,
hasFunds: true,
...overrides
}
}
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: vi
.fn()
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),
balance: mockAuthStoreState.balance,
isFetchingBalance: mockAuthStoreState.isFetchingBalance
}))
}))
// Mock the useSubscription composable
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
const mockFetchBalance = vi.fn().mockResolvedValue(undefined)
const mockIsActiveSubscription = ref(true)
const mockIsFreeTier = ref(false)
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: vi.fn(() => ({
isActiveSubscription: ref(true),
const mockTier = ref<SubscriptionInfo['tier']>('CREATOR')
const mockSubscription = ref<SubscriptionInfo | null>(makeSubscription())
const mockBalance = ref<BalanceInfo | null>(null)
const mockIsLoading = ref(false)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: mockIsActiveSubscription,
isFreeTier: mockIsFreeTier,
subscriptionTierName: ref('Creator'),
subscriptionTier: ref('CREATOR'),
fetchStatus: mockFetchStatus
tier: mockTier,
subscription: mockSubscription,
balance: mockBalance,
isLoading: mockIsLoading,
fetchStatus: mockFetchStatus,
fetchBalance: mockFetchBalance
}))
}))
// Mock the useSubscriptionDialog composable
const mockShowPricingTable = vi.fn()
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
@@ -127,7 +96,6 @@ vi.mock(
})
)
// Mock UserAvatar component
vi.mock('@/components/common/UserAvatar.vue', () => ({
default: {
name: 'UserAvatarMock',
@@ -137,22 +105,10 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
}
}))
// Mock UserCredit component
vi.mock('@/components/common/UserCredit.vue', () => ({
default: {
name: 'UserCreditMock',
render() {
return h('div', 'Credit: 100')
}
}
}))
// Mock formatCreditsFromCents
vi.mock('@/base/credits/comfyCredits', () => ({
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
}))
// Mock useExternalLink
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: vi.fn(() => ({
buildDocsUrl: vi.fn((path) => `https://docs.comfy.org${path}`),
@@ -162,14 +118,12 @@ vi.mock('@/composables/useExternalLink', () => ({
}))
}))
// Mock useTelemetry
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackAddApiCreditButtonClicked: vi.fn()
}))
}))
// Mock isCloud with hoisted state for per-test toggling
const mockIsCloud = vi.hoisted(() => ({ value: true }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
@@ -178,25 +132,37 @@ vi.mock('@/platform/distribution/types', () => ({
}))
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
default: {
default: defineComponent({
name: 'SubscribeButtonMock',
render() {
return h('div', 'Subscribe Button')
emits: ['subscribed'],
setup(_, { emit }) {
return () =>
h(
'button',
{
'data-testid': 'subscribe-button-mock',
onClick: () => emit('subscribed')
},
'Subscribe Button'
)
}
}
})
}))
describe('CurrentUserPopoverLegacy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockIsActiveSubscription.value = true
mockIsFreeTier.value = false
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 100_000,
mockTier.value = 'CREATOR'
mockSubscription.value = makeSubscription()
mockBalance.value = {
amountMicros: 100_000,
effectiveBalanceMicros: 100_000,
currency: 'usd'
}
mockAuthStoreState.isFetchingBalance = false
mockIsLoading.value = false
})
function renderComponent() {
@@ -230,7 +196,47 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('test@example.com')).toBeInTheDocument()
})
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
it('fetches the balance through the billing facade on mount', () => {
renderComponent()
expect(mockFetchBalance).toHaveBeenCalled()
})
it('refreshes subscription status through the billing facade after subscribing', async () => {
mockIsActiveSubscription.value = false
const { user } = renderComponent()
await user.click(screen.getByTestId('subscribe-button-mock'))
expect(mockFetchStatus).toHaveBeenCalled()
})
describe('subscription tier badge', () => {
it('renders the tier name derived from the facade tier', () => {
renderComponent()
expect(screen.getByText('Creator')).toBeInTheDocument()
})
it('renders the yearly tier name when the facade subscription is annual', () => {
mockSubscription.value = makeSubscription({ duration: 'ANNUAL' })
renderComponent()
expect(screen.getByText('Creator Yearly')).toBeInTheDocument()
})
it('hides the badge when the facade reports no tier', () => {
mockTier.value = null
mockSubscription.value = null
renderComponent()
expect(screen.queryByText('Creator')).not.toBeInTheDocument()
})
})
it('formats and displays the facade balance', () => {
renderComponent()
expect(formatCreditsFromCents).toHaveBeenCalledWith({
@@ -245,6 +251,14 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('shows a skeleton instead of the balance while billing is loading', () => {
mockIsLoading.value = true
renderComponent()
expect(screen.queryByText('1000')).not.toBeInTheDocument()
})
it('renders logout menu item with correct text', () => {
renderComponent()
@@ -324,11 +338,11 @@ describe('CurrentUserPopoverLegacy', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
describe('effective_balance_micros handling', () => {
it('uses effective_balance_micros when present (positive balance)', () => {
mockAuthStoreState.balance = {
amount_micros: 200_000,
effective_balance_micros: 150_000,
describe('facade balance handling', () => {
it('uses effectiveBalanceMicros when present (positive balance)', () => {
mockBalance.value = {
amountMicros: 200_000,
effectiveBalanceMicros: 150_000,
currency: 'usd'
}
@@ -345,10 +359,10 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1500')).toBeInTheDocument()
})
it('uses effective_balance_micros when zero', () => {
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 0,
it('uses effectiveBalanceMicros when zero', () => {
mockBalance.value = {
amountMicros: 100_000,
effectiveBalanceMicros: 0,
currency: 'usd'
}
@@ -365,10 +379,10 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('0')).toBeInTheDocument()
})
it('uses effective_balance_micros when negative', () => {
mockAuthStoreState.balance = {
amount_micros: 0,
effective_balance_micros: -50_000,
it('uses effectiveBalanceMicros when negative', () => {
mockBalance.value = {
amountMicros: 0,
effectiveBalanceMicros: -50_000,
currency: 'usd'
}
@@ -385,9 +399,9 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('-500')).toBeInTheDocument()
})
it('falls back to amount_micros when effective_balance_micros is missing', () => {
mockAuthStoreState.balance = {
amount_micros: 100_000,
it('falls back to amountMicros when effectiveBalanceMicros is missing', () => {
mockBalance.value = {
amountMicros: 100_000,
currency: 'usd'
}
@@ -404,10 +418,8 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
mockAuthStoreState.balance = {
currency: 'usd'
}
it('falls back to 0 when the facade reports no balance', () => {
mockBalance.value = null
renderComponent()
@@ -466,8 +478,11 @@ describe('CurrentUserPopoverLegacy', () => {
})
it('hides subscribe button', () => {
mockIsActiveSubscription.value = false
renderComponent()
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
expect(
screen.queryByTestId('subscribe-button-mock')
).not.toBeInTheDocument()
})
it('still shows partner nodes menu item', () => {

View File

@@ -32,12 +32,7 @@
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="authStore.isFetchingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
}}</span>
@@ -162,16 +157,15 @@ import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useWorkspaceTierLabel } from '@/platform/workspace/composables/useWorkspaceTierLabel'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
const emit = defineEmits<{
close: []
@@ -181,25 +175,29 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useAuthActions()
const authStore = useAuthStore()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {
isActiveSubscription,
isFreeTier,
subscriptionTierName,
subscriptionTier,
fetchStatus
} = useSubscription()
tier,
subscription,
balance,
isLoading,
fetchStatus,
fetchBalance
} = useBillingContext()
const { formatTierName } = useWorkspaceTierLabel()
const subscriptionDialog = useSubscriptionDialog()
const { locale } = useI18n()
const subscriptionTierName = computed(() =>
formatTierName(tier.value, subscription.value?.duration === 'ANNUAL')
)
const formattedBalance = computed(() => {
const cents =
authStore.balance?.effective_balance_micros ??
authStore.balance?.amount_micros ??
0
balance.value?.effectiveBalanceMicros ?? balance.value?.amountMicros ?? 0
return formatCreditsFromCents({
cents,
locale: locale.value,
@@ -211,12 +209,12 @@ const formattedBalance = computed(() => {
})
const canUpgrade = computed(() => {
const tier = subscriptionTier.value
const currentTier = tier.value
return (
tier === 'FREE' ||
tier === 'FOUNDERS_EDITION' ||
tier === 'STANDARD' ||
tier === 'CREATOR'
currentTier === 'FREE' ||
currentTier === 'FOUNDERS_EDITION' ||
currentTier === 'STANDARD' ||
currentTier === 'CREATOR'
)
})
@@ -270,6 +268,6 @@ const handleSubscribed = async () => {
}
onMounted(() => {
void authActions.fetchBalance()
void fetchBalance()
})
</script>

View File

@@ -22,6 +22,8 @@ export const buttonVariants = cva({
link: 'bg-transparent text-muted-foreground hover:text-base-foreground',
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
tertiary:
'bg-tertiary-background text-base-foreground hover:bg-tertiary-background-hover',
gradient:
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
},
@@ -54,6 +56,7 @@ const variants = [
'destructive-textonly',
'link',
'base',
'tertiary',
'overlay-white',
'gradient'
] as const satisfies Array<ButtonVariants['variant']>

View File

@@ -13,7 +13,8 @@ import { cn } from '@comfyorg/tailwind-utils'
import Slider from '@/components/ui/slider/Slider.vue'
import {
DEFAULT_TEAM_PLAN_STOP_INDEX,
TEAM_PLAN_CREDIT_STOPS
TEAM_PLAN_CREDIT_STOPS,
getStopDiscountedMonthlyUsd
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import type { CreditStop } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
@@ -83,7 +84,7 @@ const effectiveDiscountPercent = computed(() =>
: current.value.discountPercentYearly
)
const discountedMonthly = computed(() =>
Math.round(current.value.usd * (1 - effectiveDiscountPercent.value / 100))
getStopDiscountedMonthlyUsd(current.value, cycle)
)
const saveAmount = computed(() => current.value.usd - discountedMonthly.value)
const hasDiscount = computed(() => effectiveDiscountPercent.value > 0)

View File

@@ -0,0 +1,232 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TurnstileApi } from '@/composables/auth/turnstileScript'
const TURNSTILE_SRC =
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
const fakeApi = (): TurnstileApi => ({
render: vi.fn(() => 'widget-id'),
reset: vi.fn(),
remove: vi.fn()
})
/**
* Controllable stand-in for the injected <script>. We never insert a real
* external script because jsdom would try (and fail) to fetch it and fire its
* own `error` event, making `load` impossible to simulate deterministically.
* Instead `createElement`/`querySelector`/`appendChild` are spied to route
* through this fake, so the test drives `load`/`error`/timeout itself.
*/
class FakeScript {
src = ''
async = false
private handlers: Record<string, Array<(e: Event) => void>> = {}
addEventListener(type: string, cb: (e: Event) => void) {
;(this.handlers[type] ??= []).push(cb)
}
dispatchEvent(event: Event): boolean {
for (const cb of this.handlers[event.type] ?? []) cb(event)
return true
}
remove() {
const i = inserted.indexOf(this)
if (i >= 0) inserted.splice(i, 1)
}
}
let inserted: FakeScript[] = []
const scriptEl = (): FakeScript | null =>
inserted.find((s) => s.src === TURNSTILE_SRC) ?? null
const scriptCount = () => inserted.filter((s) => s.src === TURNSTILE_SRC).length
/**
* The module keeps a private singleton promise, so each test imports a fresh
* copy after `vi.resetModules()`.
*/
async function freshLoadTurnstile() {
vi.resetModules()
const mod = await import('@/composables/auth/turnstileScript')
return mod.loadTurnstile
}
describe('loadTurnstile', () => {
beforeEach(() => {
inserted = []
delete window.turnstile
const realCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tag: string) =>
tag === 'script'
? (new FakeScript() as unknown as HTMLElement)
: realCreateElement(tag)
)
vi.spyOn(document, 'querySelector').mockImplementation((sel: string) =>
typeof sel === 'string' && sel.includes('challenges.cloudflare.com')
? (scriptEl() as unknown as Element | null)
: null
)
vi.spyOn(document.head, 'appendChild').mockImplementation((node: Node) => {
inserted.push(node as unknown as FakeScript)
return node
})
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
it('resolves immediately with the existing global and appends no script', async () => {
const api = fakeApi()
window.turnstile = api
const loadTurnstile = await freshLoadTurnstile()
await expect(loadTurnstile()).resolves.toBe(api)
expect(scriptEl()).toBeNull()
})
it('appends the script and resolves once it loads and exposes the global', async () => {
const loadTurnstile = await freshLoadTurnstile()
const promise = loadTurnstile()
const el = scriptEl()
expect(el).not.toBeNull()
expect(el?.async).toBe(true)
const api = fakeApi()
window.turnstile = api
el!.dispatchEvent(new Event('load'))
await expect(promise).resolves.toBe(api)
})
it('caches the in-flight promise so concurrent callers share one load', async () => {
const loadTurnstile = await freshLoadTurnstile()
const p1 = loadTurnstile()
const p2 = loadTurnstile()
expect(p1).toBe(p2)
expect(scriptCount()).toBe(1)
})
it('polls for the global when it is published asynchronously after the load event', async () => {
vi.useFakeTimers()
const loadTurnstile = await freshLoadTurnstile()
const promise = loadTurnstile()
scriptEl()!.dispatchEvent(new Event('load'))
// global published shortly after load
const api = fakeApi()
window.turnstile = api
await vi.advanceTimersByTimeAsync(50)
await expect(promise).resolves.toBe(api)
// tag stays in place on success
expect(scriptEl()).not.toBeNull()
})
it('rejects and clears the cache when the global never appears after load (poll timeout)', async () => {
vi.useFakeTimers()
const loadTurnstile = await freshLoadTurnstile()
const promise = loadTurnstile()
scriptEl()!.dispatchEvent(new Event('load'))
// global never published; deadline elapses
const assertion = expect(promise).rejects.toThrow(/timed out/i)
await vi.advanceTimersByTimeAsync(10_000)
await assertion
// dead tag is removed so a later retry starts clean
expect(scriptEl()).toBeNull()
// cache was reset → a later call starts a brand-new load
const retry = loadTurnstile()
expect(retry).not.toBe(promise)
// settle the throwaway retry so it doesn't leak a 10s timer
retry.catch(() => {})
scriptEl()!.dispatchEvent(new Event('error'))
await expect(retry).rejects.toThrow()
})
it('rejects, removes the self-appended script, and clears the cache on load error', async () => {
const loadTurnstile = await freshLoadTurnstile()
const promise = loadTurnstile()
scriptEl()!.dispatchEvent(new Event('error'))
await expect(promise).rejects.toThrow(/failed to load/i)
expect(scriptEl()).toBeNull()
// cache was reset → a later call starts a brand-new load
const retry = loadTurnstile()
expect(retry).not.toBe(promise)
// settle the throwaway retry so it doesn't leak a 10s timer
retry.catch(() => {})
scriptEl()!.dispatchEvent(new Event('error'))
await expect(retry).rejects.toThrow()
})
it('rejects, removes the script, and clears the cache on timeout', async () => {
vi.useFakeTimers()
const loadTurnstile = await freshLoadTurnstile()
const promise = loadTurnstile()
const assertion = expect(promise).rejects.toThrow(/timed out/i)
vi.advanceTimersByTime(10_000)
await assertion
expect(scriptEl()).toBeNull()
})
it('reuses a pre-existing script tag and resolves promptly once the global appears (no duplicate, tag left in place)', async () => {
vi.useFakeTimers()
const existing = new FakeScript()
existing.src = TURNSTILE_SRC
inserted.push(existing)
const loadTurnstile = await freshLoadTurnstile()
const promise = loadTurnstile()
// no duplicate appended
expect(scriptCount()).toBe(1)
// The pre-existing tag's load event may have already fired before we
// attached listeners, so resolution must come from polling for the global
// rather than from a (dead) load event.
const api = fakeApi()
window.turnstile = api
await vi.advanceTimersByTimeAsync(50)
await expect(promise).resolves.toBe(api)
// a pre-existing tag is left alone (never removed by this loader)
expect(scriptEl()).not.toBeNull()
})
it('reuses a pre-existing script tag and times out (clearing the cache) if the global never appears, leaving the tag in place', async () => {
vi.useFakeTimers()
const existing = new FakeScript()
existing.src = TURNSTILE_SRC
inserted.push(existing)
const loadTurnstile = await freshLoadTurnstile()
const promise = loadTurnstile()
const assertion = expect(promise).rejects.toThrow(/timed out/i)
await vi.advanceTimersByTimeAsync(10_000)
await assertion
// pre-existing tag is never removed by the loader
expect(scriptEl()).not.toBeNull()
// cache was reset → a later call starts a brand-new load
const retry = loadTurnstile()
expect(retry).not.toBe(promise)
// drain the throwaway retry's timer/promise so nothing leaks
retry.catch(() => {})
await vi.advanceTimersByTimeAsync(10_000)
})
})

View File

@@ -0,0 +1,36 @@
import { createScriptLoader } from '@/utils/loadExternalScript'
const TURNSTILE_SRC =
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
export interface TurnstileRenderOptions {
sitekey: string
theme?: 'light' | 'dark' | 'auto'
callback?: (token: string) => void
'expired-callback'?: () => void
'error-callback'?: () => void
}
export interface TurnstileApi {
render: (
container: string | HTMLElement,
options: TurnstileRenderOptions
) => string
reset: (widgetId?: string) => void
remove: (widgetId: string) => void
}
declare global {
interface Window {
turnstile?: TurnstileApi
}
}
const loadTurnstileScript = createScriptLoader(
TURNSTILE_SRC,
() => window.turnstile ?? null
)
export function loadTurnstile(): Promise<TurnstileApi> {
return loadTurnstileScript()
}

View File

@@ -199,8 +199,8 @@ export const useAuthActions = () => {
)
const signUpWithEmail = wrapWithErrorHandlingAsync(
async (email: string, password: string) => {
return await authStore.register(email, password)
async (email: string, password: string, turnstileToken?: string) => {
return await authStore.register(email, password, turnstileToken)
},
reportError
)

View File

@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
isTurnstileEnabled,
normalizeTurnstileMode,
useTurnstile
} from '@/composables/auth/useTurnstile'
import { getTurnstileSiteKey } from '@/config/turnstile'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: { value: {} }
}))
vi.mock('@/scripts/api', () => ({
api: { getServerFeature: vi.fn() }
}))
vi.mock('@/utils/devFeatureFlagOverride', () => ({
getDevOverride: vi.fn()
}))
vi.mock('@/config/turnstile', () => ({
getTurnstileSiteKey: vi.fn()
}))
const mockedDevOverride = vi.mocked(getDevOverride)
const mockedGetServerFeature = vi.mocked(api.getServerFeature)
const mockedSiteKey = vi.mocked(getTurnstileSiteKey)
describe('normalizeTurnstileMode', () => {
it('passes through known modes', () => {
expect(normalizeTurnstileMode('off')).toBe('off')
expect(normalizeTurnstileMode('shadow')).toBe('shadow')
expect(normalizeTurnstileMode('enforce')).toBe('enforce')
})
it('clamps unknown or missing values to off', () => {
expect(normalizeTurnstileMode('enfroce')).toBe('off')
expect(normalizeTurnstileMode('')).toBe('off')
expect(normalizeTurnstileMode(undefined)).toBe('off')
})
})
describe('isTurnstileEnabled', () => {
it('renders when the flag is active and a sitekey is configured', () => {
expect(isTurnstileEnabled('shadow', 'site-key')).toBe(true)
expect(isTurnstileEnabled('enforce', 'site-key')).toBe(true)
})
it('does not render when the flag is off', () => {
expect(isTurnstileEnabled('off', 'site-key')).toBe(false)
})
it('does not render without a sitekey (OSS / local builds)', () => {
expect(isTurnstileEnabled('shadow', '')).toBe(false)
expect(isTurnstileEnabled('enforce', '')).toBe(false)
})
})
describe('useTurnstile', () => {
beforeEach(() => {
vi.clearAllMocks()
remoteConfig.value = {}
mockedDevOverride.mockReturnValue(undefined)
mockedGetServerFeature.mockReturnValue('off')
mockedSiteKey.mockReturnValue('site-key')
})
describe('mode precedence', () => {
it('prefers the dev override over remote config and the server feature', () => {
mockedDevOverride.mockReturnValue('enforce')
remoteConfig.value = { signup_turnstile: 'shadow' }
mockedGetServerFeature.mockReturnValue('off')
expect(useTurnstile().mode.value).toBe('enforce')
})
it('uses remote config when there is no dev override', () => {
remoteConfig.value = { signup_turnstile: 'shadow' }
expect(useTurnstile().mode.value).toBe('shadow')
})
it('falls back to the server feature flag (default off) when nothing else is set', () => {
mockedGetServerFeature.mockReturnValue('enforce')
expect(useTurnstile().mode.value).toBe('enforce')
expect(mockedGetServerFeature).toHaveBeenCalledWith(
'signup_turnstile',
'off'
)
})
it('clamps an unknown remote-config value to off', () => {
remoteConfig.value = {
signup_turnstile: 'bogus' as unknown as 'shadow'
}
expect(useTurnstile().mode.value).toBe('off')
})
it('resolves to off when every source is unset', () => {
expect(useTurnstile().mode.value).toBe('off')
})
})
describe('enabled / enforced', () => {
it('is enabled but not enforced in shadow with a sitekey', () => {
remoteConfig.value = { signup_turnstile: 'shadow' }
const { enabled, enforced } = useTurnstile()
expect(enabled.value).toBe(true)
expect(enforced.value).toBe(false)
})
it('is enabled and enforced in enforce with a sitekey', () => {
remoteConfig.value = { signup_turnstile: 'enforce' }
const { enabled, enforced } = useTurnstile()
expect(enabled.value).toBe(true)
expect(enforced.value).toBe(true)
})
it('is neither enabled nor enforced without a sitekey, even in enforce', () => {
remoteConfig.value = { signup_turnstile: 'enforce' }
mockedSiteKey.mockReturnValue('')
const { enabled, enforced } = useTurnstile()
expect(enabled.value).toBe(false)
expect(enforced.value).toBe(false)
})
it('is disabled when the mode is off', () => {
const { enabled, enforced } = useTurnstile()
expect(enabled.value).toBe(false)
expect(enforced.value).toBe(false)
})
})
})

View File

@@ -0,0 +1,44 @@
import { computed } from 'vue'
import { getTurnstileSiteKey } from '@/config/turnstile'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import type { TurnstileMode } from '@/platform/remoteConfig/types'
/**
* Clamp an externally-sourced value to a known TurnstileMode. Unknown strings
* (typos, stale flag variants) resolve to 'off' so a bad value can never leave
* the widget rendered-but-unenforced — mirrors the server-side resolver.
*/
export function normalizeTurnstileMode(raw: string | undefined): TurnstileMode {
return raw === 'shadow' || raw === 'enforce' ? raw : 'off'
}
/**
* Whether the signup Turnstile widget should render. Purely config-driven: the
* flag must be shadow/enforce and a sitekey must be configured. OSS / local
* builds resolve no sitekey — the real per-env keys are tree-shaken out via the
* __DISTRIBUTION__ build define (see config/turnstile.ts) — so the widget never
* renders. The local-OSS exemption lives server-side (loopback-IP check in
* CreateCustomer).
*/
export function isTurnstileEnabled(
mode: TurnstileMode,
siteKey: string
): boolean {
return mode !== 'off' && siteKey !== ''
}
/**
* Reactive Turnstile state for the signup form.
* - `enabled`: render the widget
* - `enforced`: block submit until the challenge is solved
*/
export function useTurnstile() {
const { flags } = useFeatureFlags()
const mode = computed(() => normalizeTurnstileMode(flags.signupTurnstileMode))
const siteKey = computed(getTurnstileSiteKey)
const enabled = computed(() => isTurnstileEnabled(mode.value, siteKey.value))
const enforced = computed(() => enabled.value && mode.value === 'enforce')
return { mode, siteKey, enabled, enforced }
}

View File

@@ -7,7 +7,9 @@ import type {
CreateTopupResponse,
CurrentTeamCreditStop,
Plan,
PreviewSubscribeOptions,
PreviewSubscribeResponse,
SubscribeOptions,
SubscribeResponse,
SubscriptionDuration,
SubscriptionTier,
@@ -21,9 +23,9 @@ export interface SubscriptionInfo {
tier: SubscriptionTier | null
duration: SubscriptionDuration | null
planSlug: string | null
/** ISO 8601 */
/** ISO 8601; format at the display site. */
renewalDate: string | null
/** ISO 8601 */
/** ISO 8601; format at the display site. */
endDate: string | null
isCancelled: boolean
hasFunds: boolean
@@ -43,16 +45,27 @@ export interface BillingActions {
fetchBalance: () => Promise<void>
subscribe: (
planSlug: string,
returnUrl?: string,
cancelUrl?: string
options?: SubscribeOptions
) => Promise<SubscribeResponse | void>
previewSubscribe: (
planSlug: string
planSlug: string,
options?: PreviewSubscribeOptions
) => Promise<PreviewSubscribeResponse | null>
manageSubscription: () => Promise<void>
cancelSubscription: () => Promise<void>
/**
* Reactivates a cancelled-but-still-active subscription. Legacy has no
* dedicated endpoint, so the legacy adapter re-runs the checkout flow.
* The workspace adapter refreshes status and balance internally on success.
*/
resubscribe: () => Promise<void>
/** `amountCents` must be a whole-dollar multiple of 100. */
/**
* Purchases additional credits. Standardized on **whole-dollar cents**
* (multiples of 100); the legacy adapter divides by 100 for the
* dollar-based /customers/credit endpoint.
* Pass-through by design: the caller owns the completed/pending follow-up
* (balance refresh or billing-op polling), so this does not refresh.
*/
topup: (amountCents: number) => Promise<CreateTopupResponse | void>
fetchPlans: () => Promise<void>
/**
@@ -80,8 +93,11 @@ export interface BillingState {
isLoading: Ref<boolean>
error: Ref<string | null>
isActiveSubscription: ComputedRef<boolean>
/** Reflects the active workspace's tier, not the user's personal tier. */
isFreeTier: ComputedRef<boolean>
/** Coarse funding state (`billing_status`); legacy reports null. */
billingStatus: ComputedRef<BillingStatus | null>
/** Lifecycle state; legacy synthesizes it from active/cancelled flags. */
subscriptionStatus: ComputedRef<BillingSubscriptionStatus | null>
tier: ComputedRef<SubscriptionTier | null>
renewalDate: ComputedRef<string | null>

View File

@@ -1,6 +1,8 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import type {
BillingStatusResponse,
Plan
@@ -20,12 +22,14 @@ const {
mockIsPersonal,
mockPlans,
mockPurchaseCredits,
mockUpdateActiveWorkspace,
mockBillingStatus
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn(),
mockUpdateActiveWorkspace: vi.fn(),
mockBillingStatus: {
value: {
is_active: true,
@@ -44,15 +48,25 @@ vi.mock('@vueuse/core', async (importOriginal) => {
}
})
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
vi.mock('@/composables/useFeatureFlags', async () => {
const { ref } = await import('vue')
const teamWorkspacesEnabledRef = ref(mockTeamWorkspacesEnabled.value)
Object.defineProperty(mockTeamWorkspacesEnabled, 'value', {
get: () => teamWorkspacesEnabledRef.value,
set: (value: boolean) => {
teamWorkspacesEnabledRef.value = value
}
})
}))
return {
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
}
})
}
})
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
@@ -64,7 +78,7 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
},
updateActiveWorkspace: vi.fn()
updateActiveWorkspace: mockUpdateActiveWorkspace
})
}))
@@ -142,11 +156,28 @@ describe('useBillingContext', () => {
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
})
it('returns legacy type for personal workspace', () => {
it('selects legacy type when team workspaces are disabled', () => {
mockTeamWorkspacesEnabled.value = false
const { type } = useBillingContext()
expect(type.value).toBe('legacy')
})
it('selects workspace type for personal when team workspaces are enabled', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('workspace')
})
it('selects workspace type for team when team workspaces are enabled', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
const { type } = useBillingContext()
expect(type.value).toBe('workspace')
})
it('provides subscription info from legacy billing', () => {
const { subscription } = useBillingContext()
@@ -206,6 +237,14 @@ describe('useBillingContext', () => {
expect(mockPurchaseCredits).toHaveBeenCalledWith(5)
})
it('rejects topup amounts that are not positive whole-dollar cents', async () => {
const { topup } = useBillingContext()
await expect(topup(550)).rejects.toThrow()
await expect(topup(0)).rejects.toThrow()
await expect(topup(-100)).rejects.toThrow()
await expect(topup(99.5)).rejects.toThrow()
})
it('provides isActiveSubscription convenience computed', () => {
const { isActiveSubscription } = useBillingContext()
expect(isActiveSubscription.value).toBe(true)
@@ -221,6 +260,42 @@ describe('useBillingContext', () => {
expect(() => showSubscriptionDialog()).not.toThrow()
})
it('reinitializes workspace billing when the type flips on after legacy init', async () => {
mockTeamWorkspacesEnabled.value = false
mockIsPersonal.value = true
const { type, initialize } = useBillingContext()
await initialize()
await nextTick()
expect(type.value).toBe('legacy')
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
// Authenticated remote config resolves the flag on for the same workspace
mockTeamWorkspacesEnabled.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 () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = true
const { initialize } = useBillingContext()
await initialize()
await nextTick()
expect(mockUpdateActiveWorkspace).toHaveBeenCalledWith({
isSubscribed: true,
subscriptionPlan: null
})
})
})
describe('getMaxSeats', () => {
it('returns 1 for personal workspaces regardless of tier', () => {
const { getMaxSeats } = useBillingContext()

View File

@@ -7,6 +7,10 @@ import {
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
PreviewSubscribeOptions,
SubscribeOptions
} from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type {
@@ -27,11 +31,11 @@ import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspa
const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
/**
* Unified billing context that automatically switches between legacy (user-scoped)
* and workspace billing based on the active workspace type.
* Unified billing context that selects the billing implementation by build/flag.
*
* - Personal workspaces use legacy billing via /customers/* endpoints
* - Team workspaces use workspace billing via /billing/* endpoints
* - 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
*
* The context automatically initializes when the workspace changes and provides
* a unified interface for subscription status, balance, and billing actions.
@@ -92,16 +96,14 @@ function useBillingContextInternal(): BillingContext {
const error = ref<string | null>(null)
/**
* Determines which billing type to use:
* - If team workspaces feature is disabled: always use legacy (/customers)
* - If team workspaces feature is enabled:
* - Personal workspace: use legacy (/customers)
* - Team workspace: use workspace (/billing)
* 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>(() => {
if (!flags.teamWorkspacesEnabled) return 'legacy'
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
})
const type = computed<BillingType>(() =>
flags.teamWorkspacesEnabled ? 'workspace' : 'legacy'
)
const activeContext = computed(() =>
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
@@ -173,7 +175,7 @@ function useBillingContextInternal(): BillingContext {
watch(
subscription,
(sub) => {
if (!sub || store.isInPersonalWorkspace) return
if (!sub) return
store.updateActiveWorkspace({
isSubscribed: sub.isActive && !sub.isCancelled,
@@ -183,26 +185,28 @@ function useBillingContextInternal(): BillingContext {
{ immediate: true }
)
// Initialize billing when workspace changes
function resetBillingState() {
isInitialized.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.
watch(
() => store.activeWorkspace?.id,
async (newWorkspaceId, oldWorkspaceId) => {
[() => store.activeWorkspace?.id, () => type.value],
async ([newWorkspaceId]) => {
if (!newWorkspaceId) {
// No workspace selected - reset state
isInitialized.value = false
error.value = null
resetBillingState()
return
}
if (newWorkspaceId !== oldWorkspaceId) {
// Workspace changed - reinitialize
isInitialized.value = false
try {
await initialize()
} catch (err) {
// Error is already captured in error ref
console.error('Failed to initialize billing context:', err)
}
isInitialized.value = false
try {
await initialize()
} catch (err) {
console.error('Failed to initialize billing context:', err)
}
},
{ immediate: true }
@@ -233,16 +237,15 @@ function useBillingContextInternal(): BillingContext {
return activeContext.value.fetchBalance()
}
async function subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
) {
return activeContext.value.subscribe(planSlug, returnUrl, cancelUrl)
async function subscribe(planSlug: string, options?: SubscribeOptions) {
return activeContext.value.subscribe(planSlug, options)
}
async function previewSubscribe(planSlug: string) {
return activeContext.value.previewSubscribe(planSlug)
async function previewSubscribe(
planSlug: string,
options?: PreviewSubscribeOptions
) {
return activeContext.value.previewSubscribe(planSlug, options)
}
async function manageSubscription() {
@@ -258,6 +261,15 @@ function useBillingContextInternal(): BillingContext {
}
async function topup(amountCents: number) {
if (
!Number.isInteger(amountCents) ||
amountCents <= 0 ||
amountCents % 100 !== 0
) {
throw new Error(
'Top-up amount must be a positive whole-dollar cent value'
)
}
return activeContext.value.topup(amountCents)
}

View File

@@ -5,7 +5,9 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
import type {
BillingStatus,
BillingSubscriptionStatus,
PreviewSubscribeOptions,
PreviewSubscribeResponse,
SubscribeOptions,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { useAuthStore } from '@/stores/authStore'
@@ -147,15 +149,15 @@ export function useLegacyBilling(): BillingState & BillingActions {
async function subscribe(
_planSlug: string,
_returnUrl?: string,
_cancelUrl?: string
_options?: SubscribeOptions
): Promise<SubscribeResponse | void> {
// Legacy billing uses Stripe checkout flow via useSubscription
await legacySubscribe()
}
async function previewSubscribe(
_planSlug: string
_planSlug: string,
_options?: PreviewSubscribeOptions
): Promise<PreviewSubscribeResponse | null> {
// Legacy billing doesn't support preview - returns null
return null

View File

@@ -8,7 +8,6 @@ import type {
Subgraph
} from '@/lib/litegraph/src/litegraph'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { useLitegraphService } from '@/services/litegraphService'
async function navigateToGraph(targetGraph: LGraph) {
const canvasStore = useCanvasStore()
@@ -49,23 +48,7 @@ export function useFocusNode() {
canvasStore.canvas?.animateToBounds(graphNode.boundingRect)
}
async function enterSubgraph(
nodeId: string,
executionIdMap?: Map<string, LGraphNode>
) {
if (!canvasStore.canvas) return
const graphNode = executionIdMap
? executionIdMap.get(nodeId)
: getNodeByExecutionId(app.rootGraph, nodeId)
if (!graphNode?.graph) return
await navigateToGraph(graphNode.graph as LGraph)
useLitegraphService().fitView()
}
return {
focusNode,
enterSubgraph
focusNode
}
}

View File

@@ -221,6 +221,39 @@ describe('useFeatureFlags', () => {
})
})
describe('signupTurnstileMode', () => {
afterEach(() => {
localStorage.clear()
})
it('falls back to the server feature flag with default off', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.SIGNUP_TURNSTILE) return 'enforce'
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.signupTurnstileMode).toBe('enforce')
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.SIGNUP_TURNSTILE,
'off'
)
})
it('lets a dev override beat the server value', () => {
vi.mocked(api.getServerFeature).mockReturnValue('off')
localStorage.setItem(
`ff:${ServerFeatureFlag.SIGNUP_TURNSTILE}`,
'"shadow"'
)
const { flags } = useFeatureFlags()
expect(flags.signupTurnstileMode).toBe('shadow')
})
})
describe('unifiedCloudAuthEnabled', () => {
afterEach(() => {
localStorage.clear()

View File

@@ -29,7 +29,8 @@ 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',
SIGNUP_TURNSTILE = 'signup_turnstile'
}
/**
@@ -173,6 +174,13 @@ export function useFeatureFlags() {
remoteConfig.value.unified_cloud_auth,
false
)
},
get signupTurnstileMode() {
return resolveFlag(
ServerFeatureFlag.SIGNUP_TURNSTILE,
remoteConfig.value.signup_turnstile,
'off'
)
}
})

View File

@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useUrlActionLoaders } from './useUrlActionLoaders'
const mockIsCloud = vi.hoisted(() => ({ value: true }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
const mockFlags = vi.hoisted(() => ({ value: { teamWorkspacesEnabled: true } }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: mockFlags.value })
}))
const mocks = vi.hoisted(() => ({
loadInvite: vi.fn().mockResolvedValue(undefined),
loadCreateWorkspace: vi.fn().mockResolvedValue(undefined),
loadPricingTable: vi.fn().mockResolvedValue(undefined),
useInvite: vi.fn(),
useCreateWorkspace: vi.fn(),
usePricingTable: vi.fn()
}))
mocks.useInvite.mockImplementation(() => ({
loadInviteFromUrl: mocks.loadInvite
}))
mocks.useCreateWorkspace.mockImplementation(() => ({
loadCreateWorkspaceFromUrl: mocks.loadCreateWorkspace
}))
mocks.usePricingTable.mockImplementation(() => ({
loadPricingTableFromUrl: mocks.loadPricingTable
}))
vi.mock('@/platform/workspace/composables/useInviteUrlLoader', () => ({
useInviteUrlLoader: mocks.useInvite
}))
vi.mock('@/platform/workspace/composables/useCreateWorkspaceUrlLoader', () => ({
useCreateWorkspaceUrlLoader: mocks.useCreateWorkspace
}))
vi.mock(
'@/platform/cloud/subscription/composables/usePricingTableUrlLoader',
() => ({ usePricingTableUrlLoader: mocks.usePricingTable })
)
describe('useUrlActionLoaders', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockFlags.value = { teamWorkspacesEnabled: true }
})
it('does not instantiate or run any loader off cloud', async () => {
mockIsCloud.value = false
const { runUrlActionLoaders } = useUrlActionLoaders()
await runUrlActionLoaders()
expect(mocks.useInvite).not.toHaveBeenCalled()
expect(mocks.useCreateWorkspace).not.toHaveBeenCalled()
expect(mocks.usePricingTable).not.toHaveBeenCalled()
expect(mocks.loadInvite).not.toHaveBeenCalled()
expect(mocks.loadCreateWorkspace).not.toHaveBeenCalled()
expect(mocks.loadPricingTable).not.toHaveBeenCalled()
})
it('runs all loaders on cloud when team workspaces are enabled', async () => {
const { runUrlActionLoaders } = useUrlActionLoaders()
await runUrlActionLoaders()
expect(mocks.loadInvite).toHaveBeenCalledOnce()
expect(mocks.loadCreateWorkspace).toHaveBeenCalledOnce()
expect(mocks.loadPricingTable).toHaveBeenCalledOnce()
})
it('runs the pricing loader but skips the flag-gated loaders when team workspaces are disabled', async () => {
mockFlags.value = { teamWorkspacesEnabled: false }
const { runUrlActionLoaders } = useUrlActionLoaders()
await runUrlActionLoaders()
expect(mocks.loadInvite).not.toHaveBeenCalled()
expect(mocks.loadCreateWorkspace).not.toHaveBeenCalled()
expect(mocks.loadPricingTable).toHaveBeenCalledOnce()
})
it('isolates a pricing-loader failure so it does not abort the boot chain', async () => {
mocks.loadPricingTable.mockRejectedValueOnce(new Error('boom'))
const { runUrlActionLoaders } = useUrlActionLoaders()
await expect(runUrlActionLoaders()).resolves.toBeUndefined()
expect(mocks.loadInvite).toHaveBeenCalledOnce()
expect(mocks.loadCreateWorkspace).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,55 @@
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { usePricingTableUrlLoader } from '@/platform/cloud/subscription/composables/usePricingTableUrlLoader'
import { isCloud } from '@/platform/distribution/types'
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
/**
* Aggregates the query-param "deep link" loaders the cloud app checks on mount
* (`?invite`, `?create_workspace`, `?pricing`). The loaders are instantiated in
* setup so their `useRoute`/`useRouter` resolve; call `runUrlActionLoaders()`
* from `onMounted` once the app is ready.
*/
export function useUrlActionLoaders() {
const { flags } = useFeatureFlags()
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
const createWorkspaceUrlLoader = isCloud
? useCreateWorkspaceUrlLoader()
: null
const pricingTableUrlLoader = isCloud ? usePricingTableUrlLoader() : null
async function runUrlActionLoaders() {
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN).
// WorkspaceAuthGate ensures flag state is resolved before the app mounts.
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {
await inviteUrlLoader.loadInviteFromUrl()
}
// Open create workspace dialog from URL if present (e.g., ?create_workspace=1).
if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) {
try {
await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl()
} catch (error) {
console.error(
'[UrlActionLoaders] Failed to load create workspace from URL:',
error
)
}
}
// Open the pricing table from URL if present (e.g., ?pricing=1 / ?pricing=team).
// Not gated on the team-workspaces flag: it also drives personal/legacy users.
if (pricingTableUrlLoader) {
try {
await pricingTableUrlLoader.loadPricingTableFromUrl()
} catch (error) {
console.error(
'[UrlActionLoaders] Failed to load pricing table from URL:',
error
)
}
}
}
return { runUrlActionLoaders }
}

View File

@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getTurnstileSiteKey } from '@/config/turnstile'
const TURNSTILE_TEST_SITE_KEY = '1x00000000000000000000AA'
// __USE_PROD_CONFIG__ is false under vitest (see vitest.setup.ts), so the
// build-time fallback resolves to the staging sitekey.
const STAGING_TURNSTILE_SITE_KEY = '0x4AAAAAADnYY4_Q0qxHZ5a7'
// Mutable containers go through vi.hoisted so the hoisted vi.mock factories can
// reference them without a temporal-dead-zone crash (which surfaces under
// coverage instrumentation, not a plain run).
const { mockRemoteConfig } = vi.hoisted(() => ({
mockRemoteConfig: { value: {} as Record<string, unknown> }
}))
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: mockRemoteConfig,
configValueOrDefault: (
cfg: Record<string, unknown>,
key: string,
fallback: unknown
) => cfg[key] || fallback
}))
describe('getTurnstileSiteKey', () => {
beforeEach(() => {
mockRemoteConfig.value = {}
vi.stubGlobal('__DISTRIBUTION__', 'localhost')
})
afterEach(() => {
vi.unstubAllEnvs()
vi.unstubAllGlobals()
})
describe('OSS / non-cloud build', () => {
it('falls back to the always-pass test key in dev', () => {
vi.stubEnv('DEV', true)
expect(getTurnstileSiteKey()).toBe(TURNSTILE_TEST_SITE_KEY)
})
it('returns empty string outside dev so the widget never renders', () => {
vi.stubEnv('DEV', false)
expect(getTurnstileSiteKey()).toBe('')
})
it('ignores remote config (the widget is cloud-only)', () => {
vi.stubEnv('DEV', false)
mockRemoteConfig.value = { turnstile_sitekey: '0xshould-not-be-used' }
expect(getTurnstileSiteKey()).toBe('')
})
})
describe('cloud build', () => {
beforeEach(() => {
vi.stubGlobal('__DISTRIBUTION__', 'cloud')
})
it('returns the sitekey delivered via remote config', () => {
mockRemoteConfig.value = { turnstile_sitekey: '0x4AAAAAreal' }
expect(getTurnstileSiteKey()).toBe('0x4AAAAAreal')
})
it('falls back to the build-time per-env sitekey during a remote-config gap', () => {
expect(getTurnstileSiteKey()).toBe(STAGING_TURNSTILE_SITE_KEY)
})
})
})

43
src/config/turnstile.ts Normal file
View File

@@ -0,0 +1,43 @@
import {
configValueOrDefault,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
/**
* Cloudflare Turnstile always-pass test sitekey, used only in local dev so the
* signup flow can be exercised without a real key.
* @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/
*/
const TURNSTILE_TEST_SITE_KEY = '1x00000000000000000000AA'
// Public per-environment sitekeys, baked at build time so a cloud build renders
// the widget even before (or without) remote config; remote config still
// overrides them, so keys rotate live without a rebuild.
const PROD_TURNSTILE_SITE_KEY = '0x4AAAAAADnYZPVOpFCL_zeo'
const STAGING_TURNSTILE_SITE_KEY = '0x4AAAAAADnYY4_Q0qxHZ5a7'
/**
* Returns the Cloudflare Turnstile sitekey for the current environment.
* - OSS / localhost never renders the cloud widget (server-side loopback
* exemption covers local signup); in dev it falls back to the always-pass test
* key so the flow is exercisable locally, otherwise ''.
* - Cloud builds prefer the per-env sitekey delivered via remote config
* (`turnstile_sitekey`) and fall back to the build-time constant, so the widget
* still renders during a remote-config gap rather than silently disappearing.
*/
export function getTurnstileSiteKey(): string {
// Gate on the __DISTRIBUTION__ build define rather than the cross-module
// `isCloud` const so dead-code elimination strips the real per-env sitekeys
// from OSS/desktop bundles — same idiom as initTelemetry.ts, enforced by the
// dist scan in ci-dist-telemetry-scan.yaml.
const isCloudBuild = __DISTRIBUTION__ === 'cloud'
if (!isCloudBuild) {
return import.meta.env.DEV ? TURNSTILE_TEST_SITE_KEY : ''
}
return configValueOrDefault(
remoteConfig.value,
'turnstile_sitekey',
__USE_PROD_CONFIG__ ? PROD_TURNSTILE_SITE_KEY : STAGING_TURNSTILE_SITE_KEY
)
}

View File

@@ -3682,7 +3682,6 @@
"keepSubscription": "الاحتفاظ بالاشتراك",
"title": "إلغاء الاشتراك"
},
"cancelSubscription": "إلغاء الاشتراك",
"cancelSuccess": "تم إلغاء الاشتراك بنجاح",
"canceled": "تم الإلغاء",
"canceledCard": {
@@ -3847,7 +3846,7 @@
"workspaceNotSubscribed": "هذه مساحة العمل ليست مشتركة",
"yearly": "سنوي",
"yearlyCreditsLabel": "إجمالي الرصيد السنوي",
"yearlyDiscount": "خصم 20%",
"saveYearly": "وفّر 20%",
"yourPlanIncludes": "خطتك تشمل:"
},
"tabMenu": {

View File

@@ -2347,6 +2347,11 @@
"personalDataConsentLabel": "I agree to the processing of my personal data.",
"emailNotEligibleForFreeTier": "Email sign-up is not eligible for Free Tier."
},
"turnstile": {
"failed": "Verification failed. Please try again.",
"expired": "Verification expired. Please complete the challenge again.",
"submitBlockedHint": "Complete the verification challenge above to enable sign up."
},
"signOut": {
"signOut": "Log Out",
"success": "Signed out successfully",
@@ -2507,13 +2512,18 @@
"creditSliderSave": "Save {percent}% ({amount})",
"renewsDate": "Renews {date}",
"expiresDate": "Expires {date}",
"renewsOnDate": "Renews on {date}",
"endsOnDate": "Ends on {date}",
"manageSubscription": "Manage subscription",
"managePayment": "Manage Payment",
"cancelSubscription": "Cancel Subscription",
"manageBilling": "Manage billing",
"changePlan": "Change plan",
"cancelPlan": "Cancel plan",
"canceled": "Canceled",
"resubscribe": "Resubscribe",
"reactivatePlan": "Reactivate plan",
"resubscribeTo": "Resubscribe to {plan}",
"resubscribeSuccess": "Subscription reactivated successfully",
"subscribeFailed": "Failed to subscribe",
"canceledCard": {
"title": "Your subscription has been canceled",
"description": "You won't be charged again. Your features remain active until {date}."
@@ -2553,16 +2563,48 @@
"creditsRemainingThisMonth": "Included (Refills {date})",
"creditsRemainingThisYear": "Included (Refills {date})",
"creditsYouveAdded": "Additional",
"remaining": "remaining",
"refillsDate": "Refills {date}",
"refillsNextCycle": "Refills next cycle",
"creditsUsed": "{used} used",
"creditsLeftOfTotal": "{remaining} left of {total}",
"monthlyUsageProgress": "{used} of {total} monthly credits used",
"additionalCreditsInfo": "About additional credits",
"additionalCredits": "Additional credits",
"additionalCreditsInUse": "In use",
"usedAfterMonthly": "Used after monthly runs out",
"monthlyCreditsUsedUpTitle": "Monthly credits are used up. Refills {date}",
"monthlyCreditsUsedUpTitleNoDate": "Monthly credits are used up",
"monthlyCreditsUsedUpDescription": "You're now spending additional credits.",
"outOfCreditsTitle": "You're out of credits. Credits refill {date}",
"outOfCreditsTitleNoDate": "You're out of credits",
"outOfCreditsDescription": "Add more credits to continue generating.",
"additionalCreditsTooltip": "Credits you add on top of your plan. Used after monthly credits run out. Each expires one year after purchase.",
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
"viewMoreDetailsPlans": "View more details about plans & pricing",
"nextBillingCycle": "next billing cycle",
"yourPlanIncludes": "Your plan includes:",
"whatsIncluded": "What's included:",
"planLoadError": "We couldn't load your plan details.",
"planLoadErrorRetry": "Try again",
"teamPlanName": "Team",
"teamPlanIncludes": "Your plan includes everything in {plan}, plus:",
"teamPerks": {
"inviteMembers": "Invite members",
"concurrentRuns": "Members can run workflows concurrently",
"sharedCreditPool": "Shared credit pool for all members",
"rolePermissions": "Role-based permissions"
},
"freePerks": {
"maxRuntime": "{duration} max runtime"
},
"viewMoreDetails": "View more details",
"learnMore": "Learn more",
"billedMonthly": "Billed monthly",
"billedYearly": "{total} Billed yearly",
"monthly": "Monthly",
"yearly": "Yearly",
"saveYearly": "Save 20%",
"tierNameYearly": "{name} Yearly",
"messageSupport": "Message support",
"invoiceHistory": "Invoice history",
@@ -2573,7 +2615,6 @@
"benefit2": "Up to 1 hour runtime per job on Pro",
"benefit3": "Bring your own models (Creator & Pro)"
},
"yearlyDiscount": "20% DISCOUNT",
"tiers": {
"free": {
"name": "Free"
@@ -2614,6 +2655,7 @@
"subscribeToRunFull": "Subscribe to Run",
"subscribeForMore": "Upgrade",
"upgradeToAddCredits": "Upgrade to add credits",
"subscribe": "Subscribe",
"subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"workspaceNotSubscribed": "This workspace is not on a subscription",
@@ -2650,9 +2692,9 @@
"perkProjectAssets": "Project & asset management",
"cta": "Subscribe to Team Yearly",
"ctaMonthly": "Subscribe to Team Monthly",
"unavailable": "This team plan is not available right now.",
"changePlan": "Change plan",
"currentPlan": "Current plan",
"checkoutComingSoon": "Team plan checkout is coming soon."
"currentPlan": "Current plan"
},
"enterprise": {
"name": "Enterprise",
@@ -2682,6 +2724,7 @@
"upgradeCta": "View plans"
},
"partnerNodesCredits": "Partner nodes pricing",
"partnerNodesPricingTable": "Partner Nodes pricing table",
"plansAndPricing": "Plans & pricing",
"managePlan": "Manage plan",
"upgrade": "UPGRADE",
@@ -2692,8 +2735,6 @@
"monthlyCreditsPerMemberLabel": "Monthly credits / member",
"maxMembersLabel": "Max. members",
"yearlyCreditsLabel": "Total yearly credits",
"membersLabel": "Up to {count} members",
"nextMonthInvoice": "Next month invoice",
"memberCount": "{count} member | {count} members",
"maxDurationLabel": "Max run duration",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
@@ -2708,7 +2749,7 @@
"upgradeTo": "Upgrade to {plan}",
"changeTo": "Change to {plan}",
"maxDuration": {
"free": "30 min",
"free": "10 min",
"standard": "30 min",
"creator": "30 min",
"pro": "1 hr",
@@ -2721,10 +2762,11 @@
"preview": {
"confirmPayment": "Confirm your payment",
"confirmPlanChange": "Confirm your plan change",
"startingToday": "Starting today",
"startingToday": "Starts today",
"starting": "Starting {date}",
"ends": "Ends {date}",
"eachMonthCreditsRefill": "Each month credits refill to",
"eachYearCreditsRefill": "Each year credits refill to",
"everyMonthStarting": "Every month starting {date}",
"creditsRefillTo": "Credits refill to",
"youllBeCharged": "You'll be charged",
@@ -2735,6 +2777,24 @@
"proratedCharge": "Prorated charge for {plan}",
"totalDueToday": "Total due today",
"nextPaymentDue": "Next payment due {date}. Cancel anytime.",
"confirmUpgradeTitle": "Confirm your upgrade",
"confirmUpgradeCta": "Confirm upgrade",
"confirmChange": "Confirm change",
"confirmChangeTitle": "Review your scheduled change",
"paymentPopupBlocked": "Couldn't open the payment page — please allow popups and try again.",
"switchesToday": "Switches today",
"startsOn": "Starts {date}",
"yearlySubscription": "Yearly subscription",
"newMonthlySubscription": "New monthly subscription",
"creditFromCurrent": "Credit from current {plan}",
"currentMonthly": "monthly plan",
"commitment": "commitment",
"creditsYoullGetToday": "Credits you'll get today",
"refillReplacesNote": "Replaces your monthly refill. Existing balance is kept.",
"afterThat": "After that",
"creditsRefillMonthlyTo": "Credits refill monthly to",
"billedEachMonth": "{amount} billed each month. Cancel anytime.",
"stayOnUntil": "You'll stay on {plan} until {date}.",
"termsAgreement": "By continuing, you agree to Comfy Org's {terms} and {privacy}.",
"terms": "Terms",
"privacyPolicy": "Privacy Policy",
@@ -2746,8 +2806,12 @@
},
"success": {
"allSet": "You're all set",
"inviteEmailsPlaceholder": "Enter emails separated by commas",
"inviteSubtext": "You can also invite people later from Settings",
"inviteTitle": "Invite your team",
"planUpdated": "Your plan has been successfully updated.",
"receiptEmailed": "A receipt has been emailed to you."
"receiptEmailed": "A receipt has been emailed to you.",
"sendInvites": "Send invites"
}
},
"userSettings": {
@@ -2763,7 +2827,7 @@
"workspacePanel": {
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of 50 members",
"inviteLimitReached": "You've reached the maximum of {count} members",
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
@@ -2773,7 +2837,8 @@
"placeholder": "Dashboard workspace settings"
},
"members": {
"membersCount": "{count}/{maxSeats} Members",
"header": "Members",
"membersCount": "{count} of {maxSeats} members",
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
@@ -2782,26 +2847,30 @@
"columns": {
"inviteDate": "Invite date",
"expiryDate": "Expiry date",
"joinDate": "Join date"
"role": "Role"
},
"actions": {
"copyLink": "Copy invite link",
"revokeInvite": "Revoke invite",
"resendInvite": "Resend invite",
"cancelInvite": "Cancel invite",
"changeRole": "Change role",
"removeMember": "Remove member"
},
"upsellBannerSubscribe": "Subscribe to the Creator plan or above to invite team members to this workspace.",
"upsellBannerUpgrade": "Upgrade to the Creator plan or above to invite additional team members.",
"viewPlans": "View plans",
"upsellBanner": "To add teammates, upgrade your plan.",
"upsellBannerReactivate": "To add more teammates, reactivate your plan.",
"upgradeToTeam": "Upgrade to Team",
"reactivateTeam": "Reactivate Team",
"needMoreMembers": "Need more members?",
"contactUs": "Contact us",
"noInvites": "No pending invites",
"noMembers": "No members",
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
"createNewWorkspace": "create a new one."
"searchPlaceholder": "Search..."
},
"menu": {
"editWorkspace": "Edit workspace details",
"leaveWorkspace": "Leave Workspace",
"deleteWorkspace": "Delete Workspace",
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first"
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
"creatorCannotLeave": "The workspace creator can't leave the workspace they created"
},
"editWorkspaceDialog": {
"title": "Edit workspace details",
@@ -2825,32 +2894,38 @@
"success": "Member removed",
"error": "Failed to remove member"
},
"changeRoleDialog": {
"promoteTitle": "Make {name} an owner?",
"promoteIntro": "They'll be able to:",
"promotePermissionCredits": "Add additional credits",
"promotePermissionManage": "Manage members, payment methods, and workspace settings",
"promotePermissionRoles": "Promote and demote other owners (except the workspace creator).",
"promoteConfirm": "Make owner",
"demoteTitle": "Demote {name} to member?",
"demoteMessage": "They'll lose admin access.",
"demoteConfirm": "Demote to member",
"success": "Role updated",
"error": "Failed to update role"
},
"revokeInviteDialog": {
"title": "Uninvite this person?",
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
"revoke": "Uninvite"
},
"inviteUpsellDialog": {
"titleNotSubscribed": "A subscription is required to invite members",
"titleNotSubscribed": "A Team plan is required to invite members",
"titleSingleSeat": "Your current plan supports a single seat",
"messageNotSubscribed": "To add team members to this workspace, you need a Creator plan or above. The Standard plan supports only a single seat (the owner).",
"messageSingleSeat": "The Standard plan includes one seat for the workspace owner. To invite additional members, upgrade to the Creator plan or above to unlock multiple seats.",
"viewPlans": "View Plans",
"upgradeToCreator": "Upgrade to Creator"
"messageNotSubscribed": "To add teammates to this workspace, upgrade to a Team plan.",
"messageSingleSeat": "Your current plan includes one seat for the workspace owner. To add teammates, upgrade to a Team plan.",
"upgradeToTeam": "Upgrade to Team"
},
"inviteMemberDialog": {
"title": "Invite a person to this workspace",
"message": "Create a shareable invite link to send to someone",
"placeholder": "Enter the person's email",
"createLink": "Create link",
"linkStep": {
"title": "Send this link to the person",
"message": "Make sure their account uses this email.",
"copyLink": "Copy Link",
"done": "Done"
},
"linkCopied": "Copied",
"linkCopyFailed": "Failed to copy link"
"title": "Invite members to this workspace",
"placeholder": "Enter emails separated by commas",
"invalidEmailCount": "{count} invalid email address | {count} invalid email addresses",
"failedCount": "Couldn't send {count} invite. Try again. | Couldn't send {count} invites. Try again.",
"invitedMessage": "An invite was sent to {emails} | Invites were sent to {emails}",
"seatLimitReached": "You can invite up to {count} teammate. | You can invite up to {count} teammates."
},
"createWorkspaceDialog": {
"title": "Create a new workspace",
@@ -2877,6 +2952,8 @@
"title": "Left workspace",
"message": "You have left the workspace."
},
"inviteResent": "Invite resent",
"inviteResendFailed": "Failed to resend invite",
"failedToUpdateWorkspace": "Failed to update workspace",
"failedToCreateWorkspace": "Failed to create workspace",
"failedToDeleteWorkspace": "Failed to delete workspace",
@@ -3787,7 +3864,6 @@
"errorLog": "Error log",
"findOnGithubTooltip": "Search GitHub issues for related problems",
"getHelpTooltip": "Report this error and we'll help you resolve it",
"enterSubgraph": "Enter subgraph",
"seeError": "See Error",
"errorHelp": "For more help, {github} or {support}",
"errorHelpGithub": "submit a GitHub issue",

View File

@@ -3682,7 +3682,6 @@
"keepSubscription": "Mantener suscripción",
"title": "Cancelar suscripción"
},
"cancelSubscription": "Cancelar suscripción",
"cancelSuccess": "Suscripción cancelada correctamente",
"canceled": "Cancelada",
"canceledCard": {
@@ -3847,7 +3846,7 @@
"workspaceNotSubscribed": "Este espacio de trabajo no tiene una suscripción",
"yearly": "Anual",
"yearlyCreditsLabel": "Total de créditos anuales",
"yearlyDiscount": "20% DESCUENTO",
"saveYearly": "Ahorra 20%",
"yourPlanIncludes": "Tu plan incluye:"
},
"tabMenu": {

View File

@@ -3694,7 +3694,6 @@
"keepSubscription": "حفظ اشتراک",
"title": "لغو اشتراک"
},
"cancelSubscription": "لغو اشتراک",
"cancelSuccess": "اشتراک با موفقیت لغو شد",
"canceled": "لغو شد",
"canceledCard": {
@@ -3859,7 +3858,7 @@
"workspaceNotSubscribed": "این محیط کاری اشتراک فعال ندارد",
"yearly": "سالانه",
"yearlyCreditsLabel": "کل اعتبار سالانه",
"yearlyDiscount": "٪۲۰ تخفیف",
"saveYearly": "٪۲۰ صرفه‌جویی",
"yourPlanIncludes": "طرح شما شامل:"
},
"tabMenu": {

View File

@@ -3682,7 +3682,6 @@
"keepSubscription": "Conserver l'abonnement",
"title": "Annuler l'abonnement"
},
"cancelSubscription": "Annuler labonnement",
"cancelSuccess": "Abonnement annulé avec succès",
"canceled": "Annulé",
"canceledCard": {
@@ -3847,7 +3846,7 @@
"workspaceNotSubscribed": "Cet espace de travail na pas dabonnement",
"yearly": "Annuel",
"yearlyCreditsLabel": "Crédits annuels totaux",
"yearlyDiscount": "20% DE RÉDUCTION",
"saveYearly": "Économisez 20 %",
"yourPlanIncludes": "Votre forfait comprend :"
},
"tabMenu": {

View File

@@ -3682,7 +3682,6 @@
"keepSubscription": "サブスクリプションを維持する",
"title": "サブスクリプションのキャンセル"
},
"cancelSubscription": "サブスクリプションをキャンセル",
"cancelSuccess": "サブスクリプションが正常にキャンセルされました",
"canceled": "キャンセル済み",
"canceledCard": {
@@ -3847,7 +3846,7 @@
"workspaceNotSubscribed": "このワークスペースはサブスクリプションに加入していません",
"yearly": "年額",
"yearlyCreditsLabel": "年間合計クレジット",
"yearlyDiscount": "20%割引",
"saveYearly": "20%お得",
"yourPlanIncludes": "ご利用プランに含まれるもの:"
},
"tabMenu": {

View File

@@ -3682,7 +3682,6 @@
"keepSubscription": "구독 유지",
"title": "구독 취소"
},
"cancelSubscription": "구독 취소",
"cancelSuccess": "구독이 성공적으로 취소되었습니다",
"canceled": "취소됨",
"canceledCard": {
@@ -3847,7 +3846,7 @@
"workspaceNotSubscribed": "이 워크스페이스는 구독 중이 아닙니다",
"yearly": "연간",
"yearlyCreditsLabel": "연간 총 크레딧",
"yearlyDiscount": "20% 할인",
"saveYearly": "20% 절감",
"yourPlanIncludes": "귀하의 플랜 포함 사항:"
},
"tabMenu": {

View File

@@ -3694,7 +3694,6 @@
"keepSubscription": "Manter assinatura",
"title": "Cancelar assinatura"
},
"cancelSubscription": "Cancelar assinatura",
"cancelSuccess": "Assinatura cancelada com sucesso",
"canceled": "Cancelado",
"canceledCard": {
@@ -3859,7 +3858,7 @@
"workspaceNotSubscribed": "Este espaço de trabalho não possui uma assinatura",
"yearly": "Anual",
"yearlyCreditsLabel": "Total de créditos anuais",
"yearlyDiscount": "20% DE DESCONTO",
"saveYearly": "Economize 20%",
"yourPlanIncludes": "Seu plano inclui:"
},
"tabMenu": {

View File

@@ -3682,7 +3682,6 @@
"keepSubscription": "Сохранить подписку",
"title": "Отмена подписки"
},
"cancelSubscription": "Отменить подписку",
"cancelSuccess": "Подписка успешно отменена",
"canceled": "Отменено",
"canceledCard": {
@@ -3847,7 +3846,7 @@
"workspaceNotSubscribed": "Это рабочее пространство не имеет подписки",
"yearly": "Ежегодно",
"yearlyCreditsLabel": "Годовые кредиты",
"yearlyDiscount": "СКИДКА 20%",
"saveYearly": "Экономия 20%",
"yourPlanIncludes": "Ваш план включает:"
},
"tabMenu": {

View File

@@ -3682,7 +3682,6 @@
"keepSubscription": "Aboneliği sürdür",
"title": "Aboneliği iptal et"
},
"cancelSubscription": "Aboneliği İptal Et",
"cancelSuccess": "Abonelik başarıyla iptal edildi",
"canceled": "İptal edildi",
"canceledCard": {
@@ -3847,7 +3846,7 @@
"workspaceNotSubscribed": "Bu çalışma alanı bir aboneliğe sahip değil",
"yearly": "Yıllık",
"yearlyCreditsLabel": "Toplam yıllık krediler",
"yearlyDiscount": "%20 İNDİRİM",
"saveYearly": "%20 tasarruf",
"yourPlanIncludes": "Planınız şunları içerir:"
},
"tabMenu": {

View File

@@ -3682,7 +3682,6 @@
"keepSubscription": "保留訂閱",
"title": "取消訂閱"
},
"cancelSubscription": "取消訂閱",
"cancelSuccess": "訂閱已成功取消",
"canceled": "已取消",
"canceledCard": {
@@ -3847,7 +3846,7 @@
"workspaceNotSubscribed": "此工作區尚未訂閱",
"yearly": "每年",
"yearlyCreditsLabel": "年度總點數",
"yearlyDiscount": "八折優惠",
"saveYearly": "節省 20%",
"yourPlanIncludes": "您的方案包含:"
},
"tabMenu": {

View File

@@ -3694,7 +3694,6 @@
"keepSubscription": "保留订阅",
"title": "取消订阅"
},
"cancelSubscription": "取消订阅",
"cancelSuccess": "订阅取消成功",
"canceled": "已取消",
"canceledCard": {
@@ -3859,7 +3858,7 @@
"workspaceNotSubscribed": "此工作区未订阅",
"yearly": "年度",
"yearlyCreditsLabel": "总共年度积分",
"yearlyDiscount": "20% 减免",
"saveYearly": "立省 20%",
"yourPlanIncludes": "您的计划包括:"
},
"tabMenu": {

View File

@@ -116,7 +116,9 @@ app
modal: 1800,
overlay: 1800,
menu: 1800,
tooltip: 1800
// Tooltips sit above modals/menus so a menu-item tooltip isn't hidden
// behind a body-portaled dropdown that lifts itself to modal + 1.
tooltip: 2000
},
theme: {
preset: ComfyUIPreset,

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import { parseAgentEvent } from './agentProtocol'
const base = { threadId: 't1', messageId: 'm1' }
describe('parseAgentEvent', () => {
it('decodes a draft_patch', () => {
const event = parseAgentEvent({
...base,
type: 'draft_patch',
workflowId: 'wf1',
content: { nodes: [] },
version: 8,
baseVersion: 7
})
expect(event).toMatchObject({
type: 'draft_patch',
workflowId: 'wf1',
version: 8
})
})
it('decodes a tool call and drops absent optional fields', () => {
const event = parseAgentEvent({
...base,
type: 'agent_tool_call',
toolCallId: 'tc1',
toolName: 'workflow set-slot',
status: 'success'
})
expect(event).toEqual({
...base,
type: 'agent_tool_call',
toolCallId: 'tc1',
toolName: 'workflow set-slot',
status: 'success'
})
})
it('rejects an unknown event type', () => {
expect(parseAgentEvent({ ...base, type: 'nope' })).toBeNull()
})
it('rejects a payload missing the base identifiers', () => {
expect(
parseAgentEvent({ type: 'agent_message_delta', delta: 'hi' })
).toBeNull()
})
it('rejects a draft_patch with a malformed version', () => {
const event = parseAgentEvent({
...base,
type: 'draft_patch',
workflowId: 'wf1',
content: {},
version: '8',
baseVersion: 7
})
expect(event).toBeNull()
})
it('rejects a tool call with an invalid status', () => {
const event = parseAgentEvent({
...base,
type: 'agent_tool_call',
toolCallId: 'tc1',
toolName: 'run',
status: 'pending'
})
expect(event).toBeNull()
})
})

View File

@@ -0,0 +1,177 @@
/**
* In-App Agent protocol (prototype — ADR-0011).
*
* The cross-repo contract between the frontend (TS) and the server-side agent
* (Go, `Comfy-Org/cloud`). Inbound requests go to ingest `/api/agent/*`;
* outbound events arrive over the existing Redis-PubSub -> WebSocket bridge on
* `channel:ws:{workspaceId}:u:{userId}`.
*
* This is the single TS definition of that contract; it should be kept in sync
* with the Go side (open question: where the schema lives + how drift is caught).
*/
export type WorkflowId = string
export type ThreadId = string
export type MessageId = string
export type NodeId = string
/** Full save-format graph. Opaque here; validated by the workflow schema layer. */
export type WorkflowGraph = Record<string, unknown>
// ---------------------------------------------------------------------------
// Inbound: browser -> agent
// ---------------------------------------------------------------------------
/** Where an agent write lands (ADR-0001). */
export type AgentWriteTarget = 'active' | 'new_tab'
export interface AgentTurnRequest {
content: string
/** Selected node ids — the awareness input (ADR-0003). */
selection?: NodeId[]
/** Uploaded asset ids referenced by the turn. */
attachments?: string[]
target?: AgentWriteTarget
/** The tab's current draft version when `target === 'active'` (ADR-0005). */
baseVersion?: number
}
// ---------------------------------------------------------------------------
// Outbound: agent -> browser
// ---------------------------------------------------------------------------
export type AgentToolCallStatus = 'running' | 'success' | 'error'
interface AgentEventBase {
threadId: ThreadId
messageId: MessageId
}
export interface AgentMessageDeltaEvent extends AgentEventBase {
type: 'agent_message_delta'
delta: string
}
export interface AgentToolCallEvent extends AgentEventBase {
type: 'agent_tool_call'
toolCallId: string
toolName: string
status: AgentToolCallStatus
durationMs?: number
errorCode?: string
}
/** A graph write: full-document replace guarded by `version` (ADR-0004). */
export interface DraftPatchEvent extends AgentEventBase {
type: 'draft_patch'
workflowId: WorkflowId
content: WorkflowGraph
/** The new authoritative version after the agent's CAS commit. */
version: number
/** The version the agent started from; compared against the tab (ADR-0005). */
baseVersion: number
}
export interface AgentMessageDoneEvent extends AgentEventBase {
type: 'agent_message_done'
tokenUsage?: { input: number; output: number }
}
export type AgentEvent =
| AgentMessageDeltaEvent
| AgentToolCallEvent
| DraftPatchEvent
| AgentMessageDoneEvent
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
type WithBase = Record<string, unknown> & {
threadId: string
messageId: string
}
function hasBase(value: Record<string, unknown>): value is WithBase {
return (
typeof value.threadId === 'string' && typeof value.messageId === 'string'
)
}
function isToolCallStatus(value: unknown): value is AgentToolCallStatus {
return value === 'running' || value === 'success' || value === 'error'
}
function parseToolCall(raw: WithBase): AgentToolCallEvent | null {
if (
typeof raw.toolCallId !== 'string' ||
typeof raw.toolName !== 'string' ||
!isToolCallStatus(raw.status)
) {
return null
}
return {
type: 'agent_tool_call',
threadId: raw.threadId,
messageId: raw.messageId,
toolCallId: raw.toolCallId,
toolName: raw.toolName,
status: raw.status,
...(typeof raw.durationMs === 'number'
? { durationMs: raw.durationMs }
: {}),
...(typeof raw.errorCode === 'string' ? { errorCode: raw.errorCode } : {})
}
}
function parseDraftPatch(raw: WithBase): DraftPatchEvent | null {
if (
typeof raw.workflowId !== 'string' ||
!isRecord(raw.content) ||
typeof raw.version !== 'number' ||
typeof raw.baseVersion !== 'number'
) {
return null
}
return {
type: 'draft_patch',
threadId: raw.threadId,
messageId: raw.messageId,
workflowId: raw.workflowId,
content: raw.content,
version: raw.version,
baseVersion: raw.baseVersion
}
}
/**
* Decode an untrusted WebSocket payload into a typed `AgentEvent`, or `null` if
* it is not a well-formed agent event. Keeps the transport boundary type-safe.
*/
export function parseAgentEvent(raw: unknown): AgentEvent | null {
if (!isRecord(raw) || !hasBase(raw)) return null
switch (raw.type) {
case 'agent_message_delta':
return typeof raw.delta === 'string'
? {
type: 'agent_message_delta',
threadId: raw.threadId,
messageId: raw.messageId,
delta: raw.delta
}
: null
case 'agent_tool_call':
return parseToolCall(raw)
case 'draft_patch':
return parseDraftPatch(raw)
case 'agent_message_done':
return {
type: 'agent_message_done',
threadId: raw.threadId,
messageId: raw.messageId
}
default:
return null
}
}

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import type { DraftPatchEvent } from './agentProtocol'
import { reconcileDraftPatch } from './draftReconciler'
function patch(overrides: Partial<DraftPatchEvent> = {}): DraftPatchEvent {
return {
type: 'draft_patch',
threadId: 't1',
messageId: 'm1',
workflowId: 'wf1',
content: { nodes: [] },
version: 8,
baseVersion: 7,
...overrides
}
}
describe('reconcileDraftPatch', () => {
it('applies when the patch is based on the current tab version', () => {
const result = reconcileDraftPatch(patch({ baseVersion: 7, version: 8 }), 7)
expect(result).toEqual({ kind: 'apply', version: 8 })
})
it('flags a conflict when a concurrent edit advanced the tab', () => {
// Agent started from v7, but the user pushed the tab to v8 mid-turn.
const result = reconcileDraftPatch(patch({ baseVersion: 7, version: 9 }), 8)
expect(result).toEqual({ kind: 'conflict' })
})
it('ignores a stale patch the tab already supersedes', () => {
const result = reconcileDraftPatch(patch({ baseVersion: 7, version: 8 }), 8)
expect(result).toEqual({ kind: 'stale' })
})
it('ignores an older duplicate patch', () => {
const result = reconcileDraftPatch(
patch({ baseVersion: 5, version: 6 }),
10
)
expect(result).toEqual({ kind: 'stale' })
})
})

View File

@@ -0,0 +1,31 @@
/**
* Draft reconciliation (prototype — ADR-0004 / ADR-0005).
*
* Pure decision logic for an incoming `draft_patch`, given the version the tab
* currently holds. This is the load-bearing correctness piece: it decides
* whether a full-document replace applies cleanly, must surface the merge
* dialog, or is a stale/duplicate that should be ignored.
*/
import type { DraftPatchEvent } from './agentProtocol'
export type ReconcileResult =
/** Patch is based on the tab's current version — apply and adopt `version`. */
| { kind: 'apply'; version: number }
/** A concurrent user edit advanced the tab — surface the merge dialog. */
| { kind: 'conflict' }
/** Patch is superseded by what the tab already has — ignore (idempotency). */
| { kind: 'stale' }
/** User's choice in the merge dialog (ADR-0005). */
export type ConflictResolution = 'accept-agent' | 'keep-mine' | 'new-tab'
export function reconcileDraftPatch(
patch: DraftPatchEvent,
currentVersion: number
): ReconcileResult {
if (patch.version <= currentVersion) return { kind: 'stale' }
if (patch.baseVersion === currentVersion) {
return { kind: 'apply', version: patch.version }
}
return { kind: 'conflict' }
}

View File

@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from 'vitest'
import type { DraftPatchEvent } from './agentProtocol'
import type { AgentDraftPorts } from './useAgentDraftSync'
import { useAgentDraftSync } from './useAgentDraftSync'
function makePorts(): AgentDraftPorts {
return {
applyToTab: vi.fn(),
openInNewTab: vi.fn(),
discardAgentResult: vi.fn()
}
}
function patch(overrides: Partial<DraftPatchEvent> = {}): DraftPatchEvent {
return {
type: 'draft_patch',
threadId: 't1',
messageId: 'm1',
workflowId: 'wf1',
content: { nodes: ['ksampler'] },
version: 8,
baseVersion: 7,
...overrides
}
}
describe('useAgentDraftSync', () => {
it('applies a patch to the active tab and adopts the new version', () => {
const ports = makePorts()
const sync = useAgentDraftSync(ports)
sync.registerWorkflow('wf1', 7)
const outcome = sync.handlePatch(patch({ baseVersion: 7, version: 8 }))
expect(outcome).toBe('applied')
expect(ports.applyToTab).toHaveBeenCalledWith(
'wf1',
{ nodes: ['ksampler'] },
8
)
expect(sync.baseVersions.value.get('wf1')).toBe(8)
expect(sync.pendingConflict.value).toBeNull()
})
it('surfaces a conflict when the user edited the graph mid-turn', () => {
const ports = makePorts()
const sync = useAgentDraftSync(ports)
sync.registerWorkflow('wf1', 7)
sync.setVersion('wf1', 8) // local autosave advanced the tab
const outcome = sync.handlePatch(patch({ baseVersion: 7, version: 9 }))
expect(outcome).toBe('conflict')
expect(ports.applyToTab).not.toHaveBeenCalled()
expect(sync.pendingConflict.value).toMatchObject({
workflowId: 'wf1',
version: 9
})
})
it('ignores a stale patch', () => {
const ports = makePorts()
const sync = useAgentDraftSync(ports)
sync.registerWorkflow('wf1', 8)
const outcome = sync.handlePatch(patch({ baseVersion: 7, version: 8 }))
expect(outcome).toBe('ignored')
expect(ports.applyToTab).not.toHaveBeenCalled()
})
it('opens a new tab when the workflow has no open tab', () => {
const ports = makePorts()
const sync = useAgentDraftSync(ports)
const outcome = sync.handlePatch(patch({ workflowId: 'wf-new' }))
expect(outcome).toBe('opened-new-tab')
expect(ports.openInNewTab).toHaveBeenCalledWith(
'wf-new',
{ nodes: ['ksampler'] },
8
)
})
describe('resolveConflict', () => {
function setupConflict() {
const ports = makePorts()
const sync = useAgentDraftSync(ports)
sync.registerWorkflow('wf1', 7)
sync.setVersion('wf1', 8)
sync.handlePatch(patch({ baseVersion: 7, version: 9 }))
return { ports, sync }
}
it('accept-agent applies the agent version and adopts it', () => {
const { ports, sync } = setupConflict()
sync.resolveConflict('accept-agent')
expect(ports.applyToTab).toHaveBeenCalledWith(
'wf1',
{ nodes: ['ksampler'] },
9
)
expect(sync.baseVersions.value.get('wf1')).toBe(9)
expect(sync.pendingConflict.value).toBeNull()
})
it('keep-mine discards the agent result and keeps the tab version', () => {
const { ports, sync } = setupConflict()
sync.resolveConflict('keep-mine')
expect(ports.discardAgentResult).toHaveBeenCalledWith('wf1')
expect(ports.applyToTab).not.toHaveBeenCalled()
expect(sync.baseVersions.value.get('wf1')).toBe(8)
expect(sync.pendingConflict.value).toBeNull()
})
it('new-tab opens the agent result without touching the active tab', () => {
const { ports, sync } = setupConflict()
sync.resolveConflict('new-tab')
expect(ports.openInNewTab).toHaveBeenCalledWith(
'wf1',
{ nodes: ['ksampler'] },
9
)
expect(ports.applyToTab).not.toHaveBeenCalled()
expect(sync.pendingConflict.value).toBeNull()
})
})
})

View File

@@ -0,0 +1,132 @@
/**
* Agent draft sync (prototype — ADR-0011).
*
* Orchestrates the frontend side of agent graph writes: tracks the base
* `version` per open workflow (the version lifecycle), reconciles incoming
* `draft_patch` events, and drives the three merge-dialog outcomes.
*
* The canvas-facing effects are injected as `ports` so this is decoupled from
* litegraph / the workflow store and is unit-testable. In the real app the
* ports map to: `applyToTab` -> a destructive variant of `app.loadGraphData`;
* `openInNewTab` -> the existing non-destructive load; `discardAgentResult` ->
* a no-op (keep the user's canvas as-is).
*/
import { readonly, ref } from 'vue'
import type {
DraftPatchEvent,
WorkflowGraph,
WorkflowId
} from './agentProtocol'
import type { ConflictResolution } from './draftReconciler'
import { reconcileDraftPatch } from './draftReconciler'
export interface AgentDraftPorts {
applyToTab(
workflowId: WorkflowId,
content: WorkflowGraph,
version: number
): void
openInNewTab(
workflowId: WorkflowId,
content: WorkflowGraph,
version: number
): void
discardAgentResult(workflowId: WorkflowId): void
}
export interface PendingConflict {
workflowId: WorkflowId
content: WorkflowGraph
version: number
baseVersion: number
}
export type PatchOutcome = 'applied' | 'conflict' | 'ignored' | 'opened-new-tab'
export function useAgentDraftSync(ports: AgentDraftPorts) {
const baseVersions = ref(new Map<WorkflowId, number>())
const pendingConflict = ref<PendingConflict | null>(null)
/** Call when a draft tab opens, adopting its known version. */
function registerWorkflow(workflowId: WorkflowId, version: number): void {
baseVersions.value.set(workflowId, version)
}
function forgetWorkflow(workflowId: WorkflowId): void {
baseVersions.value.delete(workflowId)
}
/** Call after a local autosave returns a new server version. */
function setVersion(workflowId: WorkflowId, version: number): void {
baseVersions.value.set(workflowId, version)
}
function handlePatch(patch: DraftPatchEvent): PatchOutcome {
const current = baseVersions.value.get(patch.workflowId)
// Unknown workflow = no open tab for it (a new-tab write, or a tab the user
// closed mid-edit — see ADR-0011 open question). Route to a new tab.
if (current === undefined) {
ports.openInNewTab(patch.workflowId, patch.content, patch.version)
baseVersions.value.set(patch.workflowId, patch.version)
return 'opened-new-tab'
}
const result = reconcileDraftPatch(patch, current)
switch (result.kind) {
case 'apply':
ports.applyToTab(patch.workflowId, patch.content, result.version)
baseVersions.value.set(patch.workflowId, result.version)
return 'applied'
case 'conflict':
pendingConflict.value = {
workflowId: patch.workflowId,
content: patch.content,
version: patch.version,
baseVersion: patch.baseVersion
}
return 'conflict'
case 'stale':
return 'ignored'
}
}
function resolveConflict(decision: ConflictResolution): void {
const conflict = pendingConflict.value
if (!conflict) return
switch (decision) {
case 'accept-agent':
ports.applyToTab(
conflict.workflowId,
conflict.content,
conflict.version
)
baseVersions.value.set(conflict.workflowId, conflict.version)
break
case 'keep-mine':
ports.discardAgentResult(conflict.workflowId)
break
case 'new-tab':
ports.openInNewTab(
conflict.workflowId,
conflict.content,
conflict.version
)
break
}
pendingConflict.value = null
}
return {
baseVersions: readonly(baseVersions),
pendingConflict: readonly(pendingConflict),
registerWorkflow,
forgetWorkflow,
setVersion,
handlePatch,
resolveConflict
}
}

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { AgentRoom } from './agentRoom'
describe('AgentRoom', () => {
it('reconciles two peers via state-vector diff without conflict', () => {
const user = new AgentRoom('wf-1')
const agent = new AgentRoom('wf-1')
user.nodes.set('a', { title: 'Load Checkpoint' })
agent.nodes.set('b', { title: 'KSampler' })
agent.applyRemoteUpdate(user.diffSince(agent.encodeStateVector()))
user.applyRemoteUpdate(agent.diffSince(user.encodeStateVector()))
expect([...user.nodes.keys()].sort()).toEqual(['a', 'b'])
expect([...agent.nodes.keys()].sort()).toEqual(['a', 'b'])
})
it('merges concurrent edits to the same map deterministically', () => {
const user = new AgentRoom('wf-1')
const agent = new AgentRoom('wf-1')
agent.applyRemoteUpdate(user.encodeState())
user.nodes.set('shared', { x: 1 })
agent.nodes.set('shared', { x: 2 })
user.applyRemoteUpdate(agent.diffSince(user.encodeStateVector()))
agent.applyRemoteUpdate(user.diffSince(agent.encodeStateVector()))
expect(user.nodes.get('shared')).toEqual(agent.nodes.get('shared'))
})
it('reports an agent participant as editing via presence', () => {
const room = new AgentRoom('wf-1')
expect(room.isAgentEditing()).toBe(false)
room.setPresence({
actor: 'agent-1',
kind: 'agent',
status: 'editing',
focus: ['a'],
updatedAt: 0
})
expect(room.isAgentEditing()).toBe(true)
})
})

View File

@@ -0,0 +1,98 @@
/**
* Agent CRDT room (prototype — ADR-0011, building on ADR-0003).
*
* One collaborative workflow = one room: a thin wrapper over a Yjs `Y.Doc` that
* the browser and the server-side agent share. The agent is the first
* multiplayer peer. Top-level types (`nodes`/`links`/`reroutes`) mirror the live
* layout store so a room can drive the real canvas via binary updates
* (see `layoutStoreBinding.ts`). Conflict resolution is Yjs's job — no version
* CAS at this layer.
*/
import * as Y from 'yjs'
export type WorkflowId = string
export type ActorId = string
export type UpdateOrigin = unknown
export interface RoomPresence {
actor: ActorId
kind: 'user' | 'agent'
status: 'idle' | 'editing'
focus: string[]
updatedAt: number
}
const PRESENCE_KEY = '__presence'
export class AgentRoom {
readonly workflowId: WorkflowId
readonly doc: Y.Doc
readonly nodes: Y.Map<unknown>
readonly links: Y.Map<unknown>
readonly reroutes: Y.Map<unknown>
private readonly presence: Y.Map<RoomPresence>
constructor(workflowId: WorkflowId, doc: Y.Doc = new Y.Doc()) {
this.workflowId = workflowId
this.doc = doc
this.nodes = doc.getMap('nodes')
this.links = doc.getMap('links')
this.reroutes = doc.getMap('reroutes')
this.presence = doc.getMap(PRESENCE_KEY)
}
/** State vector describing what this peer already has (sync step 1). */
encodeStateVector(): Uint8Array {
return Y.encodeStateVector(this.doc)
}
/** Minimal update a peer needs given its state vector (sync step 2). */
diffSince(remoteStateVector: Uint8Array): Uint8Array {
return Y.encodeStateAsUpdate(this.doc, remoteStateVector)
}
/** Full state as a single update, used to seed a fresh peer. */
encodeState(): Uint8Array {
return Y.encodeStateAsUpdate(this.doc)
}
applyRemoteUpdate(update: Uint8Array, origin?: UpdateOrigin): void {
Y.applyUpdate(this.doc, update, origin)
}
onUpdate(cb: (update: Uint8Array, origin: UpdateOrigin) => void): () => void {
const handler = (update: Uint8Array, origin: UpdateOrigin) =>
cb(update, origin)
this.doc.on('update', handler)
return () => this.doc.off('update', handler)
}
setPresence(p: RoomPresence): void {
this.presence.set(p.actor, p)
}
clearPresence(actor: ActorId): void {
this.presence.delete(actor)
}
getPresence(): RoomPresence[] {
return [...this.presence.values()]
}
isAgentEditing(): boolean {
return this.getPresence().some(
(p) => p.kind === 'agent' && p.status === 'editing'
)
}
onPresenceChange(cb: (presence: RoomPresence[]) => void): () => void {
const handler = () => cb(this.getPresence())
this.presence.observe(handler)
return () => this.presence.unobserve(handler)
}
destroy(): void {
this.doc.destroy()
}
}

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { AgentRoomManager } from './agentRoomManager'
describe('AgentRoomManager', () => {
it('keeps one room alive across overlapping tab references', () => {
const manager = new AgentRoomManager()
const first = manager.join('wf-1')
const second = manager.join('wf-1')
expect(second).toBe(first)
manager.leave('wf-1')
expect(manager.has('wf-1')).toBe(true)
manager.leave('wf-1')
expect(manager.has('wf-1')).toBe(false)
})
it('keeps a pinned room alive with zero open tabs and reaps on unpin', () => {
const manager = new AgentRoomManager()
manager.join('wf-1')
manager.pin('wf-1')
manager.leave('wf-1')
expect(manager.has('wf-1')).toBe(true)
manager.unpin('wf-1')
expect(manager.has('wf-1')).toBe(false)
})
})

View File

@@ -0,0 +1,71 @@
/**
* Agent room manager (prototype — ADR-0011).
*
* Owns room lifecycle. A tab switch is a `join`/`leave`; a room stays alive in
* memory while any tab references it, so the agent can keep applying edits to a
* backgrounded workflow and the user sees them lazily on return (the scenario
* from the design meeting). `pin` keeps a room alive with zero open tabs while
* the agent is mid-edit; the room is torn down only when unreferenced and
* unpinned.
*/
import { AgentRoom } from './agentRoom'
import type { WorkflowId } from './agentRoom'
interface RoomEntry {
room: AgentRoom
refs: number
pinned: boolean
}
export class AgentRoomManager {
private readonly entries = new Map<WorkflowId, RoomEntry>()
join(workflowId: WorkflowId): AgentRoom {
const existing = this.entries.get(workflowId)
if (existing) {
existing.refs += 1
return existing.room
}
const room = new AgentRoom(workflowId)
this.entries.set(workflowId, { room, refs: 1, pinned: false })
return room
}
leave(workflowId: WorkflowId): void {
const entry = this.entries.get(workflowId)
if (!entry) return
entry.refs = Math.max(0, entry.refs - 1)
this.reapIfIdle(workflowId, entry)
}
pin(workflowId: WorkflowId): void {
const entry = this.entries.get(workflowId)
if (entry) entry.pinned = true
}
unpin(workflowId: WorkflowId): void {
const entry = this.entries.get(workflowId)
if (!entry) return
entry.pinned = false
this.reapIfIdle(workflowId, entry)
}
get(workflowId: WorkflowId): AgentRoom | undefined {
return this.entries.get(workflowId)?.room
}
has(workflowId: WorkflowId): boolean {
return this.entries.has(workflowId)
}
get size(): number {
return this.entries.size
}
private reapIfIdle(workflowId: WorkflowId, entry: RoomEntry): void {
if (entry.refs === 0 && !entry.pinned) {
entry.room.destroy()
this.entries.delete(workflowId)
}
}
}

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import * as Y from 'yjs'
import { AgentRoom } from './agentRoom'
import { bindRoomToDoc } from './roomDocBinding'
describe('bindRoomToDoc', () => {
it('seeds the room from the doc and pushes agent edits back', () => {
const doc = new Y.Doc()
doc.getMap('nodes').set('existing', { title: 'Existing' })
const room = new AgentRoom('wf-1')
const unbind = bindRoomToDoc(room, doc)
expect(room.nodes.get('existing')).toEqual({ title: 'Existing' })
room.nodes.set('agent', { title: 'Agent Node' })
expect(doc.getMap('nodes').get('agent')).toEqual({ title: 'Agent Node' })
unbind()
room.nodes.set('after-unbind', {})
expect(doc.getMap('nodes').has('after-unbind')).toBe(false)
})
it('reflects doc edits into the room while bound', () => {
const doc = new Y.Doc()
const room = new AgentRoom('wf-1')
bindRoomToDoc(room, doc)
doc.getMap('nodes').set('from-canvas', { title: 'Canvas' })
expect(room.nodes.get('from-canvas')).toEqual({ title: 'Canvas' })
})
})

View File

@@ -0,0 +1,35 @@
/**
* Binds an agent room to a Yjs document (prototype — ADR-0011).
*
* Generic, layer-safe core of the canvas binding: it works on any `Y.Doc`, so
* it stays in the platform layer with no renderer dependency. The renderer-layer
* wrapper (`renderer/core/layout/agent/bindRoomToLayoutStore.ts`) supplies the
* live layout store's doc. Bidirectional Yjs sync with origin tags prevents echo
* loops; Yjs reconciles concurrent edits.
*/
import * as Y from 'yjs'
import type { AgentRoom } from './agentRoom'
const AGENT_ORIGIN = Symbol('agent-room->doc')
const DOC_ORIGIN = Symbol('doc->agent-room')
export function bindRoomToDoc(room: AgentRoom, doc: Y.Doc): () => void {
Y.applyUpdate(room.doc, Y.encodeStateAsUpdate(doc), DOC_ORIGIN)
Y.applyUpdate(doc, Y.encodeStateAsUpdate(room.doc), AGENT_ORIGIN)
const onRoom = (update: Uint8Array, origin: unknown) => {
if (origin !== DOC_ORIGIN) Y.applyUpdate(doc, update, AGENT_ORIGIN)
}
const onDoc = (update: Uint8Array, origin: unknown) => {
if (origin !== AGENT_ORIGIN) Y.applyUpdate(room.doc, update, DOC_ORIGIN)
}
room.doc.on('update', onRoom)
doc.on('update', onDoc)
return () => {
room.doc.off('update', onRoom)
doc.off('update', onDoc)
}
}

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { AgentRoom } from './agentRoom'
import type { RoomSyncMessage, RoomTransport } from './roomSync'
import { syncRoom } from './roomSync'
class LoopbackTransport implements RoomTransport {
private listeners = new Set<(m: RoomSyncMessage) => void>()
peer: LoopbackTransport | null = null
send(message: RoomSyncMessage): void {
this.peer?.listeners.forEach((cb) => cb(message))
}
onMessage(cb: (m: RoomSyncMessage) => void): () => void {
this.listeners.add(cb)
return () => this.listeners.delete(cb)
}
}
function connect() {
const a = new LoopbackTransport()
const b = new LoopbackTransport()
a.peer = b
b.peer = a
return [a, b] as const
}
describe('syncRoom', () => {
it('converges existing state on connect via the sync handshake', () => {
const [t1, t2] = connect()
const user = new AgentRoom('wf-1')
const agent = new AgentRoom('wf-1')
user.nodes.set('a', { title: 'Load Checkpoint' })
syncRoom(user, t1)
syncRoom(agent, t2)
expect(agent.nodes.get('a')).toEqual({ title: 'Load Checkpoint' })
})
it('propagates live edits and ignores other workflows', () => {
const [t1, t2] = connect()
const user = new AgentRoom('wf-1')
const agent = new AgentRoom('wf-1')
syncRoom(user, t1)
syncRoom(agent, t2)
agent.nodes.set('b', { title: 'KSampler' })
expect(user.nodes.get('b')).toEqual({ title: 'KSampler' })
t2.send({
type: 'update',
workflowId: 'other',
update: agent.encodeState()
})
expect(user.nodes.has('b')).toBe(true)
})
})

View File

@@ -0,0 +1,63 @@
/**
* Room sync protocol (prototype — ADR-0011).
*
* Transport-agnostic two-phase Yjs sync, modelled on `y-protocols/sync`:
* step 1: a peer announces its state vector
* step 2: the other peer replies with the diff that vector is missing
* update: incremental updates are broadcast as they happen
*
* The transport is injected so this works over the existing Redis→WebSocket
* bridge in V0 and a dedicated relay later. Updates applied from the transport
* carry the `REMOTE_ORIGIN` tag so they are not echoed back.
*/
import type { AgentRoom } from './agentRoom'
export type RoomSyncMessage =
| { type: 'sync-step-1'; workflowId: string; stateVector: Uint8Array }
| { type: 'sync-step-2'; workflowId: string; update: Uint8Array }
| { type: 'update'; workflowId: string; update: Uint8Array }
export interface RoomTransport {
send(message: RoomSyncMessage): void
onMessage(cb: (message: RoomSyncMessage) => void): () => void
}
const REMOTE_ORIGIN = Symbol('agent-room-remote')
export function syncRoom(
room: AgentRoom,
transport: RoomTransport
): () => void {
const offUpdate = room.onUpdate((update, origin) => {
if (origin === REMOTE_ORIGIN) return
transport.send({ type: 'update', workflowId: room.workflowId, update })
})
const offMessage = transport.onMessage((message) => {
if (message.workflowId !== room.workflowId) return
switch (message.type) {
case 'sync-step-1':
transport.send({
type: 'sync-step-2',
workflowId: room.workflowId,
update: room.diffSince(message.stateVector)
})
return
case 'sync-step-2':
case 'update':
room.applyRemoteUpdate(message.update, REMOTE_ORIGIN)
return
}
})
transport.send({
type: 'sync-step-1',
workflowId: room.workflowId,
stateVector: room.encodeStateVector()
})
return () => {
offUpdate()
offMessage()
}
}

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest'
import type { AgentEvent } from '../common/agentProtocol'
import { createSessionState, sessionReducer } from './agentSessionStore'
import type { SessionState } from './agentSessionStore'
function reduce(state: SessionState, events: AgentEvent[]): SessionState {
return events.reduce(
(acc, event) => sessionReducer(acc, { type: 'agent-event', event }),
state
)
}
describe('sessionReducer', () => {
it('accumulates streaming deltas into one agent message', () => {
const start = sessionReducer(createSessionState('t-1'), {
type: 'user-send',
id: 'u-1',
content: 'hello'
})
const next = reduce(start, [
{
type: 'agent_message_delta',
threadId: 't-1',
messageId: 'm-1',
delta: 'Hi '
},
{
type: 'agent_message_delta',
threadId: 't-1',
messageId: 'm-1',
delta: 'there'
}
])
expect(next.messages).toHaveLength(2)
expect(next.messages[1]).toMatchObject({
role: 'agent',
content: 'Hi there',
streaming: true
})
expect(next.status).toBe('streaming')
})
it('does not mutate the previous state (structural sharing)', () => {
const before = createSessionState('t-1')
const after = sessionReducer(before, {
type: 'user-send',
id: 'u-1',
content: 'hello'
})
expect(before.messages).toHaveLength(0)
expect(after.messages).toHaveLength(1)
expect(after).not.toBe(before)
})
it('tracks tool-call lifecycle and surfaces errors', () => {
const next = reduce(createSessionState('t-1'), [
{
type: 'agent_tool_call',
threadId: 't-1',
messageId: 'm-1',
toolCallId: 'tc-1',
toolName: 'load_graph',
status: 'running'
},
{
type: 'agent_tool_call',
threadId: 't-1',
messageId: 'm-1',
toolCallId: 'tc-1',
toolName: 'load_graph',
status: 'error',
errorCode: 'BAD_GRAPH'
}
])
expect(next.messages[0].toolCalls).toHaveLength(1)
expect(next.messages[0].toolCalls[0]).toMatchObject({
status: 'error',
errorCode: 'BAD_GRAPH'
})
expect(next.status).toBe('error')
})
it('ends streaming on done', () => {
const next = reduce(createSessionState('t-1'), [
{
type: 'agent_message_delta',
threadId: 't-1',
messageId: 'm-1',
delta: 'x'
},
{ type: 'agent_message_done', threadId: 't-1', messageId: 'm-1' }
])
expect(next.messages[0].streaming).toBe(false)
expect(next.status).toBe('idle')
})
})

View File

@@ -0,0 +1,110 @@
/**
* Agent chat session state (prototype — ADR-0011).
*
* Immer-backed reducer for the *local, single-client* chat surface: streaming
* message deltas, tool-call lifecycle, run status. This is deliberately not a
* CRDT — chat is owned by one browser tab, so structural-sharing immutable
* updates (Immer) are the right tool. Graph state lives in the Yjs room layer
* (`../crdt`); the two never mix.
*/
import { produce } from 'immer'
import type { AgentEvent, MessageId, ThreadId } from '../common/agentProtocol'
interface ToolCallView {
id: string
name: string
status: 'running' | 'success' | 'error'
durationMs?: number
errorCode?: string
}
interface ChatMessage {
id: MessageId
role: 'user' | 'agent'
content: string
streaming: boolean
toolCalls: ToolCallView[]
}
export interface SessionState {
threadId: ThreadId
messages: ChatMessage[]
status: 'idle' | 'streaming' | 'error'
}
export type SessionAction =
| { type: 'user-send'; id: MessageId; content: string }
| { type: 'agent-event'; event: AgentEvent }
export function createSessionState(threadId: ThreadId): SessionState {
return { threadId, messages: [], status: 'idle' }
}
function ensureAgentMessage(state: SessionState, id: MessageId): ChatMessage {
const existing = state.messages.find((m) => m.id === id)
if (existing) return existing
const created: ChatMessage = {
id,
role: 'agent',
content: '',
streaming: true,
toolCalls: []
}
state.messages.push(created)
return created
}
function applyAgentEvent(state: SessionState, event: AgentEvent): void {
switch (event.type) {
case 'agent_message_delta': {
const message = ensureAgentMessage(state, event.messageId)
message.content += event.delta
message.streaming = true
state.status = 'streaming'
return
}
case 'agent_tool_call': {
const message = ensureAgentMessage(state, event.messageId)
const existing = message.toolCalls.find((t) => t.id === event.toolCallId)
const view: ToolCallView = {
id: event.toolCallId,
name: event.toolName,
status: event.status,
durationMs: event.durationMs,
errorCode: event.errorCode
}
if (existing) Object.assign(existing, view)
else message.toolCalls.push(view)
if (event.status === 'error') state.status = 'error'
return
}
case 'agent_message_done': {
const message = ensureAgentMessage(state, event.messageId)
message.streaming = false
if (state.status !== 'error') state.status = 'idle'
return
}
case 'draft_patch':
return
}
}
export function sessionReducer(
state: SessionState,
action: SessionAction
): SessionState {
return produce(state, (draft) => {
if (action.type === 'user-send') {
draft.messages.push({
id: action.id,
role: 'user',
content: action.content,
streaming: false,
toolCalls: []
})
return
}
applyAgentEvent(draft, action.event)
})
}

View File

@@ -0,0 +1,377 @@
import type { AxiosAdapter } from 'axios'
import axios, { AxiosError } from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
attachUnifiedRemintInterceptor,
fetchWithUnifiedRemint
} from '@/platform/auth/unified/remintRetry'
const { mockRemint, flagState } = vi.hoisted(() => ({
mockRemint: vi.fn(),
flagState: { unifiedCloudAuthEnabled: true }
}))
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => ({ remintUnifiedOnce: mockRemint })
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get unifiedCloudAuthEnabled() {
return flagState.unifiedCloudAuthEnabled
}
}
})
}))
// The axios interceptor gates on shouldRemintCloudRequest(), which is a no-op
// off-cloud; the unit env is not a cloud build, so force it on.
vi.mock('@/platform/distribution/types', () => ({ isCloud: true }))
describe('fetchWithUnifiedRemint', () => {
const ok = { status: 200 } as Response
const unauthorized = { status: 401 } as Response
let mockFetch: ReturnType<typeof vi.fn>
beforeEach(() => {
mockRemint.mockReset()
flagState.unifiedCloudAuthEnabled = true
mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('re-mints once and retries with the fresh token on a 401 (AC1)', async () => {
mockFetch.mockResolvedValueOnce(unauthorized).mockResolvedValueOnce(ok)
mockRemint.mockResolvedValue('tokenB')
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{ headers: { Authorization: 'Bearer tokenA', 'Comfy-User': 'u1' } },
true
)
expect(result).toBe(ok)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(mockRemint).toHaveBeenCalledTimes(1)
const retryHeaders = new Headers(mockFetch.mock.calls[1][1].headers)
expect(retryHeaders.get('Authorization')).toBe('Bearer tokenB')
expect(retryHeaders.get('Comfy-User')).toBe('u1')
})
it('surfaces a persistent 401 after exactly one retry (AC2)', async () => {
const secondUnauthorized = { status: 401 } as Response
mockFetch
.mockResolvedValueOnce(unauthorized)
.mockResolvedValueOnce(secondUnauthorized)
mockRemint.mockResolvedValue('tokenB')
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{ headers: { Authorization: 'Bearer tokenA' } },
true
)
expect(result).toBe(secondUnauthorized)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(mockRemint).toHaveBeenCalledTimes(1)
})
it('does not re-mint or retry when the caller gate is false (AC3)', async () => {
mockFetch.mockResolvedValueOnce(unauthorized)
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{ headers: { Authorization: 'Bearer tokenA' } },
false
)
expect(result).toBe(unauthorized)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockRemint).not.toHaveBeenCalled()
})
it('does not retry a non-401 response', async () => {
const serverError = { status: 500 } as Response
mockFetch.mockResolvedValueOnce(serverError)
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{ headers: { Authorization: 'Bearer t' } },
true
)
expect(result).toBe(serverError)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockRemint).not.toHaveBeenCalled()
})
it('surfaces the original 401 when the re-mint yields no token', async () => {
mockFetch.mockResolvedValueOnce(unauthorized)
mockRemint.mockResolvedValue(null)
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{ headers: { Authorization: 'Bearer t' } },
true
)
expect(result).toBe(unauthorized)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockRemint).toHaveBeenCalledTimes(1)
})
it('surfaces the original 401 when the re-mint throws a permanent auth error', async () => {
mockFetch.mockResolvedValueOnce(unauthorized)
mockRemint.mockRejectedValue(new Error('INVALID_FIREBASE_TOKEN'))
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{ headers: { Authorization: 'Bearer t' } },
true
)
expect(result).toBe(unauthorized)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockRemint).toHaveBeenCalledTimes(1)
})
it('surfaces the original 401 without re-minting when the body is a non-replayable stream', async () => {
mockFetch.mockResolvedValueOnce(unauthorized)
mockRemint.mockResolvedValue('tokenB')
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{
method: 'POST',
body: new ReadableStream<Uint8Array>(),
headers: { Authorization: 'Bearer tokenA' }
},
true
)
expect(result).toBe(unauthorized)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockRemint).not.toHaveBeenCalled()
})
it.for([
{
shape: 'object',
headers: { Authorization: 'Bearer tokenA', 'Comfy-User': 'u1' }
},
{
shape: 'array of tuples',
headers: [
['Authorization', 'Bearer tokenA'],
['Comfy-User', 'u1']
]
},
{
shape: 'Headers',
headers: new Headers({
Authorization: 'Bearer tokenA',
'Comfy-User': 'u1'
})
}
] as { shape: string; headers: HeadersInit }[])(
'preserves method/body and replaces Authorization on a POST retry ($shape headers)',
async ({ headers }) => {
mockFetch.mockResolvedValueOnce(unauthorized).mockResolvedValueOnce(ok)
mockRemint.mockResolvedValue('tokenB')
const body = JSON.stringify({ amount: 5 })
await fetchWithUnifiedRemint(
'https://cloud/x',
{ method: 'POST', body, headers },
true
)
const retryInit = mockFetch.mock.calls[1][1]
expect(retryInit.method).toBe('POST')
expect(retryInit.body).toBe(body)
const retryHeaders = new Headers(retryInit.headers)
expect(retryHeaders.get('Authorization')).toBe('Bearer tokenB')
expect(retryHeaders.get('Comfy-User')).toBe('u1')
}
)
})
describe('attachUnifiedRemintInterceptor', () => {
beforeEach(() => {
mockRemint.mockReset()
flagState.unifiedCloudAuthEnabled = true
})
// A custom axios adapter is responsible for its own status handling (axios
// only applies validateStatus inside its built-in adapters), so reject
// non-2xx with a real AxiosError to mirror a live response.
function makeAdapter(statuses: number[]): ReturnType<typeof vi.fn> {
let call = 0
return vi.fn<AxiosAdapter>(async (config) => {
const status = statuses[Math.min(call, statuses.length - 1)]
call++
const response = {
data: status === 200 ? { ok: true } : { message: 'unauthorized' },
status,
statusText: String(status),
headers: {},
config
}
if (status >= 200 && status < 300) {
return response
}
throw new AxiosError(
`Request failed with status code ${status}`,
AxiosError.ERR_BAD_REQUEST,
config,
null,
response
)
})
}
function makeClient(statuses: number[]) {
const adapter = makeAdapter(statuses)
const client = axios.create({ adapter: adapter as unknown as AxiosAdapter })
attachUnifiedRemintInterceptor(client)
return { client, adapter }
}
it('re-mints once and retries the request with the fresh token (AC1)', async () => {
const { client, adapter } = makeClient([401, 200])
mockRemint.mockResolvedValue('tokenB')
const res = await client.get('https://cloud/x', {
headers: { Authorization: 'Bearer tokenA' }
})
expect(res.status).toBe(200)
expect(adapter).toHaveBeenCalledTimes(2)
expect(mockRemint).toHaveBeenCalledTimes(1)
expect(String(adapter.mock.calls[1][0].headers.Authorization)).toBe(
'Bearer tokenB'
)
})
it('retries once then surfaces a persistent 401 (AC2)', async () => {
const { client, adapter } = makeClient([401, 401])
mockRemint.mockResolvedValue('tokenB')
await expect(
client.get('https://cloud/x', {
headers: { Authorization: 'Bearer tokenA' }
})
).rejects.toMatchObject({ response: { status: 401 } })
expect(adapter).toHaveBeenCalledTimes(2)
expect(mockRemint).toHaveBeenCalledTimes(1)
})
it('does not re-mint when the flag is OFF (AC3)', async () => {
flagState.unifiedCloudAuthEnabled = false
const { client, adapter } = makeClient([401])
await expect(
client.get('https://cloud/x', {
headers: { Authorization: 'Bearer tokenA' }
})
).rejects.toMatchObject({ response: { status: 401 } })
expect(adapter).toHaveBeenCalledTimes(1)
expect(mockRemint).not.toHaveBeenCalled()
})
it('does not re-mint a request flagged __skipUnifiedRemint (acceptInvite)', async () => {
const { client, adapter } = makeClient([401])
mockRemint.mockResolvedValue('tokenB')
await expect(
client.post('https://cloud/invites/x/accept', null, {
headers: { Authorization: 'Bearer firebase' },
__skipUnifiedRemint: true
})
).rejects.toMatchObject({ response: { status: 401 } })
expect(adapter).toHaveBeenCalledTimes(1)
expect(mockRemint).not.toHaveBeenCalled()
})
it('passes a non-401 error through without re-minting', async () => {
const { client } = makeClient([500])
await expect(
client.get('https://cloud/x', { headers: { Authorization: 'Bearer t' } })
).rejects.toMatchObject({ response: { status: 500 } })
expect(mockRemint).not.toHaveBeenCalled()
})
it('preserves the POST body and method on a retry, with the fresh token', async () => {
const { client, adapter } = makeClient([401, 200])
mockRemint.mockResolvedValue('tokenB')
const res = await client.post(
'https://cloud/topup',
{ amount: 5 },
{ headers: { Authorization: 'Bearer tokenA' } }
)
expect(res.status).toBe(200)
expect(adapter).toHaveBeenCalledTimes(2)
const firstConfig = adapter.mock.calls[0][0]
const retryConfig = adapter.mock.calls[1][0]
expect(retryConfig.method).toBe('post')
expect(retryConfig.data).toBe(firstConfig.data)
expect(String(retryConfig.headers.Authorization)).toBe('Bearer tokenB')
})
it('latches per request — a second request still retries once (no shared latch)', async () => {
// Per-URL: first call 401, second (the retry) 200. The latch lives on each
// request's config, so a second request must retry independently.
const callsByUrl = new Map<string, number>()
const adapter = vi.fn<AxiosAdapter>(async (config) => {
const url = config.url ?? ''
const nth = (callsByUrl.get(url) ?? 0) + 1
callsByUrl.set(url, nth)
const okStatus = nth >= 2
const response = {
data: okStatus ? { ok: true } : { message: 'unauthorized' },
status: okStatus ? 200 : 401,
statusText: okStatus ? '200' : '401',
headers: {},
config
}
if (okStatus) return response
throw new AxiosError(
'Request failed with status code 401',
AxiosError.ERR_BAD_REQUEST,
config,
null,
response
)
})
const client = axios.create({ adapter: adapter as unknown as AxiosAdapter })
attachUnifiedRemintInterceptor(client)
mockRemint.mockResolvedValue('tokenB')
const a = await client.get('https://cloud/a', {
headers: { Authorization: 'Bearer tokenA' }
})
const b = await client.get('https://cloud/b', {
headers: { Authorization: 'Bearer tokenA' }
})
expect(a.status).toBe(200)
expect(b.status).toBe(200)
// Each request: initial 401 + one retry = 4 adapter calls, one re-mint each.
expect(adapter).toHaveBeenCalledTimes(4)
expect(mockRemint).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,131 @@
import type {
AxiosError,
AxiosInstance,
InternalAxiosRequestConfig
} from 'axios'
import axios, { AxiosHeaders } from 'axios'
import { isCloud } from '@/platform/distribution/types'
let cachedUnifiedFlags:
| { readonly unifiedCloudAuthEnabled: boolean }
| undefined
/**
* Single gate for the reactive guard: a cloud build with `unified_cloud_auth`
* ON. Memoizes the feature-flag accessor so the hot `fetchApi` path does not
* build a fresh reactive proxy per request (the cached getter still reflects
* live flag changes), and is reused at every cloud request seam so the gate
* cannot be forgotten on a new call site.
*/
export async function shouldRemintCloudRequest(): Promise<boolean> {
if (!isCloud) return false
if (!cachedUnifiedFlags) {
const { useFeatureFlags } = await import('@/composables/useFeatureFlags')
cachedUnifiedFlags = useFeatureFlags().flags
}
return cachedUnifiedFlags.unifiedCloudAuthEnabled
}
/**
* Re-mints the unified Cloud JWT once from the current Firebase identity and
* returns the fresh token, or `null` when there is nothing to retry with: no
* active unified session, or the re-mint failed. A permanent auth failure is
* surfaced + torn down inside `remintUnifiedOnce` (error toast + session clear,
* matching the proactive refresh path); the `catch` here only guards an
* unexpected throw (e.g. a chunk-load failure or no active Pinia), which it
* logs. Either way `null` makes the caller surface its original 401 unchanged.
*/
async function tryRemintToken(): Promise<string | null> {
try {
const { useWorkspaceAuthStore } =
await import('@/platform/workspace/stores/workspaceAuthStore')
return await useWorkspaceAuthStore().remintUnifiedOnce()
} catch (err) {
console.warn('Unified re-mint primitive threw unexpectedly:', err)
return null
}
}
/**
* Issues a `fetch` and, on a `401`, re-mints the unified Cloud JWT once and
* retries the request exactly once with the fresh token. A persistent `401`
* (or a `null` re-mint) surfaces the original Response unchanged — no retry
* loop. Requires a replayable body: a one-shot `ReadableStream` body cannot be
* replayed, so such a request surfaces its original `401` without a retry (no
* current cloud caller sends one).
*
* `shouldRetryOn401` is the caller's gate (see {@link shouldRemintCloudRequest}):
* flag-OFF traffic returns after a single `fetch` and never enters the re-mint
* path, so the legacy cascade stays untouched for instant rollback.
*/
export async function fetchWithUnifiedRemint(
input: RequestInfo | URL,
init: RequestInit,
shouldRetryOn401: boolean
): Promise<Response> {
const response = await fetch(input, init)
if (!shouldRetryOn401 || response.status !== 401) {
return response
}
if (init.body instanceof ReadableStream) {
console.warn(
'fetchWithUnifiedRemint: a ReadableStream body is not replayable; surfacing the original 401'
)
return response
}
const token = await tryRemintToken()
if (!token) {
return response
}
const headers = new Headers(init.headers)
headers.set('Authorization', `Bearer ${token}`)
return fetch(input, { ...init, headers })
}
function isRetriableUnauthorized(
error: unknown
): error is AxiosError & { config: InternalAxiosRequestConfig } {
if (!axios.isAxiosError(error)) return false
const config = error.config
if (!config || config.__unifiedRetried || config.__skipUnifiedRemint) {
return false
}
return error.response?.status === 401
}
/**
* Installs a response interceptor that gives a cloud axios client the same
* reactive 401 guard as {@link fetchWithUnifiedRemint}: a single re-mint + a
* single retry on `401`, surfacing a persistent `401` unchanged. A strict
* no-op while `unified_cloud_auth` is OFF — the original error rejects exactly
* as it does today.
*/
export function attachUnifiedRemintInterceptor(client: AxiosInstance): void {
client.interceptors.response.use(
(response) => response,
async (error: unknown) => {
if (
!isRetriableUnauthorized(error) ||
!(await shouldRemintCloudRequest())
) {
throw error
}
const token = await tryRemintToken()
if (!token) {
throw error
}
// Clone (don't mutate) the caller's config so the re-minted Bearer never
// leaks into a caller-retained reference, matching fetchWithUnifiedRemint.
const { config } = error
const headers = new AxiosHeaders(config.headers)
headers.set('Authorization', `Bearer ${token}`)
return client.request({ ...config, headers, __unifiedRetried: true })
}
)
}

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