Compare commits

..

37 Commits

Author SHA1 Message Date
shrimbly
763c1e63f5 test: cover hide/show link menu actions
- use ctx save/restore in the badge connector stub
- note the single-canvas assumption for module-level badge state
2026-07-01 19:24:19 +12:00
shrimbly
3af08e8750 feat: attach a revealed link's curve to its label tip
Split badge layout from painting so each badge's position is known while
the curve is drawn. A revealed link's curve now attaches to the far edge
of each label instead of the socket, so it appears to flow out of the
label. Painting stays deferred, keeping labels above the curves.
2026-07-01 11:31:53 +12:00
shrimbly
202a51124c feat: hideable links with renamable end badges
Hide a link's curve and show a small renamable badge at each end instead.

- Right-click a link to Hide Link; right-click or double-click a badge to
  Show Link / Rename.
- Badges stack to avoid overlapping each other or nearby sockets' labels and
  are painted above the links.
- Hovering a badge, or a socket it connects to, reveals the link.
- hidden/label persist through serialization, so existing workflows are
  unaffected.

Hide/Show/Rename run through the graph change lifecycle so they are
undo/version aware.
2026-07-01 10:30:22 +12: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
ShihChi Huang
7ab6cb57c5 test: 1/x fix coverage run (#13086)
## Summary

Fix the two current blockers that prevented `pnpm test:coverage` from
completing on `main`.

Stack order: 1/x

## Changes

- Mock `load3dAdvanced` in the lazy-loader test so coverage does not
import the real Load3DAdvanced UI graph.
- Track the active workflow status in `useWorkflowStatusDismissal` so
terminal statuses arriving after activation are cleared.

## Test Results

| | before | after |
| -- | -- | -- |
| `pnpm test:coverage` |  failed, so the stack had no usable coverage
baseline |  passed with 877 test files passed; 11,772 passed / 8
skipped |
| focused tests | `load3dLazy` timed out; `useWorkflowStatusDismissal`
failed its active-workflow status case |  `load3dLazy`: 13 passed;
`useWorkflowStatusDismissal`: 4 passed |

## Coverage

| | before | after |
| -- | -- | -- |
| statements | unavailable | 62.84% |
| branches | unavailable | 53.03% |
| functions | unavailable | 56.94% |
| lines | unavailable | 64.05% |

Screenshots: N/A, no UI change.

<!-- CURSOR_SUMMARY -->
---

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

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-06-24 23:08:29 +00:00
Alexis Rolland
3c3a2ab4e2 fix: Load Audio node not caching execution (#12950)
## Summary

This PR fixes a bug where the Load Audio node re-executes everytime.

## Changes

- **What**: Mark `audioUIWidget.options.serialize = false`

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-06-24 23:04:26 +00:00
Dante
a07854755f fix(billing): restore unified pricing dialog width (Reka renderer regression) (#13092)
## Summary

Restore the unified "Choose a Plan" pricing dialog width — it was
collapsing to the default `md` (576px) frame, so the 1280px table
overflowed and rendered off-center with the right card clipped.

## Changes

- **What**: `showPricingTable` opens the unified dialog
(`SubscriptionRequiredDialogContentUnified`) with PrimeVue-path props
for sizing (`style: 'max-width: 95vw'` + `pt`). Since #12593 (FE-578
Phase 6a) made **Reka the default dialog renderer**, those props are
ignored — Reka sizes via `size`/`contentClass`, so the dialog fell back
to `size: 'md'` (`max-w-xl` = 576px). The content root's
`xl:w-[min(1280px,95vw)]` then overflowed the 576px box and shifted
off-center. Moved the width onto a Reka `contentClass` (`w-fit
max-w-[min(1280px,95vw)]`), matching the sibling subscription dialogs in
the same file.

## Review Focus

- **Regression origin**: the broken config landed when #12666 (FE-934,
UnifiedPricingTable) merged on top of #12593's reka-default flip while
still using the PrimeVue config. No merge conflict — the `style` line is
valid but dead, so it broke silently. FE-991 (#12792) predates #12593,
so it still rendered via PrimeVue and looked correct (matching the
report that it was fine there).
- **`w-fit` vs fixed width**: `w-fit` preserves the original "dialog
hugs its content per step" intent — the content root only sets the
1280px width on the pricing step, so confirm/success steps still shrink
instead of floating in a 1280px box.
- Out of scope: the legacy-team / flag-off paths share a PrimeVue
`style` shell and are likely affected the same way under Reka; left for
a follow-up (flag-off is the lower-priority OSS path).

## Verification

- Unit test `useSubscriptionDialog.test.ts` — red without the fix
(dialog has no `contentClass`), green with it.
- Verified live (cloud dev, viewport 1301px): box centered at 1236px
(95vw), no overflow, all three personal cards visible.

## Screenshots

Personal tab, viewport 1301px:

| Before | After |
| --- | --- |
| <img width="480" alt="before"
src="https://github.com/user-attachments/assets/e233fe00-f754-4e34-837f-cf6630ccbfb9"
/> | <img width="480" alt="after"
src="https://github.com/user-attachments/assets/dedd92b7-8707-4865-b7f3-289919043b48"
/> |
2026-06-24 22:23:00 +00:00
CodeJuggernaut
2adef5d9f6 Create script for pointing at prod and staging backends (#13096)
## Summary

Allows engineers to run their localhost frontend while choosing which
backend to point. This PR adds staging and prod as targets.
## Changes

- **What**: New NPM scripts: `dev:cloud:test`, `dev:cloud:staging`, and
`dev:cloud:prod`. `dev:cloud` points at `dev:cloud:test`
- **Breaking**: None

## Why

Currently, the testcloud environment is broken (backend config issue)
and doesn't allow going through the subscription registration process.
This also allows testing frontend code against backend changes being
staged for release, as well as against actual backend production code.
2026-06-24 21:39:42 +00:00
AustinMroz
c406042215 More robust drag cleanup (#13084)
Under some circumstances, (particularly with pointerCancel events) a
drag operation could end without properly being cleaned up. When this
occurs, the bugged state would manifest in comical ways
- Nodes would 'run away' from the cursor
<img width="1024" height="1024" alt="AnimateDiff_00001"
src="https://github.com/user-attachments/assets/accfeac0-ce4c-4d8a-b3b8-6b243e8d5f8d"
/>

- Resizing the window could cause the zombie drag to move into the
autopan region which would result in nodes rapidly scrolling away.
<img width="1024" height="1024" alt="AnimateDiff_00002"
src="https://github.com/user-attachments/assets/e30629f4-ddea-4981-83d8-0037b3010ad5"
/>


This is resolved by adding more robust cleanup for canceled drag events.

This PR also cleanups a sizeable chunk of dead TransformPane code which
was unused.
2026-06-24 17:49:30 +00:00
pythongosssss
395b0a1c89 fix: prevent NullGraphError on subgraph node removal (#11804)
## Summary

Various race conditions can cause `NullGraphError` to be thrown after
removing/converting a subgraph. This fix guards at call sites and
refactors to add a pre-removal phase before the graph is nulled.

## Changes

- **What**: 
- add pre-detach event (node:before-removed) so reactive consumers can
drop references before node.graph is nulled
- move selection and Vue node-manager teardown to this event to
eliminate stale panel/render evaluations against detached nodes
- guard SubgraphNode promoted-widget paths resilient on detached access
and add regression coverage
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus
Alternative considered approach:
- Guards: Guards were treating the symptom at every caller, and new
callers may appear that won't know about this edge case. Adding a new
hook for consumers to drop refs is safer than trying to guard every call
site - the ones that are left in are safetynets and not the primary fix.
- Large scale refactor (towards ADR0008) - requires additional
scaffolding to already be in place to implement effectively, this fix
simply adds a new hook and isnt incompatible with the projects future
goals
- Defer/remove/reorder graph null - The detach was explicitly added in
#8180 to ensure GC - delaying is fragile and may not resolve the issue,
difficult to prove and may surface a new race condition
- Make rootGraph nullable - would require 100s of references to be
updated, when `NullGraphError` was added in #8180 to throw a clear
message when the graph for a removed subgraph node was referenced,
potentially leading to other harder to track bugs without the exception

Tests:

- e2e test complexity is required to prove the issue happens, patching
calls to add artificial delays. This isn't great, but I could not find a
reliable way to recreate otherwise, unless we are happy to drop e2e and
keep only unit tests.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11804-fix-prevent-NullGraphError-on-subgraph-node-removal-3536d73d3650814e9183e17067cc0992)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
2026-06-24 14:50:02 +00:00
Alexander Brown
6068571b35 Refactor: Brand node execution and locator IDs (#13071)
## Summary

- Brand `NodeExecutionId` and `NodeLocatorId` as distinct required
string types.
- Route execution/locator ID construction through existing helper
functions instead of minting raw strings at call sites.
- Update tests and boundary parsing to use branded IDs without
conflating them with local `NodeId` values.

## Validation

- `pnpm typecheck`
- `pnpm test:unit src/types/nodeIdentification.test.ts
src/stores/executionStore.test.ts
src/renderer/extensions/vueNodes/components/NodeSlots.test.ts
src/composables/graph/useErrorClearingHooks.test.ts
src/platform/nodeReplacement/missingNodeScan.test.ts -- --runInBand`
- `pnpm exec eslint src/types/nodeIdentification.ts
src/utils/graphTraversalUtil.ts
src/platform/workflow/management/stores/workflowStore.ts
src/renderer/extensions/minimap/data/LayoutStoreDataSource.ts
src/renderer/extensions/vueNodes/execution/useNodeExecutionState.ts
src/stores/workspace/favoritedWidgetsStore.ts
src/stores/nodeOutputStore.ts
src/utils/__tests__/executionErrorTestUtils.ts
src/platform/nodeReplacement/missingNodeScan.test.ts
src/stores/executionStore.test.ts --cache`

Note: full `pnpm lint` timed out after 5 minutes while still in
stylelint startup, so targeted lint was run on changed files.

## Open Question

- Should root-level node IDs like `1` be considered valid
`NodeExecutionId` values, or should `isNodeExecutionId()` require a
colon and callers use a separate type/helper for root execution IDs?
2026-06-24 14:35:57 +00:00
Alexander Brown
e37f168eaa Add merge_group event to CLA workflow (#13093)
## Summary

So it runs in the merge queue.
2026-06-24 08:34:29 -07:00
Rizumu Ayaka
b165b3f999 fix: focus keybindings search when opening Manage Shortcuts (FE-845) (#12709)
## Summary

Opening the Keybinding panel from the **Manage Shortcuts** button now
focuses the **Search Keybindings** field instead of the **Search
Settings** field.

## Changes

- **What**: The Settings dialog's "Search Settings" input had an
unconditional `autofocus`, so opening directly to the keybinding panel
always stole focus to the wrong field. Made it conditional
(`:autofocus="activeCategoryKey !== 'keybinding'"`) and added
`autofocus` to the keybinding panel's own search input.

## Review Focus

- `autofocus` maps to the native attribute, which only fires on DOM
insertion — flipping the reactive `:autofocus` while navigating between
categories inside the dialog will not re-steal focus, so there is no
regression for in-dialog navigation.
- Added an E2E test verified in both directions: it fails on the
original code (Search Settings focused) and passes with the fix (Search
Keybindings focused).

Fixes FE-845

Co-authored-by: Dante <bunggl@naver.com>
2026-06-24 11:05:01 +00:00
Terry Jia
d7f9754393 feat: add bounding boxes and colors widgets (CORE-292) (#12960)
## Summary
Add two reusable node widgets backed by native (non-string) values:
- Bounding boxes editor (BOUNDING_BOXES): draw, select, resize, and
label regions over an optional background image. Value is a native list
of `{ x, y, width, height, metadata }` pixel boxes; the editor works in
normalized space internally and converts at the value boundary,
rescaling when the node's width/height change.
- Colors palette (COLORS): native `string[]` of hex colors, sharing the
PaletteSwatchRow component (usePaletteSwatchRow composable).

Both reactively hide the width/height widgets while a background image
is connected by writing through the widget value store so the Vue node
re-renders.

Some design refer to KJ's node

BE: https://github.com/Comfy-Org/ComfyUI/pull/14537

Screenshot
<img width="3019" height="1470" alt="image"
src="https://github.com/user-attachments/assets/06795772-97e6-4084-9205-e370f955fb28"
/>

Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-06-24 11:00:18 +00:00
Terry Jia
48a3ea0e92 feat: add HDR/EXR image viewer for SaveImageAdvanced outputs (#13049)
## Summary
Browsers cannot render EXR/HDR in <img>, so these outputs showed as
broken images. Add a full-screen three.js viewer holding a single WebGL
context created on open and released on close, opened via an 'Open in
HDR Viewer' action on EXR/HDR outputs in ImagePreview. The layout
mirrors the 3D viewer: canvas on the left, grouped controls in a
right-hand sidebar.

The display pipeline (gamut -> exposure -> linear-to-sRGB -> dither ->
clamp, plus clip warnings) is adapted from
[HDRView](https://github.com/wkjarosz/hdrview). Source gamut is
auto-detected from the EXR chromaticities attribute (Rec.709/Rec.2020)
with a manual override.

Inspection tools operate on the EXR float data kept on the CPU by
EXRLoader:
- pixel inspector: hover to read raw RGBA values and coordinates
- statistics: min/max/mean/std-dev plus NaN and Inf counts
- auto-exposure: set exposure so the max value maps to 1
- channel isolation: view R/G/B/A or luminance individually

## Screenshots (if applicable)



https://github.com/user-attachments/assets/22b80718-4b15-41ee-86b5-8fe38a6a82e2
2026-06-24 06:43:30 -04:00
Rizumu Ayaka
a8f8ba7580 fix: clamp sidebar tab labels to two lines with tooltip fallback (#12755)
## Summary

Sidebar tab labels no longer overflow the rail: they wrap up to 2 lines
max, then truncate with an ellipsis, with the full name always
recoverable via the hover tooltip (per design spec from Alex Tov in
FE-698).

## Changes

- **What**:
- Labels in `SidebarIcon.vue` now use `line-clamp-2` + `overflow-wrap:
break-word` + `whitespace-normal`, contained within the rail width minus
`--sidebar-padding` so text keeps breathing room from the rail border
(the base Button's `whitespace-nowrap` previously prevented any
wrapping, causing labels like "Input & Output" to be clipped on both
sides)
- Near-fit built-in labels ("Workflows", "Templates", "Shortcuts" —
wider than the floating-mode line) get soft hyphens (`­`) in their en
label strings, so they break cleanly as "Work-/flows" in floating mode
and render as a single unhyphenated line in connected mode (56px).
`hyphens: auto` can't do this because Chromium skips hyphenation for
capitalized words. Title/tooltip strings are untouched
- Tooltip falls back to the label when an extension registers a sidebar
tab without a tooltip, so clamped text is always recoverable on hover

## Review Focus

- Labels never bleed past the rail or get clipped by the rail's
`overflow-hidden`; long unbroken extension names (e.g.
`WASNodeSuitePreprocessors`) break mid-token across 2 lines + ellipsis,
matching the design mockup
- Soft hyphens live only in `sideToolbar.labels.*`, not in the
title/tooltip keys, so command palette / tooltip text stays clean
- No E2E regression test: the fix is pure CSS layout (line
wrapping/clamping), and per `AGENTS.md` testing guidelines we don't
write tests that depend on non-behavioral styling. The one behavioral
change (tooltip falls back to label) is covered by a unit test in
`SidebarIcon.test.ts`

Fixes
[FE-698](https://linear.app/comfyorg/issue/FE-698/bug-input-and-outputs-text-not-wrapping-in-left-sidebar)

---------

Co-authored-by: Dante <bunggl@naver.com>
2026-06-24 10:19:23 +00:00
jaeone94
966659b303 fix: bind promoted asset modals (Legacy) to host widgets (#13075)
## Summary

Bind asset-browser modal selections to the widget that actually opened
the modal, so promoted subgraph asset widgets commit through the host
promoted widget instead of the internal source widget closure.

## Changes

- **What**: Makes the asset-browser modal commit path widget-owned:
after a valid selection, `openModal` writes to the widget passed into
the modal and notifies that widget's callback.
- **What**: Captures workflow state after a successful value-changing
asset selection, because the async modal `Use` action can run after the
global mouseup-based change capture has already fired.
- **What**: Preserves existing asset-browser filtering by keeping
`nodeTypeForBrowser` and `inputNameForBrowser` captured in the asset
widget's existing modal options closure.
- **What**: Avoids adding promoted-widget-specific rebinding code to
`litegraphService` and avoids changing LiteGraph core widget option
types.
- **What**: Only runs the source widget's `onValueChange` callback when
the selected widget is the original owner widget created by
`createAssetWidget`.
- **What**: For cloned/transient host widgets, such as promoted subgraph
asset widgets, dispatches `onWidgetChanged` through the widget's owning
node instead of the internal source node.
- **What**: Removes the duplicate PrimitiveNode callback dispatch
because the asset modal commit path now centrally notifies the selected
widget callback.
- **What**: Adds stable asset-browser `data-testid`s and a cloud E2E
regression for legacy promoted subgraph asset selection.
- **What**: Adds unit coverage for both regular asset widget commits and
cloned promoted-host asset modal commits, including workflow change
capture.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

This PR supersedes #13074. The earlier direction treated the bug as a
missing callback bridge in the async asset-browser commit path, but the
ownership issue is more specific: promoted subgraph asset widgets reuse
modal options that were created from the deepest concrete source widget.
Those options still need to carry source metadata for filtering the
asset browser, but the modal's `Use` action must commit to the widget
that actually opened the modal.

This matters after the History ADR 0009 subgraph widget changes shipped
through #12197. In the 1.46 subgraph model, promoted widget values live
on the subgraph host node and are not synchronized back into the
internal widget. The internal source widget remains useful as the
provider of asset-browser metadata, because `SubgraphNode` already
resolves nested promotions down to the final concrete widget, but it
should not own the edit commit.

The final patch keeps that boundary narrow:

- no `IWidgetOptions` or LiteGraph core type changes;
- no asset-specific promoted-widget rebinding in `litegraphService`;
- no new promoted-widget traversal logic, because the existing subgraph
promotion path already resolves the final concrete source widget;
- the modal commit path uses the widget passed to `openModal` as the
value owner;
- successful async modal commits explicitly capture workflow state when
the selected value changes.

Please focus review on whether `createAssetWidget` now preserves regular
asset widget behavior while correctly handling cloned/transient host
widgets. The key distinction is that the source `onValueChange` path
only runs for the original owner widget; promoted host wrappers instead
rely on their callback bridge and owning node's `onWidgetChanged` hook.

A review pass also found that this PR makes an existing async modal
weakness more visible: asset-browser selection happens from the modal
button's `click` handler, while the global change tracker also captures
on `mouseup`. Depending on event ordering, the automatic capture can
occur before the selection mutates the widget. This PR now captures
workflow state immediately after a successful value-changing asset
selection so undo/modified tracking follows the same user-visible edit.

Local verification:

- `pnpm exec vitest run
src/platform/assets/utils/createAssetWidget.test.ts --reporter=dot`
- `pnpm exec vitest run
src/platform/assets/utils/createAssetWidget.test.ts --coverage
--reporter=dot --coverage.reporter=text
--coverage.include=src/platform/assets/utils/createAssetWidget.ts`
- `pnpm exec eslint src/platform/assets/utils/createAssetWidget.ts
src/platform/assets/utils/createAssetWidget.test.ts`
- `pnpm typecheck`
- `pnpm format:check`
- `pnpm build:cloud`

---------

Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-06-24 10:16:43 +00:00
Alexis Rolland
a95dab2f59 Update allowlist in CLA workflow (#13091)
Update `allowlist` in CLA workflow to add
[actions-user](https://github.com/actions-user)
2026-06-24 10:14:16 +00:00
Alexis Rolland
5f90bacb73 ci: add CLA Assistant workflow (#13058)
Adds the CLA Assistant GitHub Actions workflow at
`.github/workflows/cla.yml`, copied from
https://github.com/Comfy-Org/comfy-cla/blob/main/.github/workflows/cla.yml

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-24 06:53:53 +00:00
ShihChi Huang
84319bea13 refactor: drop redundant isCloud guards around telemetry calls (#13082)
## Summary

Remove redundant `if (isCloud)` guards around `useTelemetry()?.x()`
calls. `useTelemetry()` already returns `null` in OSS builds, so the
optional-chain calls no-op there — the guards only duplicated that
central contract.

## Changes

- **What**: Drop the `isCloud` guard wrapping telemetry calls across 9
files and remove the 5 now-unused `isCloud` imports (pure dedent —
implementations unchanged). Add two-path (cloud + OSS) characterization
tests for the two previously-uncovered composables
(`useTemplateWorkflows`, `useSubscriptionActions`).

## e2e
In local/OSS mode, useTelemetry() returns null, so no telemetry-related
behavior occurs, and the workflow loads as expected. There are no
local/OSS flow regressions for the exact template workflow paths touched
by the branch.

| before | after |
| -- | -- |
| <img width="1280" height="800" alt="before-01-templates-open"
src="https://github.com/user-attachments/assets/1cccc686-4e3a-4cf0-a578-a653a1383e3c"
/> | <img width="1280" height="800" alt="after-01-templates-open"
src="https://github.com/user-attachments/assets/ff834a58-4375-432a-8cc1-6e04ceeece77"
/> |
| <img width="1280" height="800" alt="before-02-template-loaded"
src="https://github.com/user-attachments/assets/1abd301b-d66d-4819-a0f3-9dff1a1e23b5"
/> | <img width="1280" height="800" alt="after-02-template-loaded"
src="https://github.com/user-attachments/assets/9fbb6903-c085-4744-b683-39b01680c654"
/> |

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Behavior is intended to be unchanged: OSS still no-ops via null
telemetry. Router page-view tracking may run in more build contexts but
remains guarded by optional chaining.
> 
> **Overview**
> Removes duplicate **`if (isCloud)`** wrappers around
**`useTelemetry()?.…()`** across onboarding, auth, templates,
subscription UI, and routing. Call sites now rely on
**`useTelemetry()`** returning **`null`** in OSS (optional chaining
stays a no-op there), and several unused **`isCloud`** imports are
dropped.
> 
> **`trackPageView`** in the router no longer bails early on cloud-only
or **`window`** checks; it always invokes
**`useTelemetry()?.trackPageView(...)`** on navigation.
> 
> Adds characterization tests for **`useTemplateWorkflows`** and
**`useSubscriptionActions`** that assert telemetry fires when the mock
dispatcher is registered and does not when the mock simulates OSS
(**`useTelemetry()` → null**).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
fd6c9a56bd. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: ShihChi Huang <shh@theonlyperson.com>
2026-06-24 06:06:19 +00:00
Simon Pinfold
f076106ca5 fix: expand grouped assets when downloading multi-selection on OSS backend (#13079)
*PR Created by the Glary-Bot Agent*

---

## Summary

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

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

## Fix

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

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

## Tests

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

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

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

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

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


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


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

---------

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

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

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

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

## Changes

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

## Review Focus

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

## Screenshots (if applicable)

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

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

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

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

---------

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

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

## Changes

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

## Review Focus

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

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

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

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

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

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

---------

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

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

## Changes

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

## Review Focus

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

Fixes FE-1090

## Screenshots (if applicable)

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

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-23 21:32:36 +00:00
328 changed files with 24401 additions and 3860 deletions

63
.github/workflows/cla.yml vendored Normal file
View File

@@ -0,0 +1,63 @@
name: CLA Assistant
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, synchronize, closed]
merge_group:
permissions:
actions: write
contents: read # 'read' is enough because signatures live in a REMOTE repo
pull-requests: write
statuses: write
jobs:
cla-assistant:
runs-on: ubuntu-latest
steps:
- name: CLA Assistant
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
if: >
github.event_name == 'pull_request_target' ||
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# PAT required to write to the centralized signatures repo.
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
# Where the CLA document lives (shown to contributors)
path-to-document: https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md
# Centralized signature storage
remote-organization-name: comfy-org
remote-repository-name: comfy-cla
path-to-signatures: signatures/cla.json
branch: main
# 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,comfy-pr-bot,github-actions,*[bot]
# Custom PR comment messages
custom-notsigned-prcomment: |
🎉 Thank you for your contribution, we really appreciate it! 🎉
Like many open source projects, we require contributors to sign our [Contributor License Agreement (CLA)](https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md). A CLA makes the ownership of contributions explicit, so contributors and the project share a clear understanding of how the code can be used. By signing, you:
- Confirm that you own your contribution.
- Keep the right to reuse your own code.
- Grant us a copyright license to include and share it within our projects.
CLAs are standard practice across major open source projects including those under the Apache Software Foundation and the Linux Foundation. Ours is based on the Apache Software Foundation's CLA. Most importantly, it would enable us to relicense the project under a more permissive license in the future, giving the project and its community greater flexibility.
✍ **To sign, please post a new comment on this PR with exactly the following text:** ✍
custom-pr-sign-comment: I have read and agree to the Contributor License Agreement
custom-allsigned-prcomment: |
✅ All contributors have signed the CLA. Thank you! This PR is ready to be merged.

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

@@ -56,12 +56,16 @@ class ComfyPropertiesPanel {
readonly panelTitle: Locator
readonly searchBox: Locator
readonly titleEditor: TitleEditor
readonly toggleButton: Locator
constructor(readonly page: Page) {
this.root = page.getByTestId(TestIds.propertiesPanel.root)
this.panelTitle = this.root.locator('h3')
this.searchBox = this.root.getByPlaceholder(/^Search/)
this.titleEditor = new TitleEditor(this.root)
this.toggleButton = page.getByRole('button', {
name: 'Toggle properties panel'
})
}
}

View File

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

View File

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

@@ -112,6 +112,10 @@ export const TestIds = {
root: 'properties-panel',
errorsTab: 'panel-tab-errors'
},
assets: {
browserModal: 'asset-browser-modal',
card: 'asset-card'
},
subgraphEditor: {
hiddenSection: 'subgraph-editor-hidden-section',
iconEye: 'icon-eye',

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

@@ -223,4 +223,23 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
await expect(comfyPage.settingDialog.root).toBeVisible()
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
})
test('should focus keybindings search when opening manage shortcuts', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
await bottomPanel.keyboardShortcutsButton.click()
await bottomPanel.shortcuts.manageButton.click()
await expect(comfyPage.settingDialog.root).toBeVisible()
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
await expect(
comfyPage.page.getByPlaceholder('Search Keybindings...')
).toBeFocused()
await expect(
comfyPage.page.getByPlaceholder('Search Settings...')
).not.toBeFocused()
})
})

View File

@@ -0,0 +1,99 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import {
assetRequestIncludesTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import {
STABLE_CHECKPOINT,
STABLE_CHECKPOINT_2
} from '@e2e/fixtures/data/assetFixtures'
import { TestIds } from '@e2e/fixtures/selectors'
const WORKFLOW = 'missing/missing_model_promoted_widget'
const HOST_NODE_ID = 2
const WIDGET_NAME = 'ckpt_name'
const SELECTED_MODEL = STABLE_CHECKPOINT_2.name
const test = createCloudAssetsFixture([STABLE_CHECKPOINT, STABLE_CHECKPOINT_2])
interface WidgetSnapshot {
type: string
value: string
hasLayout: boolean
}
async function getHostWidgetSnapshot(page: Page): Promise<WidgetSnapshot> {
return await page.evaluate(
({ nodeId, widgetName }) => {
const node = window.app!.graph.getNodeById(nodeId)
const widget = node?.widgets?.find((widget) => widget.name === widgetName)
return {
type: widget?.type ?? '',
value: String(widget?.value ?? ''),
hasLayout: widget?.last_y != null
}
},
{ nodeId: HOST_NODE_ID, widgetName: WIDGET_NAME }
)
}
test.describe(
'Promoted subgraph asset widgets',
{ tag: ['@cloud', '@canvas', '@widget'] },
() => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
})
test('legacy asset browser selection updates the promoted host widget value', async ({
cloudAssetRequests,
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await expect
.poll(
() =>
cloudAssetRequests.some((url) =>
assetRequestIncludesTag(url, 'checkpoints')
),
{ timeout: 10_000 }
)
.toBe(true)
await expect
.poll(() => getHostWidgetSnapshot(comfyPage.page))
.toMatchObject({
type: 'asset',
hasLayout: true
})
const initialWidget = await getHostWidgetSnapshot(comfyPage.page)
expect(initialWidget.value).not.toBe(SELECTED_MODEL)
const hostNode = await comfyPage.nodeOps.getNodeRefById(HOST_NODE_ID)
await hostNode.centerOnNode()
const promotedWidget = await hostNode.getWidgetByName(WIDGET_NAME)
await promotedWidget.click()
const modal = comfyPage.page.getByTestId(TestIds.assets.browserModal)
await expect(modal).toBeVisible()
const assetCard = modal
.getByTestId(TestIds.assets.card)
.filter({ hasText: SELECTED_MODEL })
.first()
await expect(assetCard).toBeVisible()
await assetCard.getByRole('button', { name: 'Use' }).click()
await expect(modal).toBeHidden()
await expect
.poll(() =>
getHostWidgetSnapshot(comfyPage.page).then((widget) => widget.value)
)
.toBe(SELECTED_MODEL)
})
}
)

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

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
import type { ConsoleMessage } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
const domPreviewSelector = '.image-preview'
@@ -95,4 +98,225 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
})
test.describe('Detach Race Repro', { tag: ['@vue-nodes'] }, () => {
const SUBGRAPH_NODE_TITLE = 'New Subgraph'
// Queues legacy onNodeRemoved/onSelectionChange so unpack completes first,
// widening the race window so a guard regression deterministically surfaces.
async function deferLegacyHandlers(comfyPage: ComfyPage) {
return await comfyPage.page.evaluateHandle(() => {
const graph = window.app!.graph!
const canvas = window.app!.canvas!
const queue: Array<() => void> = []
const originalNodeRemoved = graph.onNodeRemoved
const originalSelectionChange = canvas.onSelectionChange
graph.onNodeRemoved = function (node) {
queue.push(() => originalNodeRemoved?.call(this, node))
}
canvas.onSelectionChange = function (selected) {
queue.push(() => originalSelectionChange?.call(this, selected))
}
return {
drain: () => {
for (const fn of queue.splice(0)) fn()
},
restore: () => {
graph.onNodeRemoved = originalNodeRemoved
canvas.onSelectionChange = originalSelectionChange
}
}
})
}
type DeferredHandlers = Awaited<ReturnType<typeof deferLegacyHandlers>>
// Defers only the legacy selection-change callback, so the detached host
// node lingers in the reactive selection while onNodeRemoved still runs
// normally and clears it from the canvas. This isolates the panel render
// path: a panel mounted during this window reads the stale selection.
async function deferSelectionChange(
comfyPage: ComfyPage
): Promise<DeferredHandlers> {
return await comfyPage.page.evaluateHandle(() => {
const canvas = window.app!.canvas!
const queue: Array<() => void> = []
const original = canvas.onSelectionChange
canvas.onSelectionChange = function (selected) {
queue.push(() => original?.call(this, selected))
}
return {
drain: () => {
for (const fn of queue.splice(0)) fn()
},
restore: () => {
canvas.onSelectionChange = original
}
}
})
}
function isNullGraphErrorText(text: string): boolean {
return text.includes('NullGraphError') || text.endsWith('has no graph')
}
// Vue's default errorHandler routes render throws to console.error,
// not pageerror - listen to both.
function captureNullGraphErrors(comfyPage: ComfyPage) {
const captured: string[] = []
const onPageError = (err: Error) => {
if (
err.name === 'NullGraphError' ||
isNullGraphErrorText(err.message ?? '')
) {
captured.push(`pageerror ${err.name}: ${err.message}`)
}
}
const onConsoleMessage = (msg: ConsoleMessage) => {
if (msg.type() !== 'error') return
const text = msg.text()
if (isNullGraphErrorText(text)) {
captured.push(`console.error: ${text}`)
}
}
comfyPage.page.on('pageerror', onPageError)
comfyPage.page.on('console', onConsoleMessage)
return {
getErrors: () => [...captured],
stop: () => {
comfyPage.page.off('pageerror', onPageError)
comfyPage.page.off('console', onConsoleMessage)
}
}
}
async function unpackViaContextMenu(comfyPage: ComfyPage, title: string) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
await comfyPage.contextMenu.openForVueNode(fixture.header)
await comfyPage.contextMenu.clickMenuItemExact('Unpack Subgraph')
}
async function reopenRightSidePanel(comfyPage: ComfyPage) {
const { propertiesPanel } = comfyPage.menu
await propertiesPanel.toggleButton.click()
await expect(propertiesPanel.root).toBeHidden()
await propertiesPanel.toggleButton.click()
await comfyPage.nextFrame()
}
// Unpacks the subgraph behind deferred teardown, runs an optional
// interaction while the node is detached but not yet cleaned up, then
// drains the deferred handlers and reports any NullGraphErrors seen.
async function unpackAndCaptureNullGraphErrors(
comfyPage: ComfyPage,
options: {
defer: (comfyPage: ComfyPage) => Promise<DeferredHandlers>
duringWindow?: (comfyPage: ComfyPage) => Promise<void>
}
): Promise<string[]> {
const subgraphNode =
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
const errors = captureNullGraphErrors(comfyPage)
const deferred = await options.defer(comfyPage)
try {
await unpackViaContextMenu(comfyPage, SUBGRAPH_NODE_TITLE)
await expect(subgraphNode).toHaveCount(0)
await options.duringWindow?.(comfyPage)
await deferred.evaluate((handlers) => handlers.drain())
// Let drained-handler reactive flushes settle before stop().
await comfyPage.nextFrame()
return errors.getErrors()
} finally {
await deferred.evaluate((handlers) => handlers.restore())
await deferred.dispose()
errors.stop()
}
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', true)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphNode =
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
await expect(subgraphNode).toBeVisible()
const fixture =
await comfyPage.vueNodes.getFixtureByTitle(SUBGRAPH_NODE_TITLE)
await fixture.header.click()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
).toBeVisible()
await comfyPage.nextFrame()
})
test('unpack does not surface NullGraphError on the LGraphNode render path', async ({
comfyPage
}) => {
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferLegacyHandlers
})
expect(
nullGraphErrors,
'LGraphNode render path: detach race must not surface NullGraphError'
).toEqual([])
})
test('unpack does not surface NullGraphError from the TabSubgraphInputs panel', async ({
comfyPage
}) => {
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferLegacyHandlers
})
expect(
nullGraphErrors,
'TabSubgraphInputs panel: detach race must not surface NullGraphError'
).toEqual([])
})
test('unpack with subgraph editor open does not surface NullGraphError from the SubgraphEditor panel', async ({
comfyPage
}) => {
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
await comfyPage.nextFrame()
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferLegacyHandlers
})
expect(
nullGraphErrors,
'SubgraphEditor panel: detach race must not surface NullGraphError'
).toEqual([])
})
test('reopening the right side panel after unpack does not surface NullGraphError', async ({
comfyPage
}) => {
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferSelectionChange,
duringWindow: reopenRightSidePanel
})
expect(
nullGraphErrors,
'TabSubgraphInputs remount: stale selection must not surface NullGraphError'
).toEqual([])
})
test('reopening the right side panel with the subgraph editor open does not surface NullGraphError', async ({
comfyPage
}) => {
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
await comfyPage.nextFrame()
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferSelectionChange,
duringWindow: reopenRightSidePanel
})
expect(
nullGraphErrors,
'SubgraphEditor remount: stale selection must not surface NullGraphError'
).toEqual([])
})
})
})

View File

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

View File

@@ -335,6 +335,30 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await comfyPage.canvasOps.moveMouseToEmptyArea()
})
test('pointerCancel stops autopan', async ({ comfyPage }) => {
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await ksampler.header.click({ trial: true })
await comfyPage.page.mouse.down()
const getOffset = () => comfyPage.canvasOps.getOffset()
const initialOffset = await getOffset()
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
await expect.poll(getOffset, 'drag with autopan').not.toEqual(initialOffset)
await test.step('move outside pan range and cancel drag', async () => {
await comfyPage.page.mouse.move(400, 400, { steps: 20 })
await ksampler.header.evaluate((node) =>
node.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true }))
)
})
const secondaryOffset = await getOffset()
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
await comfyPage.nextFrame()
expect(await getOffset(), 'drag canceled').toEqual(secondaryOffset)
})
test(
'@mobile should allow moving nodes by dragging on touch devices',
{ tag: '@screenshot' },

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

@@ -19,7 +19,10 @@
"size:collect": "node scripts/size-collect.js",
"size:report": "node scripts/size-report.js",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' vite --config vite.config.mts",
"dev:cloud": "pnpm dev:cloud:test",
"dev:cloud:test": "cross-env DEV_SERVER_COMFYUI_URL=https://testcloud.comfy.org/ vite --config vite.config.mts",
"dev:cloud:staging": "cross-env DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/ vite --config vite.config.mts",
"dev:cloud:prod": "cross-env DEV_SERVER_COMFYUI_URL=https://cloud.comfy.org/ vite --config vite.config.mts",
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",

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);

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

@@ -427,7 +427,6 @@ import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
@@ -453,16 +452,14 @@ onMounted(() => {
// Wrap onClose to track session end
const onClose = () => {
if (isCloud) {
const timeSpentSeconds = Math.floor(
(Date.now() - sessionStartTime.value) / 1000
)
const timeSpentSeconds = Math.floor(
(Date.now() - sessionStartTime.value) / 1000
)
useTelemetry()?.trackTemplateLibraryClosed({
template_selected: templateWasSelected.value,
time_spent_seconds: timeSpentSeconds
})
}
useTelemetry()?.trackTemplateLibraryClosed({
template_selected: templateWasSelected.value,
time_spent_seconds: timeSpentSeconds
})
originalOnClose()
}

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

@@ -8,6 +8,7 @@
v-model="filters['global'].value"
class="max-w-96"
size="lg"
autofocus
:placeholder="
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
"

View File

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

View File

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

View File

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

View File

@@ -147,6 +147,7 @@ import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useGroupContextMenu } from '@/composables/graph/useGroupContextMenu'
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
@@ -194,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<{
@@ -456,16 +454,14 @@ 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()
useGlobalLitegraph()
useContextMenuTranslation()
useGroupContextMenu()
useCopy()
usePaste()
useWorkflowAutoSave()
@@ -567,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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/vue'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
@@ -165,7 +166,9 @@ describe('WidgetRange', () => {
outputsHolder.nodeOutputs = {
loc1: { histogram_range_w: [1, 2, 3, 4] }
}
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
renderWidget(
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
)
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'true'
)
@@ -175,7 +178,9 @@ describe('WidgetRange', () => {
outputsHolder.nodeOutputs = {
loc1: { histogram_range_w: [] }
}
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
renderWidget(
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
)
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'false'
)

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
import { createNodeExecutionId } from '@/types/nodeIdentification'
const meta: Meta<typeof ErrorNodeCard> = {
title: 'RightSidePanel/Errors/ErrorNodeCard',
@@ -23,9 +24,8 @@ type Story = StoryObj<typeof meta>
const singleErrorCard: ErrorCardData = {
id: 'node-10',
title: 'CLIPTextEncode',
nodeId: '10',
nodeId: createNodeExecutionId([10]),
nodeTitle: 'CLIP Text Encode (Prompt)',
isSubgraphNode: false,
errors: [
{
message: 'Required input "text" is missing.',
@@ -37,9 +37,8 @@ const singleErrorCard: ErrorCardData = {
const multipleErrorsCard: ErrorCardData = {
id: 'node-24',
title: 'VAEDecode',
nodeId: '24',
nodeId: createNodeExecutionId([24]),
nodeTitle: 'VAE Decode',
isSubgraphNode: false,
errors: [
{
message: 'Required input "samples" is missing.',
@@ -55,9 +54,8 @@ const multipleErrorsCard: ErrorCardData = {
const runtimeErrorCard: ErrorCardData = {
id: 'exec-45',
title: 'KSampler',
nodeId: '45',
nodeId: createNodeExecutionId([45]),
nodeTitle: 'KSampler',
isSubgraphNode: false,
errors: [
{
message: 'OutOfMemoryError: CUDA out of memory. Tried to allocate 1.2GB.',
@@ -72,20 +70,6 @@ const runtimeErrorCard: ErrorCardData = {
]
}
const subgraphErrorCard: ErrorCardData = {
id: 'node-3:15',
title: 'KSampler',
nodeId: '3:15',
nodeTitle: 'Nested KSampler',
isSubgraphNode: true,
errors: [
{
message: 'Latent input is required.',
details: ''
}
]
}
const promptOnlyCard: ErrorCardData = {
id: '__prompt__',
title: 'Prompt has no outputs.',
@@ -103,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

@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
import { createNodeExecutionId } from '@/types/nodeIdentification'
const mockGetLogs = vi.fn(() => Promise.resolve('mock server logs'))
const mockSerialize = vi.fn(() => ({ nodes: [] }))
@@ -78,7 +79,6 @@ describe('ErrorNodeCard.vue', () => {
},
rightSidePanel: {
locateNode: 'Locate Node',
enterSubgraph: 'Enter Subgraph',
errorLog: 'Error log',
findOnGithubTooltip: 'Search GitHub issues for related problems',
getHelpTooltip:
@@ -156,7 +156,7 @@ describe('ErrorNodeCard.vue', () => {
return {
id: `exec-${++cardIdCounter}`,
title: 'KSampler',
nodeId: '10',
nodeId: createNodeExecutionId([10]),
nodeTitle: 'KSampler',
errors: [
{
@@ -249,7 +249,7 @@ describe('ErrorNodeCard.vue', () => {
renderCard({
id: `node-${++cardIdCounter}`,
title: 'KSampler',
nodeId: '10',
nodeId: createNodeExecutionId([10]),
nodeTitle: 'KSampler',
errors: [
{
@@ -387,7 +387,7 @@ describe('ErrorNodeCard.vue', () => {
const card: ErrorCardData = {
id: `exec-${++cardIdCounter}`,
title: 'KSampler',
nodeId: '10',
nodeId: createNodeExecutionId([10]),
nodeTitle: 'KSampler',
errors: [
{

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

@@ -1,4 +1,5 @@
import type { ResolvedErrorMessage } from '@/platform/errorCatalog/types'
import type { NodeExecutionId } from '@/types/nodeIdentification'
export interface ErrorItem extends ResolvedErrorMessage {
/** Raw source/API-compatible message. */
@@ -12,10 +13,9 @@ export interface ErrorItem extends ResolvedErrorMessage {
export interface ErrorCardData {
id: string
title: string
nodeId?: string
nodeId?: NodeExecutionId
nodeTitle?: string
graphNodeId?: string
isSubgraphNode?: boolean
errors: ErrorItem[]
}

View File

@@ -39,8 +39,8 @@ import {
resolveRunErrorMessage
} from '@/platform/errorCatalog/errorMessageResolver'
import {
isNodeExecutionId,
compareExecutionId
compareExecutionId,
tryNormalizeNodeExecutionId
} from '@/types/nodeIdentification'
const PROMPT_CARD_ID = '__prompt__'
@@ -82,7 +82,7 @@ interface ErrorSearchItem {
type CataloguedErrorItem = ErrorItem & ResolvedCatalogErrorMessage
/** Resolve display info for a node by its execution ID. */
function resolveNodeInfo(nodeId: string) {
function resolveNodeInfo(nodeId: NodeExecutionId) {
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
return {
@@ -119,7 +119,7 @@ function getOrCreateGroup(
}
function createErrorCard(
nodeId: string,
nodeId: NodeExecutionId,
classType: string,
idPrefix: string
): ErrorCardData {
@@ -130,7 +130,6 @@ function createErrorCard(
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: isNodeExecutionId(nodeId),
errors: []
}
}
@@ -288,7 +287,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return map
})
function isErrorInSelection(executionNodeId: string): boolean {
function isErrorInSelection(executionNodeId: NodeExecutionId): boolean {
const nodeIds = selectedNodeInfo.value.nodeIds
if (!nodeIds) return true
@@ -305,7 +304,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
function addNodeErrorToGroup(
groupsMap: Map<string, GroupEntry>,
nodeId: string,
nodeId: NodeExecutionId,
classType: string,
idPrefix: string,
error: CataloguedErrorItem,
@@ -371,9 +370,11 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
) {
if (!executionErrorStore.lastNodeErrors) return
for (const [nodeId, nodeError] of Object.entries(
for (const [rawNodeId, nodeError] of Object.entries(
executionErrorStore.lastNodeErrors
)) {
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
if (!nodeId) continue
const nodeDisplayName =
resolveNodeInfo(nodeId).title || nodeError.class_type
for (const e of nodeError.errors) {
@@ -404,9 +405,12 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
if (!executionErrorStore.lastExecutionError) return
const e = executionErrorStore.lastExecutionError
const nodeId = tryNormalizeNodeExecutionId(e.node_id)
if (!nodeId) return
addNodeErrorToGroup(
groupsMap,
String(e.node_id),
nodeId,
e.node_type,
'exec',
{
@@ -417,8 +421,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
...resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName:
resolveNodeInfo(String(e.node_id)).title || e.node_type
nodeDisplayName: resolveNodeInfo(nodeId).title || e.node_type
})
},
filterBySelection
@@ -669,7 +672,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
]
}
function isAssetErrorInSelection(executionNodeId: string): boolean {
function isAssetErrorInSelection(executionNodeId: NodeExecutionId): boolean {
const nodeIds = selectedNodeInfo.value.nodeIds
if (!nodeIds) return true
@@ -691,12 +694,17 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return false
}
function isAssetCandidateInSelection(nodeId: string | number): boolean {
const executionNodeId = tryNormalizeNodeExecutionId(nodeId)
return executionNodeId ? isAssetErrorInSelection(executionNodeId) : false
}
const filteredMissingModelGroups = computed(() => {
if (!selectedNodeInfo.value.nodeIds) return missingModelGroups.value
const candidates = missingModelStore.missingModelCandidates
if (!candidates?.length) return []
const filtered = candidates.filter(
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
)
if (!filtered.length) return []
return groupMissingModelCandidates(filtered, isCloud)
@@ -707,7 +715,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
const candidates = missingMediaStore.missingMediaCandidates
if (!candidates?.length) return []
const filtered = candidates.filter(
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
)
if (!filtered.length) return []
return groupCandidatesByMediaType(filtered)

View File

@@ -4,6 +4,7 @@ import { nextTick, ref } from 'vue'
import type { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { ErrorCardData } from './types'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { useErrorReport } from './useErrorReport'
async function flushPromises() {
@@ -103,7 +104,7 @@ function makeCard(overrides: Partial<ErrorCardData> = {}): ErrorCardData {
return {
id: 'card-1',
title: 'KSampler',
nodeId: '42',
nodeId: createNodeExecutionId([42]),
errors: [],
...overrides
}
@@ -181,7 +182,7 @@ describe('useErrorReport', () => {
exceptionType: 'RuntimeError',
exceptionMessage: 'CUDA oom',
traceback: 'trace-0',
nodeId: '42',
nodeId: createNodeExecutionId([42]),
nodeType: 'KSampler',
systemStats: sampleSystemStats,
serverLogs: 'server logs',

View File

@@ -3,17 +3,12 @@ import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import SidebarIcon from './SidebarIcon.vue'
type SidebarIconProps = {
icon: string
selected: boolean
tooltip?: string
class?: string
iconBadge?: string | (() => string | null)
}
type SidebarIconProps = ComponentProps<typeof SidebarIcon>
const i18n = createI18n({
legacy: false,
@@ -84,4 +79,20 @@ describe('SidebarIcon', () => {
tooltipText
)
})
it('falls back to label for tooltip when no tooltip is provided', async () => {
const labelText = 'WASNodeSuitePreprocessors'
const { user } = renderSidebarIcon({ label: labelText })
expect(screen.getByRole('button')).toHaveAttribute('aria-label', labelText)
await user.hover(screen.getByRole('button'))
await waitFor(
() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(labelText)
},
{ timeout: 1000 }
)
})
})

View File

@@ -40,9 +40,11 @@
</span>
</div>
</slot>
<!-- w-max sizes the label to the rail instead of the padding-inset
button content box, which is too narrow for one-line labels -->
<span
v-if="label && !isSmall"
class="side-bar-button-label text-center text-2xs"
class="side-bar-button-label line-clamp-2 w-max max-w-[calc(var(--sidebar-width)-var(--sidebar-padding))] text-center text-2xs wrap-break-word whitespace-normal"
>{{ st(label, label) }}</span
>
</div>
@@ -83,7 +85,14 @@ const overlayValue = computed(() =>
typeof iconBadge === 'function' ? (iconBadge() ?? '') : iconBadge
)
const shouldShowBadge = computed(() => !!overlayValue.value)
const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
/**
* Falls back to the label when no tooltip is provided, so labels clamped
* to two lines can always be recovered in full on hover.
*/
const computedTooltip = computed(() => {
const text = tooltip || label
return st(text, text) + tooltipSuffix
})
</script>
<style>

View File

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

View File

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

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

@@ -0,0 +1,237 @@
import { describe, expect, it } from 'vitest'
import type { BoundingBox } from '@/types/boundingBoxes'
import type { HitMode, Region } from './boundingBoxesUtil'
import {
applyDrag,
boxesAt,
fromBoundingBoxes,
tagRects,
toBoundingBoxes
} from './boundingBoxesUtil'
const region = (over: Partial<Region> = {}): Region => ({
x: 0.2,
y: 0.2,
w: 0.2,
h: 0.2,
type: 'obj',
text: '',
desc: '',
palette: [],
...over
})
describe('applyDrag', () => {
it('moves without resizing and keeps width/height', () => {
const out = applyDrag('move', region({ x: 0.2, y: 0.2 }), 0.1, 0.1)
expect(out.x).toBeCloseTo(0.3)
expect(out.y).toBeCloseTo(0.3)
expect(out.w).toBeCloseTo(0.2)
expect(out.h).toBeCloseTo(0.2)
})
it('clamps a move so the box stays inside the unit square', () => {
const out = applyDrag(
'move',
region({ x: 0.9, y: 0.9, w: 0.2, h: 0.2 }),
0.5,
0.5
)
expect(out.x).toBeCloseTo(0.8)
expect(out.y).toBeCloseTo(0.8)
})
it('grows from the bottom-right for draw and resize-br', () => {
for (const mode of ['draw', 'resize-br'] as HitMode[]) {
const out = applyDrag(
mode,
region({ x: 0.2, y: 0.2, w: 0.1, h: 0.1 }),
0.1,
0.2
)
expect(out).toMatchObject({ x: 0.2, y: 0.2 })
expect(out.w).toBeCloseTo(0.2)
expect(out.h).toBeCloseTo(0.3)
}
})
it('moves the top-left corner on resize-tl', () => {
const out = applyDrag(
'resize-tl',
region({ x: 0.5, y: 0.5, w: 0.2, h: 0.2 }),
0.1,
0.1
)
expect(out.x).toBeCloseTo(0.6)
expect(out.y).toBeCloseTo(0.6)
expect(out.w).toBeCloseTo(0.1)
expect(out.h).toBeCloseTo(0.1)
})
it('normalizes a corner drag that inverts the box', () => {
const out = applyDrag(
'resize-tl',
region({ x: 0.5, y: 0.5, w: 0.2, h: 0.2 }),
0.3,
0
)
expect(out.x).toBeCloseTo(0.7)
expect(out.w).toBeCloseTo(0.1)
expect(out.y).toBeCloseTo(0.5)
expect(out.h).toBeCloseTo(0.2)
})
it('resizes single edges', () => {
expect(applyDrag('resize-r', region({ w: 0.2 }), 0.1, 0).w).toBeCloseTo(0.3)
expect(applyDrag('resize-b', region({ h: 0.2 }), 0, 0.1).h).toBeCloseTo(0.3)
const top = applyDrag('resize-t', region({ y: 0.4, h: 0.2 }), 0, 0.1)
expect(top.y).toBeCloseTo(0.5)
expect(top.h).toBeCloseTo(0.1)
const left = applyDrag('resize-l', region({ x: 0.4, w: 0.2 }), 0.1, 0)
expect(left.x).toBeCloseTo(0.5)
expect(left.w).toBeCloseTo(0.1)
})
})
describe('boxesAt', () => {
const regions: Region[] = [region({ x: 0.2, y: 0.2, w: 0.2, h: 0.2 })]
it('detects a corner handle', () => {
const hits = boxesAt(regions, 0.2, 0.2, 6, 100, 100, -1)
expect(hits[0]).toEqual({ index: 0, mode: 'resize-tl' })
})
it('detects an interior move', () => {
const hits = boxesAt(regions, 0.3, 0.3, 6, 100, 100, -1)
expect(hits[0]).toEqual({ index: 0, mode: 'move' })
})
it('returns nothing when the pointer misses every box', () => {
expect(boxesAt(regions, 0.9, 0.9, 6, 100, 100, -1)).toEqual([])
})
it('brings the active box to the front of overlapping candidates', () => {
const overlapping: Region[] = [
region({ x: 0.2, y: 0.2, w: 0.2, h: 0.2 }),
region({ x: 0.25, y: 0.25, w: 0.2, h: 0.2 })
]
const hits = boxesAt(overlapping, 0.3, 0.3, 6, 100, 100, 1)
expect(hits).toHaveLength(2)
expect(hits[0].index).toBe(1)
})
})
describe('tagRects', () => {
const measure = (s: string) => s.length * 7
it('places the first tag at the top-left corner', () => {
const rects = tagRects(
[region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 })],
100,
100,
measure
)
expect(rects[0]).toMatchObject({ x: 10, y: 10, tag: '01' })
expect(rects[0].w).toBe(measure('01') + 8)
})
it('moves a colliding tag to a different corner', () => {
const boxes = [
region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 }),
region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 })
]
const rects = tagRects(boxes, 100, 100, measure)
const sameSpot = rects[1].x === rects[0].x && rects[1].y === rects[0].y
expect(sameSpot).toBe(false)
})
})
describe('fromBoundingBoxes', () => {
it('converts pixel boxes to normalized regions with metadata', () => {
const boxes: BoundingBox[] = [
{
x: 100,
y: 200,
width: 300,
height: 400,
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#fff'] }
}
]
expect(fromBoundingBoxes(boxes, 1000, 1000)[0]).toEqual({
x: 0.1,
y: 0.2,
w: 0.3,
h: 0.4,
type: 'text',
text: 'hi',
desc: 'd',
palette: ['#fff']
})
})
it('fills defaults when metadata is missing or partial', () => {
const boxes = [{ x: 0, y: 0, width: 10, height: 10 }] as BoundingBox[]
expect(fromBoundingBoxes(boxes, 100, 100)[0]).toMatchObject({
type: 'obj',
text: '',
desc: '',
palette: []
})
})
it('drops entries that are not bounding boxes', () => {
const boxes = [null, { x: 1 }, undefined] as unknown as BoundingBox[]
expect(fromBoundingBoxes(boxes, 100, 100)).toEqual([])
})
it('guards against zero dimensions', () => {
const boxes: BoundingBox[] = [
{
x: 5,
y: 5,
width: 5,
height: 5,
metadata: { type: 'obj', text: '', desc: '', palette: [] }
}
]
expect(fromBoundingBoxes(boxes, 0, 0)[0]).toMatchObject({
x: 5,
y: 5,
w: 5,
h: 5
})
})
})
describe('toBoundingBoxes', () => {
it('rounds normalized regions back to pixels and copies the palette', () => {
const palette = ['#abc']
const regions: Region[] = [
region({ x: 0.1, y: 0.2, w: 0.3, h: 0.4, palette })
]
const [box] = toBoundingBoxes(regions, 1000, 1000)
expect(box).toMatchObject({ x: 100, y: 200, width: 300, height: 400 })
expect(box.metadata.palette).toEqual(['#abc'])
expect(box.metadata.palette).not.toBe(palette)
})
it('round-trips from pixels to regions and back', () => {
const boxes: BoundingBox[] = [
{
x: 100,
y: 200,
width: 300,
height: 400,
metadata: { type: 'obj', text: '', desc: '', palette: [] }
}
]
const restored = toBoundingBoxes(
fromBoundingBoxes(boxes, 1000, 1000),
1000,
1000
)
expect(restored).toEqual(boxes)
})
})

View File

@@ -0,0 +1,246 @@
import type { BoundingBox, BoundingBoxMetadata } from '@/types/boundingBoxes'
export type HitMode =
| 'move'
| 'draw'
| 'resize-tl'
| 'resize-tr'
| 'resize-bl'
| 'resize-br'
| 'resize-t'
| 'resize-b'
| 'resize-l'
| 'resize-r'
export interface Region extends BoundingBoxMetadata {
x: number
y: number
w: number
h: number
}
interface BoxCandidate {
index: number
mode: HitMode
}
interface TagRect {
x: number
y: number
w: number
h: number
tag: string
}
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
function normalizeBox(b: Region): Region {
let { x, y, w, h } = b
if (w < 0) {
x += w
w = -w
}
if (h < 0) {
y += h
h = -h
}
x = clamp01(x)
y = clamp01(y)
w = Math.min(w, 1 - x)
h = Math.min(h, 1 - y)
return { ...b, x, y, w: Math.max(0, w), h: Math.max(0, h) }
}
function rectHitTest(
mx: number,
my: number,
x1: number,
y1: number,
x2: number,
y2: number,
rx: number,
ry: number
): HitMode | null {
const h = (cx: number, cy: number) =>
Math.abs(mx - cx) < rx && Math.abs(my - cy) < ry
if (h(x1, y1)) return 'resize-tl'
if (h(x2, y1)) return 'resize-tr'
if (h(x1, y2)) return 'resize-bl'
if (h(x2, y2)) return 'resize-br'
if (mx >= x1 && mx <= x2 && Math.abs(my - y1) < ry) return 'resize-t'
if (mx >= x1 && mx <= x2 && Math.abs(my - y2) < ry) return 'resize-b'
if (my >= y1 && my <= y2 && Math.abs(mx - x1) < rx) return 'resize-l'
if (my >= y1 && my <= y2 && Math.abs(mx - x2) < rx) return 'resize-r'
if (mx >= x1 && mx <= x2 && my >= y1 && my <= y2) return 'move'
return null
}
export function applyDrag(
mode: HitMode,
start: Region,
dx: number,
dy: number
): Region {
let { x, y, w, h } = start
switch (mode) {
case 'move':
x += dx
y += dy
x = clamp01(Math.min(x, 1 - w))
y = clamp01(Math.min(y, 1 - h))
break
case 'draw':
case 'resize-br':
w += dx
h += dy
break
case 'resize-tl':
x += dx
y += dy
w -= dx
h -= dy
break
case 'resize-tr':
y += dy
w += dx
h -= dy
break
case 'resize-bl':
x += dx
w -= dx
h += dy
break
case 'resize-t':
y += dy
h -= dy
break
case 'resize-b':
h += dy
break
case 'resize-l':
x += dx
w -= dx
break
case 'resize-r':
w += dx
break
}
return mode === 'move'
? { ...start, x, y }
: normalizeBox({ ...start, x, y, w, h })
}
export function boxesAt(
regions: readonly Region[],
mxN: number,
myN: number,
handlePx: number,
logW: number,
logH: number,
activeIdx: number
): BoxCandidate[] {
const rx = handlePx / Math.max(1, logW)
const ry = handlePx / Math.max(1, logH)
const res: BoxCandidate[] = []
for (let i = 0; i < regions.length; i++) {
const b = regions[i]
const mode = rectHitTest(mxN, myN, b.x, b.y, b.x + b.w, b.y + b.h, rx, ry)
if (mode) res.push({ index: i, mode })
}
const ai = res.findIndex((c) => c.index === activeIdx)
if (ai > 0) res.unshift(res.splice(ai, 1)[0])
return res
}
export function tagRects(
regions: readonly Region[],
logW: number,
logH: number,
measureWidth: (s: string) => number,
height = 14
): TagRect[] {
const placed: TagRect[] = []
const rects: TagRect[] = []
const hits = (a: TagRect, b: TagRect) =>
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
for (let i = 0; i < regions.length; i++) {
const b = regions[i]
const x1 = b.x * logW
const y1 = b.y * logH
const x2 = (b.x + b.w) * logW
const y2 = (b.y + b.h) * logH
const tag = String(i + 1).padStart(2, '0')
const w = measureWidth(tag) + 8
let pick: [number, number] = [x1, y1]
for (const [cx, cy] of [
[x1, y1],
[x2 - w, y1],
[x2 - w, y2 - height],
[x1, y2 - height]
] as const) {
const candidate: TagRect = { x: cx, y: cy, w, h: height, tag }
if (!placed.some((p) => hits(candidate, p))) {
pick = [cx, cy]
break
}
}
const r: TagRect = { x: pick[0], y: pick[1], w, h: height, tag }
placed.push(r)
rects[i] = r
}
return rects
}
function isBoundingBox(b: unknown): b is BoundingBox {
if (!b || typeof b !== 'object') return false
const box = b as Record<string, unknown>
return (
typeof box.x === 'number' &&
typeof box.y === 'number' &&
typeof box.width === 'number' &&
typeof box.height === 'number'
)
}
export function fromBoundingBoxes(
boxes: readonly BoundingBox[],
width: number,
height: number
): Region[] {
const w = width || 1
const h = height || 1
return boxes.filter(isBoundingBox).map((box) => {
const meta = (box.metadata ?? {}) as Partial<BoundingBoxMetadata>
return {
x: box.x / w,
y: box.y / h,
w: box.width / w,
h: box.height / h,
type: meta.type === 'text' ? 'text' : 'obj',
text: typeof meta.text === 'string' ? meta.text : '',
desc: typeof meta.desc === 'string' ? meta.desc : '',
palette: Array.isArray(meta.palette)
? meta.palette.filter((c): c is string => typeof c === 'string')
: []
}
})
}
export function toBoundingBoxes(
regions: readonly Region[],
width: number,
height: number
): BoundingBox[] {
return regions.map((r) => ({
x: Math.round(r.x * width),
y: Math.round(r.y * height),
width: Math.round(r.w * width),
height: Math.round(r.h * height),
metadata: {
type: r.type,
text: r.text,
desc: r.desc,
palette: r.palette.slice()
}
}))
}

View File

@@ -0,0 +1,249 @@
import { render } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref, ShallowRef } from 'vue'
import { defineComponent, h, nextTick, ref, shallowRef } from 'vue'
import { useBoundingBoxes } from './useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
const { appState } = vi.hoisted(() => ({
appState: { node: null as unknown }
}))
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: { getNodeById: () => appState.node } } }
}))
const ctx = {
measureText: (s: string) => ({ width: s.length * 7 }),
setTransform: () => {},
clearRect: () => {},
fillRect: () => {},
strokeRect: () => {},
fillText: () => {},
drawImage: () => {},
save: () => {},
restore: () => {},
beginPath: () => {},
rect: () => {},
clip: () => {},
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 0
} as unknown as CanvasRenderingContext2D
function makeCanvas(): HTMLCanvasElement {
const el = document.createElement('canvas')
Object.defineProperty(el, 'clientWidth', { value: 100, configurable: true })
Object.defineProperty(el, 'clientHeight', { value: 100, configurable: true })
el.getContext = (() => ctx) as unknown as HTMLCanvasElement['getContext']
el.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON: () => ({})
}) as DOMRect
el.focus = () => {}
el.setPointerCapture = () => {}
el.releasePointerCapture = () => {}
return el
}
function makeNode() {
return {
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 }
],
findInputSlot: () => -1,
getInputNode: () => null
}
}
const pe = (
clientX: number,
clientY: number,
over: Partial<PointerEvent> = {}
) =>
({
button: 0,
clientX,
clientY,
altKey: false,
pointerId: 1,
preventDefault: () => {},
stopPropagation: () => {},
...over
}) as unknown as PointerEvent
const flush = async () => {
await Promise.resolve()
await nextTick()
}
type Api = ReturnType<typeof useBoundingBoxes>
interface Captured extends Api {
canvasEl: ShallowRef<HTMLCanvasElement | null>
modelValue: Ref<BoundingBox[]>
}
function setup(initial: BoundingBox[] = []) {
let captured: Captured | undefined
const Harness = defineComponent({
setup() {
const canvasEl = shallowRef<HTMLCanvasElement | null>(null)
const canvasContainer = shallowRef<HTMLDivElement | null>(null)
const inlineEditorEl = shallowRef<HTMLTextAreaElement | null>(null)
const modelValue = ref(initial)
const api = useBoundingBoxes('1', {
canvasEl,
canvasContainer,
inlineEditorEl,
modelValue
})
captured = { canvasEl, modelValue, ...api }
return () => h('div')
}
})
render(Harness)
captured!.canvasEl.value = makeCanvas()
return captured!
}
const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
x: 51,
y: 51,
width: 256,
height: 256,
metadata: { type: 'obj', text: '', desc: '', palette: ['#ff0000'] },
...over
})
beforeEach(() => {
setActivePinia(createPinia())
appState.node = makeNode()
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
void Promise.resolve().then(() => cb(0))
return 1
})
vi.stubGlobal('cancelAnimationFrame', () => {})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('useBoundingBoxes initialization', () => {
it('derives regions from the initial model value', () => {
const c = setup([box()])
expect(c.hasRegions.value).toBe(true)
expect(c.activeRegion.value).toMatchObject({ type: 'obj' })
})
it('exposes an aspect-ratio canvas style from the node width/height', () => {
const c = setup()
expect(c.canvasStyle.value).toEqual({ aspectRatio: '512 / 512' })
})
it('starts with no active region when empty', () => {
const c = setup()
expect(c.hasRegions.value).toBe(false)
expect(c.activeRegion.value).toBeNull()
})
})
describe('useBoundingBoxes drawing', () => {
it('draws a new region and syncs it to the model value', async () => {
const c = setup()
c.onPointerDown(pe(10, 10))
c.onCanvasPointerMove(pe(60, 60))
c.onDocPointerUp(pe(60, 60))
await flush()
expect(c.modelValue.value).toHaveLength(1)
expect(c.modelValue.value[0].width).toBeGreaterThan(0)
})
it('discards a zero-size draw', async () => {
const c = setup()
c.onPointerDown(pe(10, 10))
c.onDocPointerUp(pe(10, 10))
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('selects an existing region instead of drawing when clicking inside it', async () => {
const c = setup([box()])
c.onPointerDown(pe(30, 30))
c.onDocPointerUp(pe(30, 30))
await flush()
expect(c.modelValue.value).toHaveLength(1)
})
})
describe('useBoundingBoxes region editing', () => {
it('changes the active region type', async () => {
const c = setup([box()])
c.setActiveType('text')
await flush()
expect(c.modelValue.value[0].metadata.type).toBe('text')
})
it('deletes the active region on Delete', async () => {
const c = setup([box()])
c.onCanvasKeyDown({
key: 'Delete',
preventDefault: () => {},
stopPropagation: () => {}
} as unknown as KeyboardEvent)
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('clears all regions', async () => {
const c = setup([box(), box({ x: 0 })])
c.clearAll()
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
})
describe('useBoundingBoxes inline editor', () => {
it('opens on double click and commits the description', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
await flush()
expect(c.inlineEditor.value).not.toBeNull()
c.inlineEditor.value!.value = 'a label'
c.commitInlineEditor()
await flush()
expect(c.modelValue.value[0].metadata.desc).toBe('a label')
expect(c.inlineEditor.value).toBeNull()
})
it('closes the inline editor on Escape', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
await flush()
c.onInlineKeyDown({ key: 'Escape' } as KeyboardEvent)
expect(c.inlineEditor.value).toBeNull()
})
})
describe('useBoundingBoxes hover cursor', () => {
it('switches to a pointer cursor over a tag', async () => {
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
expect(c.canvasCursor.value).toBe('crosshair')
c.onCanvasPointerMove(pe(15, 15))
await flush()
expect(c.canvasCursor.value).toBe('pointer')
})
})

View File

@@ -0,0 +1,614 @@
import { useElementSize } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type { Ref, ShallowRef } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import {
applyDrag,
boxesAt,
fromBoundingBoxes,
tagRects,
toBoundingBoxes
} from '@/composables/boundingBoxes/boundingBoxesUtil'
import type {
HitMode,
Region
} from '@/composables/boundingBoxes/boundingBoxesUtil'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { BoundingBox } from '@/types/boundingBoxes'
import { readableTextColor, textOnColor } from '@/utils/colorUtil'
const HANDLE_PX = 8
const DIMENSION_STEP = 16
const BG_DIM = 0.75
const MAX_ELEMENT_COLORS = 5
interface InlineEditorState {
value: string
style: Record<string, string>
index: number
}
interface UseBoundingBoxesOptions {
canvasEl: Readonly<ShallowRef<HTMLCanvasElement | null>>
canvasContainer: Readonly<ShallowRef<HTMLDivElement | null>>
inlineEditorEl: Readonly<ShallowRef<HTMLTextAreaElement | null>>
modelValue: Ref<BoundingBox[]>
}
export function useBoundingBoxes(
nodeId: string,
{
canvasEl,
canvasContainer,
inlineEditorEl,
modelValue
}: UseBoundingBoxesOptions
) {
const focused = ref(false)
const drawing = ref(false)
const dragMode = ref<HitMode | null>(null)
const dragStartNorm = ref<{ x: number; y: number } | null>(null)
const boxAtStart = ref<Region | null>(null)
const hoverIndex = ref<number | null>(null)
const hoverTagIndex = ref<number | null>(null)
const bgImage = ref<HTMLImageElement | null>(null)
const inlineEditor = ref<InlineEditorState | null>(null)
const { width: containerWidth } = useElementSize(canvasContainer)
const litegraphNode = computed(() =>
nodeId && app.canvas?.graph ? app.canvas.graph.getNodeById(nodeId) : null
)
const { selectedNodeIds } = storeToRefs(useCanvasStore())
const isNodeSelected = computed(() =>
selectedNodeIds.value.has(String(nodeId))
)
function dimWidget(name: 'width' | 'height'): number | undefined {
const v = litegraphNode.value?.widgets?.find((w) => w.name === name)?.value
return typeof v === 'number' && v > 0 ? v : undefined
}
const widthValue = computed(() => dimWidget('width') ?? 1024)
const heightValue = computed(() => dimWidget('height') ?? 1024)
const state = ref({
regions: fromBoundingBoxes(
modelValue.value ?? [],
widthValue.value,
heightValue.value
)
})
const activeIndex = ref(state.value.regions.length ? 0 : -1)
const aspectRatio = computed(
() => `${widthValue.value} / ${heightValue.value}`
)
const canvasStyle = computed(() => ({ aspectRatio: aspectRatio.value }))
const activeRegion = computed(() =>
activeIndex.value >= 0 ? state.value.regions[activeIndex.value] : null
)
const hasRegions = computed(() => state.value.regions.length > 0)
function clampToCanvas(n: number) {
return Math.max(0, Math.min(1, n))
}
function logicalSize() {
const el = canvasEl.value
return { w: el?.clientWidth || 1, h: el?.clientHeight || 1 }
}
function pointerNorm(e: PointerEvent) {
const el = canvasEl.value
if (!el) return { x: 0, y: 0 }
const r = el.getBoundingClientRect()
return {
x: clampToCanvas((e.clientX - r.left) / r.width),
y: clampToCanvas((e.clientY - r.top) / r.height)
}
}
let rafHandle = 0
function requestDraw() {
if (rafHandle) return
rafHandle = requestAnimationFrame(() => {
rafHandle = 0
drawCanvas()
})
}
function measureWidth(ctx: CanvasRenderingContext2D, s: string) {
return ctx.measureText(s).width
}
function drawCanvas() {
const el = canvasEl.value
if (!el) return
const { w: W, h: H } = logicalSize()
const dpr = window.devicePixelRatio || 1
const bw = Math.max(1, Math.round(W * dpr))
const bh = Math.max(1, Math.round(H * dpr))
if (el.width !== bw || el.height !== bh) {
el.width = bw
el.height = bh
}
const ctx = el.getContext('2d')
if (!ctx) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, W, H)
if (bgImage.value) {
ctx.drawImage(bgImage.value, 0, 0, W, H)
ctx.fillStyle = `rgba(0,0,0,${BG_DIM})`
ctx.fillRect(0, 0, W, H)
}
const showActive = focused.value || isNodeSelected.value
const aIdx = showActive ? activeIndex.value : -1
const order = state.value.regions
.map((_, i) => i)
.filter((i) => i !== aIdx)
.reverse()
if (aIdx >= 0 && aIdx < state.value.regions.length) order.push(aIdx)
ctx.font = 'bold 11px monospace'
const tag_rects = tagRects(state.value.regions, W, H, (s) =>
measureWidth(ctx, s)
)
for (const i of order) {
const b = state.value.regions[i]
const active = i === aIdx
const pal = (b.palette || []).filter(Boolean)
const col = pal.length ? pal[0] : '#8c8c8c'
const x1 = b.x * W
const y1 = b.y * H
const x2 = (b.x + b.w) * W
const y2 = (b.y + b.h) * H
const w = x2 - x1
const h = y2 - y1
const hovered = i === hoverIndex.value || active
if (active) {
ctx.fillStyle = 'rgba(26,26,26,0.88)'
ctx.fillRect(x1, y1, w, h)
}
ctx.fillStyle = col + (hovered ? '3a' : '22')
ctx.fillRect(x1, y1, w, h)
const lw = active ? 2 : hovered ? 1.5 : 1
ctx.strokeStyle = col
ctx.lineWidth = lw
ctx.strokeRect(x1 + lw / 2, y1 + lw / 2, w - lw, h - lw)
if (pal.length) {
const sw = w / pal.length
const sh = 7
for (let p = 0; p < pal.length; p++) {
const sx = x1 + Math.round(p * sw)
ctx.fillStyle = pal[p]
ctx.fillRect(sx, y1, x1 + Math.round((p + 1) * sw) - sx, sh)
}
}
ctx.save()
ctx.beginPath()
ctx.rect(x1, y1, w, h)
ctx.clip()
let body = b.desc || ''
if (b.type === 'text' && b.text)
body = `"${b.text}"` + (body ? `${body}` : '')
if (body) {
ctx.font = '12px monospace'
ctx.fillStyle = readableTextColor(col)
const pad = 4
const lh = 14
let ty = y1 + 15 + 12
for (const line of wrapLines(ctx, body, w - pad * 2)) {
if (ty > y1 + h) break
ctx.fillText(line, x1 + pad, ty)
ty += lh
}
}
const tr = tag_rects[i]
ctx.font = 'bold 11px monospace'
ctx.fillStyle = col
ctx.fillRect(tr.x, tr.y, tr.w, 14)
if (i === hoverTagIndex.value) {
ctx.fillStyle = 'rgba(255,255,255,0.25)'
ctx.fillRect(tr.x, tr.y, tr.w, 14)
ctx.strokeStyle = '#fff'
ctx.lineWidth = 1
ctx.strokeRect(tr.x + 0.5, tr.y + 0.5, tr.w - 1, 13)
}
ctx.fillStyle = textOnColor(col)
ctx.fillText(tr.tag, tr.x + 4, tr.y + 11)
ctx.restore()
}
}
function wrapLines(
ctx: CanvasRenderingContext2D,
text: string,
maxW: number
): string[] {
const out: string[] = []
for (const para of text.split('\n')) {
let line = ''
for (const word of para.split(/\s+/)) {
if (!word) continue
const test = line ? `${line} ${word}` : word
if (line && ctx.measureText(test).width > maxW) {
out.push(line)
line = word
} else {
line = test
}
}
out.push(line)
}
return out
}
const hitTestPoint = (mN: { x: number; y: number }) => {
const { w: W, h: H } = logicalSize()
const cands = boxesAt(
state.value.regions,
mN.x,
mN.y,
HANDLE_PX,
W,
H,
activeIndex.value
)
if (!cands.length) return null
return (
cands.find((c) => c.index === activeIndex.value && c.mode !== 'move') ||
cands[0]
)
}
const titleAt = (mN: { x: number; y: number }) => {
const el = canvasEl.value
if (!el) return null
const ctx = el.getContext('2d')
if (!ctx) return null
const { w: W, h: H } = logicalSize()
const rects = tagRects(state.value.regions, W, H, (s) =>
measureWidth(ctx, s)
)
const px = mN.x * W
const py = mN.y * H
for (let i = state.value.regions.length - 1; i >= 0; i--) {
const r = rects[i]
if (r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h)
return i
}
return null
}
function pickForSelection(mN: { x: number; y: number }, cycle: boolean) {
const { w: W, h: H } = logicalSize()
const cands = boxesAt(
state.value.regions,
mN.x,
mN.y,
HANDLE_PX,
W,
H,
activeIndex.value
)
if (!cands.length) return null
const activeResize = cands.find(
(c) => c.index === activeIndex.value && c.mode !== 'move'
)
if (activeResize && !cycle) return activeResize
const ti = titleAt(mN)
if (ti !== null && !cycle) return { index: ti, mode: 'move' as HitMode }
if (cycle && cands.length > 1) {
const pos = cands.findIndex((c) => c.index === activeIndex.value)
return cands[(pos + 1) % cands.length]
}
return (
cands.find((c) => c.index === activeIndex.value && c.mode !== 'move') ||
cands[0]
)
}
function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return
canvasEl.value?.focus()
hoverTagIndex.value = null
hoverIndex.value = null
const mN = pointerNorm(e)
const hit = pickForSelection(mN, e.altKey)
if (hit) {
activeIndex.value = hit.index
dragMode.value = hit.mode
boxAtStart.value = { ...state.value.regions[hit.index] }
} else {
dragMode.value = 'draw'
const nb: Region = {
x: mN.x,
y: mN.y,
w: 0,
h: 0,
type: 'obj',
text: '',
desc: '',
palette: []
}
state.value.regions.push(nb)
activeIndex.value = state.value.regions.length - 1
boxAtStart.value = { ...nb }
}
drawing.value = true
dragStartNorm.value = mN
canvasEl.value?.setPointerCapture(e.pointerId)
e.preventDefault()
requestDraw()
}
function onDocPointerMove(e: PointerEvent) {
if (
!drawing.value ||
!boxAtStart.value ||
!dragStartNorm.value ||
!dragMode.value
)
return
const mN = pointerNorm(e)
const dx = mN.x - dragStartNorm.value.x
const dy = mN.y - dragStartNorm.value.y
const nb = applyDrag(dragMode.value, boxAtStart.value, dx, dy)
state.value.regions[activeIndex.value] = nb
requestDraw()
}
function onDocPointerUp(e: PointerEvent) {
if (!drawing.value) return
drawing.value = false
canvasEl.value?.releasePointerCapture?.(e.pointerId)
const b = state.value.regions[activeIndex.value]
if (b && (b.w < 0.005 || b.h < 0.005) && dragMode.value === 'draw') {
removeRegion(activeIndex.value)
}
syncState()
}
function onCanvasPointerMove(e: PointerEvent) {
if (drawing.value) onDocPointerMove(e)
else onPointerMove(e)
}
function onPointerMove(e: PointerEvent) {
if (drawing.value) return
const mN = pointerNorm(e)
const ti = titleAt(mN)
const hit = hitTestPoint(mN)
const hb = ti !== null ? ti : hit ? hit.index : null
if (ti !== hoverTagIndex.value || hb !== hoverIndex.value) {
hoverTagIndex.value = ti
hoverIndex.value = hb
requestDraw()
}
}
function onPointerLeave() {
if (hoverTagIndex.value !== null || hoverIndex.value !== null) {
hoverTagIndex.value = null
hoverIndex.value = null
requestDraw()
}
}
const canvasCursor = computed(() =>
hoverTagIndex.value !== null ? 'pointer' : 'crosshair'
)
function onDoubleClick(e: MouseEvent) {
e.preventDefault()
const mN = pointerNormFromMouse(e)
const { w: W, h: H } = logicalSize()
const cands = boxesAt(
state.value.regions,
mN.x,
mN.y,
HANDLE_PX,
W,
H,
activeIndex.value
)
const target = cands.find((c) => c.index === activeIndex.value) || cands[0]
if (!target) return
openInlineEditor(target.index)
}
function pointerNormFromMouse(e: MouseEvent) {
const el = canvasEl.value
if (!el) return { x: 0, y: 0 }
const r = el.getBoundingClientRect()
return {
x: clampToCanvas((e.clientX - r.left) / r.width),
y: clampToCanvas((e.clientY - r.top) / r.height)
}
}
function openInlineEditor(index: number) {
const b = state.value.regions[index]
if (!b) return
activeIndex.value = index
const { w: W, h: H } = logicalSize()
const w = Math.min(W, Math.max(70, b.w * W))
const h = Math.min(H, Math.max(42, b.h * H))
const left = Math.max(0, Math.min(b.x * W, W - w))
const top = Math.max(0, Math.min(b.y * H, H - h))
inlineEditor.value = {
value: b.desc || '',
index,
style: {
left: `${left}px`,
top: `${top}px`,
width: `${w}px`,
height: `${h}px`,
borderColor: (b.palette || []).find(Boolean) || '#46b4e6'
}
}
void nextTick(() => {
inlineEditorEl.value?.focus()
inlineEditorEl.value?.select()
})
}
function onInlineKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
inlineEditor.value = null
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
commitInlineEditor()
}
}
function commitInlineEditor() {
const ed = inlineEditor.value
if (!ed) return
const b = state.value.regions[ed.index]
if (b) b.desc = ed.value
inlineEditor.value = null
syncState()
}
function onCanvasKeyDown(e: KeyboardEvent) {
if (drawing.value) return
const idx = activeIndex.value
if ((e.key === 'Delete' || e.key === 'Backspace') && idx >= 0) {
e.preventDefault()
e.stopPropagation()
removeRegion(idx)
syncState()
}
}
function removeRegion(i: number) {
state.value.regions.splice(i, 1)
if (!state.value.regions.length) activeIndex.value = -1
else if (i <= activeIndex.value)
activeIndex.value = Math.max(0, activeIndex.value - 1)
}
function setActiveType(t: 'obj' | 'text') {
if (activeRegion.value) {
activeRegion.value.type = t
syncState()
}
}
function clearAll() {
state.value.regions = []
activeIndex.value = -1
syncState()
}
function syncState() {
modelValue.value = toBoundingBoxes(
state.value.regions,
widthValue.value,
heightValue.value
)
requestDraw()
}
watch(containerWidth, () => requestDraw())
watch(
() => state.value.regions.length,
() => requestDraw()
)
watch(isNodeSelected, () => requestDraw())
watch([widthValue, heightValue], () => syncState())
const nodeOutputStore = useNodeOutputStore()
function applyImageDimensions(naturalWidth: number, naturalHeight: number) {
const node = litegraphNode.value
if (!node) return
const snap = (v: number) =>
Math.max(DIMENSION_STEP, Math.round(v / DIMENSION_STEP) * DIMENSION_STEP)
const targetW = snap(naturalWidth)
const targetH = snap(naturalHeight)
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (widthWidget && widthWidget.value !== targetW) {
widthWidget.value = targetW
widthWidget.callback?.(targetW)
}
if (heightWidget && heightWidget.value !== targetH) {
heightWidget.value = targetH
heightWidget.callback?.(targetH)
}
}
let lastBgUrl = ''
function updateBgImage() {
const node = litegraphNode.value
if (!node) return
const slot = node.findInputSlot('background')
const inputNode = slot >= 0 ? node.getInputNode(slot) : null
const url = inputNode
? nodeOutputStore.getNodeImageUrls(inputNode)?.[0]
: undefined
if (!url) {
if (bgImage.value) {
bgImage.value = null
lastBgUrl = ''
requestDraw()
}
return
}
if (url === lastBgUrl) return
lastBgUrl = url
const currentUrl = url
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
if (currentUrl !== lastBgUrl) return
bgImage.value = img
applyImageDimensions(img.naturalWidth, img.naturalHeight)
requestDraw()
}
img.src = url
}
watch(() => nodeOutputStore.nodeOutputs, updateBgImage, { deep: true })
watch(() => nodeOutputStore.nodePreviewImages, updateBgImage, { deep: true })
updateBgImage()
void nextTick(() => requestDraw())
onBeforeUnmount(() => {
if (rafHandle) cancelAnimationFrame(rafHandle)
})
return {
canvasStyle,
canvasCursor,
focused,
activeRegion,
hasRegions,
inlineEditor,
maxColors: MAX_ELEMENT_COLORS,
onPointerDown,
onCanvasPointerMove,
onDocPointerUp,
onPointerLeave,
onDoubleClick,
onCanvasKeyDown,
onInlineKeyDown,
commitInlineEditor,
setActiveType,
clearAll,
syncState
}
}

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

@@ -21,6 +21,7 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
@@ -50,7 +51,11 @@ describe('Connection error clearing via onConnectionsChange', () => {
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'clip'
)
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
@@ -62,7 +67,11 @@ describe('Connection error clearing via onConnectionsChange', () => {
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'clip'
)
node.onConnectionsChange!(
NodeSlotType.INPUT,
@@ -81,7 +90,11 @@ describe('Connection error clearing via onConnectionsChange', () => {
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'clip'
)
node.onConnectionsChange!(
NodeSlotType.OUTPUT,
@@ -103,7 +116,11 @@ describe('Connection error clearing via onConnectionsChange', () => {
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(node.id), 'model')
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'model'
)
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
@@ -229,7 +246,11 @@ describe('Widget change error clearing via onWidgetChanged', () => {
const store = useExecutionErrorStore()
const mediaStore = useMissingMediaStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(node.id), 'image')
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'image'
)
mediaStore.setMissingMedia([
{
nodeId: String(node.id),
@@ -279,7 +300,11 @@ describe('installErrorClearingHooks lifecycle', () => {
// Verify the hooks actually work
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(lateNode.id), 'value')
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([lateNode.id]),
'value'
)
lateNode.onConnectionsChange!(
NodeSlotType.INPUT,

View File

@@ -34,6 +34,7 @@ import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacem
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { appendNodeExecutionId } from '@/types/nodeIdentification'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import {
collectAllNodes,
@@ -83,7 +84,7 @@ function installNodeHooks(node: LGraphNode): void {
const promotedSource = widgetPromotedSource(node, widget)
const executionId = promotedSource
? `${hostExecId}:${promotedSource.nodeId}`
? appendNodeExecutionId(hostExecId, promotedSource.nodeId)
: hostExecId
const widgetName = promotedSource?.widgetName ?? widget.name

View File

@@ -703,3 +703,55 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
expect(subgraphNode.has_errors).toBe(true)
})
})
describe('Pre-remove vueNodeData drain', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('drops vueNodeData entry before node.onRemoved fires', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
expect(vueNodeData.has(String(node.id))).toBe(true)
let dataPresentInOnRemoved: boolean | undefined
node.onRemoved = () => {
dataPresentInOnRemoved = vueNodeData.has(String(node.id))
}
graph.remove(node)
expect(
dataPresentInOnRemoved,
'vueNodeData entry must be cleared before node.onRemoved fires so reactive consumers cannot observe the detached node'
).toBe(false)
})
it('clears vueNodeData when LGraph.clear() dispatches node:before-removed for each node', () => {
const graph = new LGraph()
const nodeA = new LGraphNode('a')
const nodeB = new LGraphNode('b')
graph.add(nodeA)
graph.add(nodeB)
const { vueNodeData } = useGraphNodeManager(graph)
expect(vueNodeData.size).toBe(2)
const beforeRemovedSpy = vi.fn()
graph.events.addEventListener('node:before-removed', beforeRemovedSpy)
graph.clear()
expect(
beforeRemovedSpy,
'clear() must dispatch node:before-removed so reactive consumers can drop refs before nodes detach'
).toHaveBeenCalledTimes(2)
expect(
vueNodeData.size,
'node:before-removed listener must drain vueNodeData when clear() removes every node'
).toBe(0)
})
})

View File

@@ -30,6 +30,8 @@ import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
import type { NodeId as WorkflowNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type { WidgetId } from '@/types/widgetId'
import type {
@@ -94,7 +96,7 @@ export interface SafeWidgetData {
* host subgraph node. Used for missing-model lookups that key by
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
*/
sourceExecutionId?: string
sourceExecutionId?: NodeExecutionId
/**
* Interior source widget name. Only set for promoted widgets, where `name`
* is the host input slot name; missing-model lookups key by the interior
@@ -137,7 +139,7 @@ export interface GraphNodeManager {
vueNodeData: ReadonlyMap<string, VueNodeData>
// Access to original LiteGraph nodes (non-reactive)
getNode(id: string): LGraphNode | undefined
getNode(id: WorkflowNodeId): LGraphNode | undefined
// Lifecycle methods
cleanup(): void
@@ -225,7 +227,7 @@ function isDOMBackedWidget(widget: IBaseWidget): boolean {
interface PromotedWidgetMetadata {
controlWidget?: SafeControlWidget
isDOMWidget: boolean
sourceExecutionId?: string
sourceExecutionId?: NodeExecutionId
sourceWidgetName?: string
}
@@ -516,8 +518,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
// Get access to original LiteGraph node (non-reactive)
const getNode = (id: string): LGraphNode | undefined => {
return nodeRefs.get(id)
const getNode = (id: WorkflowNodeId): LGraphNode | undefined => {
return nodeRefs.get(String(id))
}
const syncWithGraph = () => {
@@ -608,27 +610,20 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
}
/**
* Handles node removal from the graph - cleans up all references
*/
const dropNodeReferences = (node: LGraphNode) => {
const id = String(node.id)
nodeRefs.delete(id)
vueNodeData.delete(id)
}
const handleNodeRemoved = (
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = String(node.id)
// Remove node from layout store
setSource(LayoutSource.Canvas)
void deleteNode(id)
// Clean up all tracking references
nodeRefs.delete(id)
vueNodeData.delete(id)
// Call original callback if provided
if (originalCallback) {
originalCallback(node)
}
originalCallback?.(node)
}
/**
@@ -637,7 +632,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const createCleanupFunction = (
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined,
beforeNodeRemovedListener: (e: CustomEvent<{ node: LGraphNode }>) => void
) => {
return () => {
// Restore original callbacks
@@ -645,15 +641,17 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined
graph.events.removeEventListener(
'node:before-removed',
beforeNodeRemovedListener
)
// Clear all state maps
nodeRefs.clear()
vueNodeData.clear()
}
}
/**
* Sets up event listeners - now simplified with extracted handlers
*/
const setupEventListeners = (): (() => void) => {
// Store original callbacks
const originalOnNodeAdded = graph.onNodeAdded
@@ -669,6 +667,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
handleNodeRemoved(node, originalOnNodeRemoved)
}
const beforeNodeRemovedListener = (
e: CustomEvent<{ node: LGraphNode }>
) => {
dropNodeReferences(e.detail.node)
}
graph.events.addEventListener(
'node:before-removed',
beforeNodeRemovedListener
)
const triggerHandlers: {
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
} = {
@@ -817,11 +825,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Initialize state
syncWithGraph()
// Return cleanup function
return createCleanupFunction(
originalOnNodeAdded || undefined,
originalOnNodeRemoved || undefined,
originalOnTrigger || undefined
originalOnTrigger || undefined,
beforeNodeRemovedListener
)
}

View File

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

View File

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

View File

@@ -137,6 +137,18 @@ describe(usePromotedPreviews, () => {
expect(promotedPreviews.value).toEqual([])
})
it('returns empty array (does not throw) when SubgraphNode is detached', () => {
const setup = createSetup()
const parentGraph = setup.subgraphNode.graph!
parentGraph.add(setup.subgraphNode)
parentGraph.remove(setup.subgraphNode)
expect(setup.subgraphNode.graph).toBeNull()
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(() => promotedPreviews.value).not.toThrow()
expect(promotedPreviews.value).toEqual([])
})
it('returns empty array when no $$ promotions exist', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })

View File

@@ -6,7 +6,11 @@ import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { UUID } from '@/utils/uuid'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import {
appendNodeExecutionId,
createNodeLocatorId
} from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
interface PromotedPreview {
sourceNodeId: string
@@ -38,7 +42,7 @@ export function usePromotedPreviews(
function readReactivePreviewUrls(
leafHost: SubgraphNode,
leafSourceNodeId: string,
leafExecutionId: string,
leafExecutionId: NodeExecutionId,
interiorNode: LGraphNode
): string[] | undefined {
const locatorId = createNodeLocatorId(
@@ -68,6 +72,7 @@ export function usePromotedPreviews(
const promotedPreviews = computed((): PromotedPreview[] => {
const node = toValue(lgraphNode)
if (!(node instanceof SubgraphNode)) return []
if (node.isDetached) return []
const rootGraphId = node.rootGraph.id
const hostLocator = String(node.id)
@@ -121,7 +126,7 @@ export function usePromotedPreviews(
const urls = readReactivePreviewUrls(
leafHost,
leaf.sourceNodeId,
`${leafHostLocator}:${leaf.sourceNodeId}`,
appendNodeExecutionId(leafHostLocator, leaf.sourceNodeId),
interiorNode
)
if (!urls?.length) return []

View File

@@ -0,0 +1,114 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { EffectScope } from 'vue'
import { effectScope, ref, shallowRef } from 'vue'
import { usePaletteSwatchRow } from './usePaletteSwatchRow'
const scopes: EffectScope[] = []
afterEach(() => {
while (scopes.length) scopes.pop()?.stop()
})
function setup(initial: string[]) {
const modelValue = ref(initial)
const container = shallowRef(document.createElement('div'))
const picker = shallowRef(document.createElement('input'))
const scope = effectScope()
scopes.push(scope)
const api = scope.run(() =>
usePaletteSwatchRow({ modelValue, container, picker })
)!
return { modelValue, container, picker, ...api }
}
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
describe('usePaletteSwatchRow', () => {
it('appends a default color', () => {
const { modelValue, addColor } = setup(['#000000'])
addColor()
expect(modelValue.value).toEqual(['#000000', '#ffffff'])
})
it('removes a color by index', () => {
const { modelValue, remove } = setup(['#a', '#b', '#c'])
remove(1)
expect(modelValue.value).toEqual(['#a', '#c'])
})
it('seeds the picker input with the clicked color before opening it', () => {
const { picker, openPicker } = setup(['#112233'])
const click = vi.spyOn(picker.value!, 'click')
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#112233')
expect(click).toHaveBeenCalled()
})
it('falls back to white when the slot is empty', () => {
const { picker, openPicker } = setup([''])
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#ffffff')
})
it('writes the picked color back to the open slot', () => {
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
openPicker(1, mouseEvent())
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
expect(modelValue.value).toEqual(['#a', '#123456'])
})
it('ignores picker input when no slot is open', () => {
const { modelValue, onPickerInput } = setup(['#a'])
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
expect(modelValue.value).toEqual(['#a'])
})
it('reorders via drag when the pointer crosses another swatch', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
for (const i of [0, 1]) {
const swatch = document.createElement('div')
swatch.setAttribute('data-index', String(i))
container.value!.appendChild(swatch)
}
const second = container.value!.children[1] as HTMLDivElement
second.getBoundingClientRect = () =>
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
)
expect(modelValue.value).toEqual(['#b', '#a'])
})
it('cancels a stale drag when the primary button is no longer pressed', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
for (const i of [0, 1]) {
const swatch = document.createElement('div')
swatch.setAttribute('data-index', String(i))
container.value!.appendChild(swatch)
}
const second = container.value!.children[1] as HTMLDivElement
second.getBoundingClientRect = () =>
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 0 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
it('ignores non-left-button pointer downs', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
const swatch = document.createElement('div')
swatch.setAttribute('data-index', '1')
container.value!.appendChild(swatch)
onPointerDown(0, { button: 2, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
})

View File

@@ -0,0 +1,114 @@
import { useEventListener } from '@vueuse/core'
import type { Ref, ShallowRef } from 'vue'
import { ref } from 'vue'
interface UsePaletteSwatchRowOptions {
modelValue: Ref<string[]>
container: Readonly<ShallowRef<HTMLDivElement | null>>
picker: Readonly<ShallowRef<HTMLInputElement | null>>
}
export function usePaletteSwatchRow({
modelValue,
container,
picker
}: UsePaletteSwatchRowOptions) {
const pickerIndex = ref<number | null>(null)
function openPicker(i: number, e: MouseEvent) {
e.stopPropagation()
pickerIndex.value = i
const el = picker.value
if (!el) return
el.value = modelValue.value[i] || '#ffffff'
el.click()
}
function onPickerInput(e: Event) {
const v = (e.target as HTMLInputElement).value
if (pickerIndex.value === null) return
const next = modelValue.value.slice()
next[pickerIndex.value] = v
modelValue.value = next
}
function remove(i: number) {
const next = modelValue.value.slice()
next.splice(i, 1)
modelValue.value = next
}
function addColor() {
modelValue.value = [...modelValue.value, '#ffffff']
}
const drag = ref<{
index: number
startX: number
startY: number
active: boolean
} | null>(null)
function onPointerDown(i: number, e: PointerEvent) {
if (e.button !== 0) return
drag.value = {
index: i,
startX: e.clientX,
startY: e.clientY,
active: false
}
}
useEventListener(document, 'pointermove', (e: PointerEvent) => {
const d = drag.value
if (!d) return
if ((e.buttons & 1) === 0) {
drag.value = null
return
}
if (!d.active) {
if (Math.abs(e.clientX - d.startX) + Math.abs(e.clientY - d.startY) < 4)
return
d.active = true
}
const rows =
container.value?.querySelectorAll<HTMLDivElement>('[data-index]')
if (!rows) return
for (const other of rows) {
if (parseInt(other.dataset.index || '-1', 10) === d.index) continue
const r = other.getBoundingClientRect()
if (
e.clientX >= r.left &&
e.clientX <= r.right &&
e.clientY >= r.top - 6 &&
e.clientY <= r.bottom + 6
) {
const oi = parseInt(other.dataset.index || '-1', 10)
if (oi < 0) continue
const next = modelValue.value.slice()
const [moved] = next.splice(d.index, 1)
const insertAt = e.clientX > r.left + r.width / 2 ? oi + 1 : oi
next.splice(insertAt > d.index ? insertAt - 1 : insertAt, 0, moved)
modelValue.value = next
drag.value = null
return
}
}
})
useEventListener(document, 'pointerup', () => {
drag.value = null
})
useEventListener(document, 'pointercancel', () => {
drag.value = null
})
return {
openPicker,
onPickerInput,
remove,
addColor,
onPointerDown
}
}

View File

@@ -0,0 +1,443 @@
import * as THREE from 'three'
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader'
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
import type { ChromaticityCoords, GamutName } from '@/renderer/hdr/colorGamut'
import {
detectGamutFromChromaticities,
gamutToSrgbMatrix
} from '@/renderer/hdr/colorGamut'
import {
HDR_VIEWER_FRAGMENT_SHADER,
HDR_VIEWER_VERTEX_SHADER
} from '@/renderer/hdr/hdrViewerShader'
import type { ChannelHistograms, ImageStats } from '@/renderer/hdr/hdrStats'
import {
computeChannelHistograms,
computeImageStats
} from '@/renderer/hdr/hdrStats'
import { WebGLViewport } from '@/renderer/three/WebGLViewport'
import { getImageFilenameFromUrl } from '@/utils/hdrFormatUtil'
const MIN_ZOOM = 0.05
const MAX_ZOOM = 64
export type ChannelMode = 'rgb' | 'r' | 'g' | 'b' | 'a' | 'luminance'
export const CHANNEL_MODES: ChannelMode[] = [
'rgb',
'r',
'g',
'b',
'a',
'luminance'
]
const CHANNEL_INDEX: Record<ChannelMode, number> = {
rgb: 0,
r: 1,
g: 2,
b: 3,
a: 4,
luminance: 5
}
export interface PixelReadout {
x: number
y: number
r: number
g: number
b: number
a: number | null
}
interface ExrTexData {
header?: { chromaticities?: ChromaticityCoords }
}
function createLoader(url: string) {
const filename = getImageFilenameFromUrl(url)
if (filename?.toLowerCase().endsWith('.hdr')) return new RGBELoader()
const loader = new EXRLoader()
loader.setDataType(THREE.FloatType)
return loader
}
function makeReader(
data: ArrayLike<number>,
type: THREE.TextureDataType
): (index: number) => number {
if (type === THREE.HalfFloatType) {
return (index) => THREE.DataUtils.fromHalfFloat(data[index])
}
return (index) => data[index]
}
function loadHdrTexture(
url: string
): Promise<{ texture: THREE.DataTexture; gamut: GamutName }> {
return new Promise((resolve, reject) => {
createLoader(url).load(
url,
(texture, texData) => {
const chromaticities = (texData as ExrTexData)?.header?.chromaticities
resolve({
texture,
gamut: detectGamutFromChromaticities(chromaticities)
})
},
undefined,
reject
)
})
}
export function useHdrViewer() {
const exposureStops = ref(0)
const dither = ref(true)
const clipWarnings = ref(false)
const gamut = ref<GamutName>('sRGB')
const channel = ref<ChannelMode>('rgb')
const loading = ref(true)
const error = ref<string | null>(null)
const dimensions = ref<string | null>(null)
const stats = ref<ImageStats | null>(null)
const histograms = shallowRef<ChannelHistograms | null>(null)
const pixel = ref<PixelReadout | null>(null)
const histogram = computed<Uint32Array | null>(() => {
const channelHistograms = histograms.value
if (!channelHistograms) return null
switch (channel.value) {
case 'r':
return channelHistograms.r
case 'g':
return channelHistograms.g
case 'b':
return channelHistograms.b
case 'a':
return channelHistograms.a
default:
return channelHistograms.luminance
}
})
const containerRef = shallowRef<HTMLElement | null>(null)
let renderer: THREE.WebGLRenderer | null = null
let viewport: WebGLViewport | null = null
let scene: THREE.Scene | null = null
let camera: THREE.OrthographicCamera | null = null
let material: THREE.ShaderMaterial | null = null
let mesh: THREE.Mesh | null = null
let texture: THREE.Texture | null = null
let imageAspect = 1
let frameRequested = false
let readSample: ((index: number) => number) | null = null
let imageWidth = 0
let imageHeight = 0
let imageChannels = 4
const raycaster = new THREE.Raycaster()
const pointerNdc = new THREE.Vector2()
function requestRender() {
if (!renderer || frameRequested) return
frameRequested = true
requestAnimationFrame(() => {
frameRequested = false
if (renderer && scene && camera) renderer.render(scene, camera)
})
}
function containerSize() {
const el = containerRef.value
return {
width: el?.clientWidth || 1,
height: el?.clientHeight || 1
}
}
function updateProjection() {
if (!camera) return
const { width, height } = containerSize()
const halfH = 0.5
const halfW = (0.5 * width) / height
camera.left = -halfW
camera.right = halfW
camera.top = halfH
camera.bottom = -halfH
camera.updateProjectionMatrix()
}
function fitView() {
if (!camera) return
const { width, height } = containerSize()
const containerAspect = width / height
camera.zoom = Math.min(1, containerAspect / imageAspect)
camera.position.set(0, 0, 1)
camera.updateProjectionMatrix()
requestRender()
}
function applyUniforms() {
if (!material) return
material.uniforms.uGain.value = Math.pow(2, exposureStops.value)
material.uniforms.uDither.value = dither.value
material.uniforms.uClipWarnings.value = clipWarnings.value
material.uniforms.uChannel.value = CHANNEL_INDEX[channel.value]
const m = gamutToSrgbMatrix(gamut.value)
;(material.uniforms.uGamutToSRGB.value as THREE.Matrix3).set(
m[0],
m[1],
m[2],
m[3],
m[4],
m[5],
m[6],
m[7],
m[8]
)
requestRender()
}
function buildScene() {
renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false })
viewport = new WebGLViewport(renderer)
renderer.outputColorSpace = THREE.LinearSRGBColorSpace
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setClearColor(0x0a0a0a, 1)
scene = new THREE.Scene()
camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10)
camera.position.set(0, 0, 1)
material = new THREE.ShaderMaterial({
glslVersion: THREE.GLSL3,
vertexShader: HDR_VIEWER_VERTEX_SHADER,
fragmentShader: HDR_VIEWER_FRAGMENT_SHADER,
uniforms: {
uImage: { value: null },
uGamutToSRGB: { value: new THREE.Matrix3() },
uGain: { value: 1 },
uChannel: { value: 0 },
uDither: { value: true },
uClipWarnings: { value: false },
uClipRange: { value: new THREE.Vector2(0, 1) }
}
})
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material)
scene.add(mesh)
}
function resize() {
if (!renderer) return
const { width, height } = containerSize()
renderer.setSize(width, height, false)
updateProjection()
requestRender()
}
function setTexture(loaded: THREE.DataTexture) {
if (!material || !mesh) return
loaded.colorSpace = THREE.LinearSRGBColorSpace
loaded.minFilter = THREE.LinearFilter
loaded.magFilter = THREE.LinearFilter
loaded.needsUpdate = true
const { width, height, data } = loaded.image
texture = loaded
imageAspect = width / height
mesh.scale.set(imageAspect, 1, 1)
material.uniforms.uImage.value = loaded
dimensions.value = `${width} x ${height}`
if (!data) return
imageWidth = width
imageHeight = height
imageChannels = data.length / (width * height)
readSample = makeReader(data, loaded.type)
stats.value = computeImageStats(readSample, data.length, imageChannels)
histograms.value = computeChannelHistograms(
readSample,
data.length,
imageChannels
)
}
async function mount(container: HTMLElement, url: string) {
containerRef.value = container
loading.value = true
error.value = null
try {
buildScene()
container.appendChild(renderer!.domElement)
renderer!.domElement.classList.add('block', 'size-full')
resize()
applyUniforms()
attachInteractions(renderer!.domElement)
viewport!.observeResize(container, resize)
const { texture: loaded, gamut: detectedGamut } =
await loadHdrTexture(url)
if (!material || !mesh) {
loaded.dispose()
return
}
gamut.value = detectedGamut
setTexture(loaded)
applyUniforms()
fitView()
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
dispose()
} finally {
loading.value = false
}
}
function normalizeExposure() {
const max = stats.value?.max ?? 0
exposureStops.value = max > 0 ? -Math.log2(max) : 0
}
function attachInteractions(canvas: HTMLCanvasElement) {
canvas.addEventListener('wheel', onWheel, { passive: false })
canvas.addEventListener('pointerdown', onPointerDown)
canvas.addEventListener('pointermove', onHoverMove)
canvas.addEventListener('pointerleave', onHoverLeave)
}
function onWheel(event: WheelEvent) {
if (!camera) return
event.preventDefault()
const factor = Math.exp(-event.deltaY * 0.001)
const nextZoom = THREE.MathUtils.clamp(
camera.zoom * factor,
MIN_ZOOM,
MAX_ZOOM
)
camera.zoom = nextZoom
camera.updateProjectionMatrix()
requestRender()
}
let dragStart: { x: number; y: number; camX: number; camY: number } | null =
null
function onPointerDown(event: PointerEvent) {
if (!camera) return
dragStart = {
x: event.clientX,
y: event.clientY,
camX: camera.position.x,
camY: camera.position.y
}
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
}
function onPointerMove(event: PointerEvent) {
if (!camera || !dragStart) return
const { height } = containerSize()
const worldPerPixel = 1 / (height * camera.zoom)
camera.position.x =
dragStart.camX - (event.clientX - dragStart.x) * worldPerPixel
camera.position.y =
dragStart.camY + (event.clientY - dragStart.y) * worldPerPixel
requestRender()
}
function onPointerUp() {
dragStart = null
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
}
function onHoverMove(event: PointerEvent) {
if (!camera || !mesh || !renderer || dragStart || !readSample) return
const rect = renderer.domElement.getBoundingClientRect()
pointerNdc.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
pointerNdc.y = -(((event.clientY - rect.top) / rect.height) * 2 - 1)
raycaster.setFromCamera(pointerNdc, camera)
const hit = raycaster.intersectObject(mesh)[0]
if (!hit?.uv) {
pixel.value = null
return
}
const col = THREE.MathUtils.clamp(
Math.floor(hit.uv.x * imageWidth),
0,
imageWidth - 1
)
const row = THREE.MathUtils.clamp(
Math.floor(hit.uv.y * imageHeight),
0,
imageHeight - 1
)
const base = (row * imageWidth + col) * imageChannels
pixel.value = {
x: col,
y: imageHeight - 1 - row,
r: readSample(base),
g: readSample(base + 1),
b: readSample(base + 2),
a: imageChannels === 4 ? readSample(base + 3) : null
}
}
function onHoverLeave() {
pixel.value = null
}
function dispose() {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
if (renderer) {
renderer.domElement.removeEventListener('wheel', onWheel)
renderer.domElement.removeEventListener('pointerdown', onPointerDown)
renderer.domElement.removeEventListener('pointermove', onHoverMove)
renderer.domElement.removeEventListener('pointerleave', onHoverLeave)
}
viewport?.disposeRenderer()
texture?.dispose()
material?.dispose()
mesh?.geometry.dispose()
renderer = null
viewport = null
scene = null
camera = null
material = null
mesh = null
texture = null
readSample = null
}
watch([exposureStops, dither, clipWarnings, gamut, channel], applyUniforms)
onUnmounted(dispose)
return {
exposureStops,
dither,
clipWarnings,
gamut,
channel,
loading,
error,
dimensions,
stats,
histogram,
pixel,
mount,
dispose,
fitView,
normalizeExposure
}
}

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

@@ -8,12 +8,12 @@ export function useWorkflowStatusDismissal() {
const executionStore = useExecutionStore()
watch(
() => workflowStore.activeWorkflow,
(workflow) => {
if (
workflow &&
executionStore.getWorkflowStatus(workflow) !== 'running'
) {
() => {
const workflow = workflowStore.activeWorkflow
return [workflow, executionStore.getWorkflowStatus(workflow)] as const
},
([workflow, status]) => {
if (workflow && status !== undefined && status !== 'running') {
executionStore.clearWorkflowStatus(workflow)
}
},

View File

@@ -522,6 +522,22 @@ describe('hasUnpromotedWidgets', () => {
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
it('returns false (does not throw) when SubgraphNode is detached', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const parentGraph = subgraphNode.graph!
parentGraph.add(subgraphNode)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
parentGraph.remove(subgraphNode)
expect(subgraphNode.graph).toBeNull()
expect(() => hasUnpromotedWidgets(subgraphNode)).not.toThrow()
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
})
describe('isLinkedPromotion', () => {

View File

@@ -633,6 +633,7 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
}
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
if (subgraphNode.isDetached) return false
const { subgraph } = subgraphNode
return subgraph.nodes.some((interiorNode) =>

View File

@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { state } = vi.hoisted(() => ({
state: {
extension: null as { nodeCreated: (node: unknown) => void } | null,
widgetState: undefined as { options: Record<string, unknown> } | undefined
}
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({
registerExtension: (ext: { nodeCreated: (node: unknown) => void }) => {
state.extension = ext
}
})
}))
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({ getWidget: () => state.widgetState })
}))
await import('./createBoundingBoxes')
interface MockWidget {
name: string
hidden: boolean
options: Record<string, unknown>
widgetId?: string
}
function makeNode(connected: boolean, comfyClass = 'CreateBoundingBoxes') {
const widgets: MockWidget[] = [
{ name: 'width', hidden: false, options: {} },
{ name: 'height', hidden: false, options: {} },
{ name: 'other', hidden: false, options: {} }
]
return {
constructor: { comfyClass },
size: [100, 100] as [number, number],
setSize: vi.fn(),
findInputSlot: () => 0,
isInputConnected: () => connected,
widgets,
onConnectionsChange: undefined as unknown
}
}
beforeEach(() => {
state.widgetState = undefined
})
describe('Comfy.CreateBoundingBoxes extension', () => {
it('ignores nodes of other classes', () => {
const node = makeNode(true, 'SomethingElse')
state.extension!.nodeCreated(node)
expect(node.setSize).not.toHaveBeenCalled()
})
it('enlarges the node and hides width/height when a background is connected', () => {
const node = makeNode(true)
state.extension!.nodeCreated(node)
expect(node.setSize).toHaveBeenCalledWith([420, 560])
expect(node.widgets[0].hidden).toBe(true)
expect(node.widgets[1].hidden).toBe(true)
expect(node.widgets[0].options.hidden).toBe(true)
expect(node.widgets[2].hidden).toBe(false)
})
it('shows width/height when no background is connected', () => {
const node = makeNode(false)
state.extension!.nodeCreated(node)
expect(node.widgets[0].hidden).toBe(false)
expect(node.widgets[0].options.hidden).toBe(false)
})
it('writes visibility through the widget value store when present', () => {
state.widgetState = { options: {} }
const node = makeNode(true)
node.widgets[0].widgetId = 'w-0'
state.extension!.nodeCreated(node)
expect(state.widgetState.options.hidden).toBe(true)
})
it('chains a connections-change handler that re-syncs visibility', () => {
const node = makeNode(false)
state.extension!.nodeCreated(node)
expect(typeof node.onConnectionsChange).toBe('function')
})
})

View File

@@ -0,0 +1,38 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useExtensionService } from '@/services/extensionService'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
const DIMENSION_WIDGETS = new Set(['width', 'height'])
useExtensionService().registerExtension({
name: 'Comfy.CreateBoundingBoxes',
nodeCreated(node) {
if (node.constructor.comfyClass !== 'CreateBoundingBoxes') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 420), Math.max(oldHeight, 560)])
const widgetValueStore = useWidgetValueStore()
const syncDimensionVisibility = () => {
const slot = node.findInputSlot('background')
const hidden = slot >= 0 && node.isInputConnected(slot)
for (const widget of node.widgets ?? []) {
if (!DIMENSION_WIDGETS.has(widget.name)) continue
widget.hidden = hidden
const state = widget.widgetId
? widgetValueStore.getWidget(widget.widgetId)
: undefined
if (state?.options) state.options.hidden = hidden
else widget.options.hidden = hidden
}
}
syncDimensionVisibility()
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
syncDimensionVisibility
)
}
})

View File

@@ -2,6 +2,7 @@ import { isCloud, isNightly } from '@/platform/distribution/types'
import './clipspace'
import './contextMenuFilter'
import './createBoundingBoxes'
import './customWidgets'
import './dynamicPrompts'
import './editAttention'

View File

@@ -1,5 +1,7 @@
import * as THREE from 'three'
import { WebGLViewport } from '@/renderer/three/WebGLViewport'
import type { CameraManager } from './CameraManager'
import type { ControlsManager } from './ControlsManager'
import type { EventManager } from './EventManager'
@@ -27,8 +29,7 @@ export type Viewport3dDeps = {
viewHelperManager: ViewHelperManager
}
export class Viewport3d {
renderer: THREE.WebGLRenderer
export class Viewport3d extends WebGLViewport {
protected clock: THREE.Clock
private renderLoop: RenderLoopHandle | null = null
private onContextMenuCallback?: (event: MouseEvent) => void
@@ -52,7 +53,6 @@ export class Viewport3d {
isViewerMode: boolean = false
private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
private getZoomScaleCallback: (() => number) | undefined
private externalActiveCamera: THREE.Camera | null = null
private overlay: SceneOverlay | null = null
@@ -63,6 +63,7 @@ export class Viewport3d {
deps: Viewport3dDeps,
options: Load3DOptions = {}
) {
super(deps.renderer)
this.clock = new THREE.Clock()
this.isViewerMode = options.isViewerMode || false
this.onContextMenuCallback = options.onContextMenu
@@ -73,7 +74,6 @@ export class Viewport3d {
this.applyTargetSize(options.width, options.height)
}
this.renderer = deps.renderer
this.eventManager = deps.eventManager
this.sceneManager = deps.sceneManager
this.cameraManager = deps.cameraManager
@@ -94,7 +94,7 @@ export class Viewport3d {
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.initResizeObserver(container)
this.observeResize(container, () => this.handleResize())
}
start(): void {
@@ -118,16 +118,6 @@ export class Viewport3d {
this.targetAspectRatio = width / height
}
private initResizeObserver(container: Element | HTMLElement): void {
if (typeof ResizeObserver === 'undefined') return
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => {
this.handleResize()
})
this.resizeObserver.observe(container)
}
private initContextMenu(): void {
this.disposeContextMenuGuard = attachContextMenuGuard(
this.renderer.domElement,
@@ -400,29 +390,15 @@ export class Viewport3d {
this.initialRenderTimer = null
}
if (this.resizeObserver) {
this.resizeObserver.disconnect()
this.resizeObserver = null
}
this.disposeContextMenuGuard?.()
this.disposeContextMenuGuard = null
this.renderer.forceContextLoss()
const canvas = this.renderer.domElement
const event = new Event('webglcontextlost', {
bubbles: true,
cancelable: true
})
canvas.dispatchEvent(event)
this.renderLoop?.stop()
this.renderLoop = null
this.disposeManagers()
this.renderer.dispose()
this.renderer.domElement.remove()
this.disposeRenderer()
}
protected disposeManagers(): void {

View File

@@ -26,6 +26,7 @@ vi.mock('@/scripts/app', () => ({
}))
vi.mock('@/extensions/core/load3d', () => ({}))
vi.mock('@/extensions/core/load3dAdvanced', () => ({}))
vi.mock('@/extensions/core/load3dPreviewExtensions', () => ({}))
vi.mock('@/extensions/core/saveMesh', () => ({}))

View File

@@ -246,3 +246,37 @@ describe('Comfy.UploadAudio AUDIOUPLOAD widget', () => {
expect(mockFetchApi).not.toHaveBeenCalled()
})
})
type AudioUIWidget = (node: LGraphNode, inputName: string) => unknown
async function loadAudioUIWidget() {
vi.resetModules()
mockRegisterExtension.mockClear()
await import('./uploadAudio')
const extension = mockRegisterExtension.mock.calls
.map(([extension]) => extension as ComfyExtension)
.find((extension) => extension.name === 'Comfy.AudioWidget')
if (!extension)
throw new Error('Comfy.AudioWidget extension was not registered')
const widgets = await extension.getCustomWidgets!(fromAny({}))
return (widgets as Record<string, AudioUIWidget>).AUDIO_UI
}
describe('Comfy.AudioWidget AUDIO_UI widget', () => {
it('excludes the audio player from workflow and prompt serialization', async () => {
const AUDIO_UI = await loadAudioUIWidget()
const domWidget = {
serialize: true,
options: {} as Record<string, unknown>
}
const node = fromAny<LGraphNode, unknown>({
addDOMWidget: vi.fn(() => domWidget),
constructor: { nodeData: { output_node: false } }
})
AUDIO_UI(node, 'audioUI')
expect(domWidget.serialize).toBe(false)
expect(domWidget.options.serialize).toBe(false)
})
})

View File

@@ -128,6 +128,7 @@ app.registerExtension({
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
audioUIWidget.serialize = false
audioUIWidget.options.serialize = false
const { nodeData } = node.constructor
if (nodeData == null) throw new TypeError('nodeData is null')

View File

@@ -7,7 +7,6 @@ import type {
LLink
} from '@/lib/litegraph/src/litegraph'
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type {
IBaseWidget,
TWidgetValue
@@ -273,17 +272,7 @@ export class PrimitiveNode extends LGraphNode {
widgetName: 'value',
nodeTypeForBrowser: targetNode.comfyClass ?? '',
inputNameForBrowser: targetInputName,
defaultValue,
onValueChange: (widget, newValue, oldValue) => {
widget.callback?.(
widget.value,
app.canvas,
this,
app.canvas.graph_mouse,
{} as CanvasPointerEvent
)
this.onWidgetChanged?.(widget.name, newValue, oldValue, widget)
}
defaultValue
})
}

View File

@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeId, Subgraph } from '@/lib/litegraph/src/litegraph'
import {
LGraph,
LGraphGroup,
LGraphNode,
LiteGraph,
LLink,
@@ -323,6 +324,96 @@ describe('Graph Clearing and Callbacks', () => {
})
})
describe('node:before-removed event', () => {
it('fires node:before-removed for a successful node removal', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const events: { node: LGraphNode; graphAtDispatch: unknown }[] = []
graph.events.addEventListener('node:before-removed', (e) => {
events.push({
node: e.detail.node,
graphAtDispatch: e.detail.node.graph
})
})
graph.remove(node)
expect(events).toHaveLength(1)
expect(events[0].node).toBe(node)
expect(events[0].graphAtDispatch).toBe(graph)
expect(node.graph).toBeNull()
})
it('does not fire node:before-removed for a node not in the graph', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
const fired = vi.fn()
graph.events.addEventListener('node:before-removed', fired)
graph.remove(node)
expect(fired).not.toHaveBeenCalled()
})
it('does not fire node:before-removed when removing an LGraphGroup', () => {
const graph = new LGraph()
const group = new LGraphGroup('test-group')
graph.add(group)
const fired = vi.fn()
graph.events.addEventListener('node:before-removed', fired)
graph.remove(group)
expect(fired).not.toHaveBeenCalled()
})
it('does not fire node:before-removed when ignore_remove is set', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
node.ignore_remove = true
const fired = vi.fn()
graph.events.addEventListener('node:before-removed', fired)
graph.remove(node)
expect(fired).not.toHaveBeenCalled()
expect(graph.nodes).toContain(node)
})
it('fires node:before-removed before node.onRemoved and detach', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const order: string[] = []
graph.events.addEventListener('node:before-removed', () => {
order.push(
`before-removed(graph=${node.graph === graph ? 'set' : 'null'})`
)
})
node.onRemoved = () => {
order.push(`onRemoved(graph=${node.graph === graph ? 'set' : 'null'})`)
}
graph.onNodeRemoved = (n) => {
order.push(`onNodeRemoved(graph=${n.graph === null ? 'null' : 'set'})`)
}
graph.remove(node)
expect(order).toEqual([
'before-removed(graph=set)',
'onRemoved(graph=set)',
'onNodeRemoved(graph=null)'
])
})
})
describe('Subgraph Definition Garbage Collection', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -375,6 +466,53 @@ describe('Subgraph Definition Garbage Collection', () => {
expect(graphRemovedNodeIds.size).toBe(2)
})
it('subgraph-definition GC dispatches node:before-removed on the inner subgraph for each inner node', () => {
const rootGraph = new LGraph()
const { subgraph, innerNodes } = createSubgraphWithNodes(rootGraph, 2)
const dispatched: { node: LGraphNode; graphAtDispatch: unknown }[] = []
subgraph.events.addEventListener('node:before-removed', (e) => {
dispatched.push({
node: e.detail.node,
graphAtDispatch: e.detail.node.graph
})
})
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
rootGraph.add(subgraphNode)
rootGraph.remove(subgraphNode)
expect(dispatched.map((e) => e.node)).toEqual(innerNodes)
for (const entry of dispatched) {
expect(entry.graphAtDispatch).toBe(subgraph)
}
})
it('subgraph-definition GC dispatches node:before-removed before each inner node onRemoved', () => {
const rootGraph = new LGraph()
const { subgraph, innerNodes } = createSubgraphWithNodes(rootGraph, 1)
const innerNode = innerNodes[0]
const order: string[] = []
subgraph.events.addEventListener('node:before-removed', () => {
order.push('before-removed')
})
innerNode.onRemoved = () => {
order.push('onRemoved')
}
subgraph.onNodeRemoved = () => {
order.push('onNodeRemoved')
}
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
rootGraph.add(subgraphNode)
rootGraph.remove(subgraphNode)
expect(order).toEqual(['before-removed', 'onRemoved', 'onNodeRemoved'])
})
it('subgraph definition is removed when SubgraphNode is removed', () => {
const rootGraph = new LGraph()
const { subgraph } = createSubgraphWithNodes(rootGraph, 1)

View File

@@ -145,7 +145,12 @@ export interface GraphAddOptions {
export interface LGraphExtra extends Dictionary<unknown> {
reroutes?: SerialisableReroute[]
linkExtensions?: { id: LinkId; parentId: RerouteId | undefined }[]
linkExtensions?: {
id: LinkId
parentId: RerouteId | undefined
hidden?: boolean
label?: string
}[]
ds?: DragAndScaleState
workflowRendererVersion?: RendererType
}
@@ -155,6 +160,13 @@ export interface BaseLGraph {
readonly rootGraph: LGraph
}
function fireNodeRemovalLifecycle(node: LGraphNode): void {
const graph: LGraph | null = node.graph
graph?.events.dispatch('node:before-removed', { node })
node.onRemoved?.()
graph?.onNodeRemoved?.(node)
}
/**
* LGraph is the class that contain a full graph. We instantiate one and add nodes to it, and then we can run the execution loop.
* supported callbacks:
@@ -386,8 +398,7 @@ export class LGraph
// safe clear
if (this._nodes) {
for (const _node of this._nodes) {
_node.onRemoved?.()
this.onNodeRemoved?.(_node)
fireNodeRemovalLifecycle(_node)
}
}
@@ -1046,6 +1057,8 @@ export class LGraph
// sure? - almost sure is wrong
this.beforeChange()
this.events.dispatch('node:before-removed', { node })
const { inputs, outputs } = node
// disconnect inputs
@@ -1081,10 +1094,7 @@ export class LGraph
)
if (!hasRemainingReferences) {
forEachNode(node.subgraph, (innerNode) => {
innerNode.onRemoved?.()
innerNode.graph?.onNodeRemoved?.(innerNode)
})
forEachNode(node.subgraph, fireNodeRemovalLifecycle)
this.rootGraph.subgraphs.delete(node.subgraph.id)
}
}
@@ -2275,12 +2285,19 @@ export class LGraph
const linkArray = [...this._links.values()]
const links = linkArray.map((x) => x.serialize())
if (reroutes?.length) {
// Link parent IDs cannot go in 0.4 schema arrays
extra.linkExtensions = linkArray
.filter((x) => x.parentId !== undefined)
.map((x) => ({ id: x.id, parentId: x.parentId }))
}
// Per-link data that cannot fit in 0.4 schema arrays (parent reroute id,
// hidden state, badge label) rides along in extra.linkExtensions.
const linkExtensions = linkArray
.filter(
(x) => x.parentId !== undefined || x.hidden || x.label !== undefined
)
.map((x) => ({
id: x.id,
parentId: x.parentId,
hidden: x.hidden,
label: x.label
}))
extra.linkExtensions = linkExtensions.length ? linkExtensions : undefined
extra.reroutes = reroutes?.length ? reroutes : undefined
return {
@@ -2437,7 +2454,10 @@ export class LGraph
if (Array.isArray(extra?.linkExtensions)) {
for (const linkEx of extra.linkExtensions) {
const link = this._links.get(linkEx.id)
if (link) link.parentId = linkEx.parentId
if (!link) continue
link.parentId = linkEx.parentId
if (linkEx.hidden !== undefined) link.hidden = linkEx.hidden
if (linkEx.label !== undefined) link.label = linkEx.label
}
}

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