Compare commits

..

16 Commits

Author SHA1 Message Date
huang47
31542afc16 feat: block disabled partner nodes from queue 2026-07-17 13:23:46 -07:00
huang47
6ca9ce7607 feat: filter disabled partner nodes from discovery 2026-07-17 13:23:36 -07:00
huang47
0a582f62e2 feat: add partner node enforcement control 2026-07-17 13:23:27 -07:00
huang47
a0b3c1b436 fix: ignore stale allowlist saves 2026-07-17 13:22:19 -07:00
huang47
397559b080 fix: address allowlist review feedback 2026-07-17 12:36:42 -07:00
huang47
aa41a160fe feat: add partner node allowlist editor 2026-07-17 12:36:42 -07:00
huang47
3e2a02f181 test: cover partner policy fail-open loading 2026-07-17 12:35:45 -07:00
huang47
cb10d58525 feat: add partner node governance policy foundation 2026-07-17 12:34:08 -07:00
huang47
6d862f60c8 test: cover disabled partner node governance flag 2026-07-17 12:32:16 -07:00
huang47
b8ea3851cb feat: add partner node governance feature flag 2026-07-17 11:06:46 -07:00
Angelo Lesniak
e6c04359b5 fix: load mask editor layers from the saved image's subfolder (#13744)
## Summary

Since #12318 the mask editor uploads its layer files to the input root,
but the loader still looked for them in the `clipspace/` subfolder, so
reopening the editor after saving a mask silently failed (four 404s,
`[MaskEditorContent] Initialization failed`, dialog never opens).
Affects v1.47.3+.

## Changes

- **What**: `useMaskEditorLoader` derives the layer-file subfolder from
the image the widget references (input root for saves under the unified
upload contract, `clipspace/` for saves made before #12318) instead of
hardcoding `clipspace`. When layer files are missing entirely, it falls
back to loading the node image (mask restored from its alpha channel) so
the editor still opens instead of failing silently.

## Review Focus

- The cloud path (`/files/mask-layers`) now also resolves layer refs
with the widget ref's subfolder instead of hardcoded `clipspace`.
Verified against a local OSS backend (save → reopen round-trip, plus
masks saved by 1.45.x with `clipspace/`-prefixed widget values and by
1.47.x with root-stored files); not verified against cloud.
- Repro/verification: unit tests and the new e2e round-trip test fail on
`main` and pass with this change.
2026-07-17 09:45:21 -04:00
Deep Mehta
27859cb41c feat(website): section-level client tabs on /mcp setup with click tracking (#13731)
## Summary

Stacked on #13728. Implements the setup-section redesign from the design
mock: the client picker moves out of the Option 1 card into a bordered
tab tray directly under the section header, and the selected client
drives the step + command shown inside the Option 1 (manual install)
card.

## Changes

- **What**:
- Tabs render at section level in a bordered tray (`rounded-2xl border
p-1`, yellow fill-active pills); `TabsRoot` wraps the tray and the grid
so the panels stay inside the manual card.
- Added OpenClaw as a sixth client (skill install + `openclaw mcp set`
commands from the setup docs); the catch-all tab is renamed to "Others".
- The agent card's skills note now sits inline under the command instead
of pinned to the card bottom.
- PostHog: new `website:mcp_client_tab_clicked` event (property:
`client`) captured on tab click via the existing `posthog.ts` helper
pattern; two unit tests added alongside the download-click tests.
Heatmap/autocapture data comes for free once on the page, this gives
named click-volume per client.
- e2e: OpenClaw tab assertion added; all 7 mcp tests pass; card heights
verified stable across all six tabs.

## Review Focus

- Event-capture verification in headless browsers is structurally
impossible (posthog-js drops events for `navigator.webdriver` clients),
so runtime verification is unit-level, matching how
`captureDownloadClick` is tested.
- Copy kept from #13728 (subtitle lists manual first; agent card leads
"Prefer to let your agent do it?") rather than the mock's carried-over
screenshot text.

Linear:
[PM-132](https://linear.app/comfyorg/issue/PM-132/comfyorgmcp-faq-is-stale-and-contradicts-the-page-rewrite-faq-fix)

## Screenshots (if applicable)
2026-07-17 08:47:18 +00:00
AustinMroz
3be998aaf4 Add indicator for free tier quota system (#13657)
Adds frontend support for a quota based free tier.
- Adds a indicator component beneath the run button (docked/undocked/app
mode)
- Tracks available usages as workflows are queued. This is enforced by
backend, but updated quota information is not sent after initial load.
- Tracks partner node presence in graph (Workflows with partner nodes
are not allowed under the quota system)

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/c8dd53c3-9eff-4712-a4c2-c3d31bbbae35"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/c9410799-130d-4332-b2f8-20e993402f2c"
/>|

---------

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-07-17 04:25:26 +00:00
Alexander Brown
99674df73b chore: add fallow dev dependency, scripts, and minimal config (#13271)
Adds the [fallow](https://github.com/fallow-rs/fallow)
codebase-intelligence analyzer as a devDependency to surface unused
code, duplication, and circular dependencies.

## Changes
- `package.json` — `fallow` devDep (`catalog:`) + scripts:
  - `pnpm fallow` — full-repo scan
  - `pnpm fallow:audit` — changed-files gate (suitable for CI)
- `pnpm-workspace.yaml` — `fallow` catalog entry
- `.fallowrc.json` — minimal config: entry points (`src/main.ts`,
`index.html`) + duplicate-clone threshold, ignoring generated files
- `.gitignore` — `.fallow/` cache dir

This PR only wires up the tool. The first scan surfaced cleanup
candidates (unused deps/exports, duplicate exports, circular deps);
those are tracked as separate follow-up PRs.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:32:29 +00:00
Dante
cfaf89edea feat: role-aware billing status banner for team workspaces (FE-1246) (#13641)
Adds the single billing-status banner slot for team plans, rendered in
priority order — **paused > payment declined > out of credits > ending**
— with role-aware CTAs. At most one state shows at a time.

Placement matches the prototype (`comfydesigner/team-workspaces-v1`) and
Figma: the banner lives in the **workspace settings panel**, in one slot
between the tab list and the tab body, so it shows on every tab. Our
container has two tabs, so one mount covers both rather than duplicating
the banner into each panel as the prototype does.

Derivation lives in a pure `deriveBillingBanner()`; the component only
renders. State comes from `billing-status` fields, never from error
strings (per Luke's user story).


## Gated on the plan, not the workspace type

`useBillingContext` gains `isTeamPlan`: a credit stop marks the
per-credit Team plan, a `team-` slug the retired seat-based ones.
Workspace type is the wrong question in both directions:

- A **team workspace can sit on a retired seat-based plan** (tier
STANDARD/CREATOR/PRO) — the type gate admits it.
- Once consolidated billing lands (cloud#5010), a **personal workspace
can hold a team plan**. The backend blocks that today pending BE-1526,
so the type gate is only accidentally right, and only for now. Per
[Hunter](https://comfy-organization.slack.com/archives/C0BEE5503RQ/p1784151520761519?thread_ts=1784051425.176829):
*"We should deprecate the workspace type and derive it from the
subscription."*

Two deliberate non-choices:

- **Not gated on `isActiveSubscription`**, unlike the existing
`isLegacyTeamPlan`. The spend gate folds `billing_status` into
`is_active`, so a paused or payment-failed team plan reports
`is_active=false` — it must still read as a team plan exactly when the
banner is needed.
- **Not gated on `subscription_tier === 'TEAM'`**, which would silently
drop every legacy team subscriber. The FE cannot express `'TEAM'`
anyway: `tierPricing.ts` resolves `SubscriptionTier` from the
**registry** spec for what is an **ingest** field. That's a real bug,
but orthogonal — filing separately.

## Fixes payment_failed, which was dead code

`payment_failed` denies spend, so it always arrives with
`is_active=false` — and the check sat **below** the `is_active` gate,
two lines under a comment documenting that exact trap for `paused`.
**Every team in Stripe dunning saw no banner.** Now hoisted alongside
`paused`.

Its tests passed only because they spread the `funded` fixture
(`isActiveSubscription: true`) onto `payment_failed` — a pairing the
backend never emits. They now pin `is_active=false`. Both this and the
`isTeamPlan` decoupling are mutation-tested: reverting either kills
tests.

Members no longer fall through from `payment_failed` to out-of-credits —
with the real pairing that path was unreachable, and DES-380 says
members never see the payment banner.

## Renders

The six role/state variants render from the Storybook stories at
`Platform/Workspace/BillingStatusBanner`. Each story drives the real
`deriveBillingBanner` through a stubbed billing context, so a story can
only show a state the backend can actually produce — not a hand-set
banner kind.

Run them with `DISTRIBUTION=cloud pnpm storybook` — the banner is
cloud-only and `isCloud` is compile-time, so a plain `pnpm storybook`
renders every story empty.

Icon language follows the prototype's severity rule: amber
triangle-alert for every action-needed state (paused, payment declined,
out of credits), muted circle-alert reserved for the informational "plan
ends" notice.

## Feature flag

Personal-workspace routing now keys off **`billing_control_enabled`**,
replacing `consolidated_billing_enabled` (which had exactly one consumer
— this routing check — so it is replaced rather than left alongside).
Cloud registers the new flag in
[Comfy-Org/cloud#5091](https://github.com/Comfy-Org/cloud/pull/5091).

This coupling is load-bearing for the banner. `isTeamPlan` requires the
workspace billing rail, so a personal workspace holding a team plan only
surfaces billing state once its routing flag is on. Had routing stayed
on `consolidated_billing_enabled` while the subscribe path unblocks
under `billing_control_enabled`, the two could diverge: a personal
workspace could buy a team plan and then be routed to legacy, which
carries no `billing_status` at all, blanking the banner.

**Merge order:** cloud#5091 must merge and `billing_control_enabled`
must exist in PostHog before this ships, otherwise the flag resolves
false for everyone and every personal workspace routes to legacy. Both
flags default false, so the floor is today's behaviour — but any users
already rolled out on `consolidated_billing_enabled` would revert to
legacy billing until `billing_control_enabled` is rolled out to them.
Worth confirming the current rollout state before merge.

## Backend status

`billing_status: 'paused'` merged in cloud
[#5075](https://github.com/Comfy-Org/cloud/pull/5075) but is **not
emitted yet**: the lifecycle handler that writes it is unreachable in
prod (ingest's forward allowlist omits `customer.subscription.*`;
billing-api is ClusterIP with no ingress). That's BE-1530's unfinished
half, tracked backend-side. The paused banner stays inert until then.

The other three states render from fields the API already returns:
`billing_status=payment_failed` + `renewal_date`, `has_funds`, and
`cancel_at`.

## How has this been tested?

- `useBillingContext.test.ts` — `isTeamPlan` across per-credit, legacy,
paused, payment-failed, and team-workspace-on-personal-plan
- `deriveBillingBanner.test.ts` — priority order, role gating, and the
realistic `is_active=false` pairing for both paused and payment_failed
- `BillingStatusBanner.test.ts` — per-variant copy and actions, date
interpolation + no-date fallback, cross-mount dismiss, ending read-only
for a non-original owner
- `useBillingBanner.test.ts` — dismiss resets after a top-up so a later
exhaustion re-shows
- `WorkspacePanelContent.test.ts` — the banner takes one slot above the
tab body
- 696 tests pass across the touched areas; typecheck / lint / knip clean

## Known gaps

- **Blocked-run surface:** settings-scoped per the prototype, so a user
who exhausts credits mid-canvas won't see it until they open settings.
Queue-time blocked-submit is carried by the existing
insufficient-credits dialog. An app-shell surface would be a design
call, not this PR.
- **Personal workspaces on consolidated billing** get no banner —
`isTeamPlan` excludes them by design, matching FE-1246's team scope and
the `!isInPersonalWorkspace` precedent in `useSubscriptionDialog`. Their
copy would need personal variants (`outOfCredits.body` says "Your
team…") and a free-tier decision. Belongs with the consolidated-billing
rollout.
- **Prototype slot:** the prototype's banner exposes an `actions` slot
used only by its Invoices view. We have no such view, so it's omitted
until needed.


## screenshots

<img width="1150" height="574" alt="01-paused-owner"
src="https://github.com/user-attachments/assets/c1ff0abd-0286-4453-85f2-6f89cff1e655"
/>
<img width="1150" height="574" alt="02-paused-member"
src="https://github.com/user-attachments/assets/ad5ce843-cbc6-49d1-95fc-28e908bc010a"
/>
<img width="1150" height="574" alt="03-payment-declined"
src="https://github.com/user-attachments/assets/717c1191-8f12-4835-90c6-5fe345b20780"
/>
<img width="1150" height="574" alt="04-payment-declined-no-date"
src="https://github.com/user-attachments/assets/c7e483e4-e0cb-4810-b090-a9c432546dbd"
/>
<img width="1150" height="574" alt="05-out-of-credits-owner"
src="https://github.com/user-attachments/assets/9261fd39-ecdb-4501-a401-47d9cbee04dc"
/>
<img width="1150" height="574" alt="06-out-of-credits-member"
src="https://github.com/user-attachments/assets/4a91af88-4405-43e1-8975-b64fdf180a13"
/>
<img width="1150" height="574" alt="07-ending-owner"
src="https://github.com/user-attachments/assets/80e5011e-ef11-482f-bc49-1ddfbe991221"
/>
<img width="1150" height="574" alt="08-ending-non-original-owner"
src="https://github.com/user-attachments/assets/129b79aa-1427-4286-829c-eed501a6d44d"
/>
2026-07-17 00:08:30 +00:00
claude[bot]
a154e6a311 Redirect comfy.org/login to cloud.comfy.org/login (#13727)
<!-- ccr-slack-attribution -->
_Requested by **nav** · [Slack
thread](https://comfy-organization.slack.com/archives/C0B61J7QCJW/p1784217313626029?thread_ts=1784217313.626029&cid=C0B61J7QCJW)_

## Summary

Redirect comfy.org/login to cloud.comfy.org/login.

## Changes

- **What**: Adds a `/login` entry to the `redirects` array in
`apps/website/vercel.json`, matching the existing `/blog` ->
`https://blog.comfy.org` cross-origin redirect pattern.

## Review Focus

- Before: `comfy.org/login` had no redirect (404/no route).
- After: `comfy.org/login` returns a 302 (`permanent: false`) to
`https://cloud.comfy.org/login`.
- How: Astro's native redirects don't support cross-origin destinations,
so this uses Vercel's `redirects` config in `apps/website/vercel.json`,
consistent with the existing `/blog` entry.

## Screenshots (if applicable)

N/A

---
_Generated by [Claude
Code](https://claude.ai/code/session_01UZGZ27NenZf5dzdvNcTFev)_

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-16 22:20:02 +00:00
100 changed files with 5080 additions and 877 deletions

9
.fallowrc.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
"entry": ["src/main.ts", "index.html"],
"duplicates": {
"minOccurrences": 3,
"ignore": ["**/*.generated.*", "**/generatedManagerTypes.ts"]
},
"rules": {}
}

1
.gitignore vendored
View File

@@ -16,6 +16,7 @@ yarn.lock
.eslintcache
.prettiercache
.stylelintcache
.fallow/
node_modules
.pnpm-store

View File

@@ -88,6 +88,11 @@ const config: StorybookConfig = {
replacement:
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
},
{
find: '@/platform/workspace/composables/useWorkspaceUI',
replacement:
process.cwd() + '/src/storybook/mocks/useWorkspaceUI.ts'
},
{
find: '@/platform/workspace/stores/teamWorkspaceStore',
replacement:

View File

@@ -1,9 +1,20 @@
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const MCP_ENDPOINT = 'https://cloud.comfy.org/mcp'
// The setup island hydrates on visibility; clicks before hydration are
// no-ops, so retry until the tab actually activates.
async function selectClientTab(setup: Locator, name: string) {
const tab = setup.getByRole('tab', { name })
await expect(async () => {
await tab.click()
await expect(tab).toHaveAttribute('data-state', 'active', { timeout: 500 })
}).toPass()
}
test.describe('MCP page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/mcp')
@@ -19,46 +30,72 @@ test.describe('MCP page @smoke', () => {
}
})
test('setup section shows both install options', async ({ page }) => {
test('Claude Desktop is the default tab and shows only the connector card', async ({
page
}) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(
setup.getByRole('tab', { name: 'Claude Desktop' })
).toHaveAttribute('data-state', 'active')
await expect(
setup.getByRole('heading', { name: 'Add Custom Connector' })
).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
await expect(
setup.getByRole('heading', {
name: 'Ask your agent to install Comfy MCP'
})
).toBeVisible()
await expect(
setup.getByRole('heading', { name: 'Install manually' })
).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
).toHaveCount(0)
await expect(setup.locator('video')).toBeVisible()
})
test('client tabs swap install instructions', async ({ page }) => {
test('client tabs swap install instructions and agent-card visibility', async ({
page
}) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
const activePanel = setup.locator('[role="tabpanel"][data-state="active"]')
const agentHeading = setup.getByRole('heading', {
name: 'Ask your agent to install Comfy MCP'
})
// Claude Code is the default tab and carries the CLI command
await expect(
setup.getByRole('tab', { name: 'Claude Code' })
).toHaveAttribute('data-state', 'active')
await expect(activePanel).toContainText('Add custom connector')
// First interaction retries until the island hydrates; later switches
// assert synchronously so steady-state click regressions fail.
await selectClientTab(setup, 'Claude Code Terminal')
await expect(activePanel).toContainText(
`claude mcp add --transport http comfy-cloud ${MCP_ENDPOINT}`
)
await expect(
setup.getByRole('heading', { name: 'Install manually' })
).toBeVisible()
await expect(agentHeading).toBeVisible()
await setup.getByRole('tab', { name: 'Claude Desktop' }).click()
await expect(activePanel).toContainText('Add custom connector')
await setup.getByRole('tab', { name: 'Codex' }).click()
await expect(activePanel).toContainText(
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
)
await expect(agentHeading).toHaveCount(0)
await expect(setup.locator('video')).toBeVisible()
await setup.getByRole('tab', { name: 'Cursor' }).click()
await expect(activePanel).toContainText('X-API-Key')
await expect(
activePanel.getByRole('link', { name: 'platform.comfy.org' })
).toHaveAttribute('href', 'https://platform.comfy.org/profile/api-keys')
await expect(agentHeading).toBeVisible()
await setup.getByRole('tab', { name: 'Codex' }).click()
await setup.getByRole('tab', { name: 'OpenClaw' }).click()
await expect(activePanel).toContainText(
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
'openclaw skills install @comfy-org/comfy'
)
await expect(agentHeading).toBeVisible()
await setup.getByRole('tab', { name: 'Others' }).click()
await expect(activePanel).toContainText('remote MCP server')
await expect(agentHeading).toBeVisible()
})
test('skills plugin link lives in the agent option card', async ({
@@ -66,6 +103,7 @@ test.describe('MCP page @smoke', () => {
}) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await selectClientTab(setup, 'Claude Code Terminal')
await expect(
setup.getByRole('link', { name: 'View on GitHub' })
).toHaveAttribute('href', 'https://github.com/Comfy-Org/comfy-skills')
@@ -105,7 +143,10 @@ test.describe('MCP page zh-CN @smoke', () => {
await page.goto('/zh-CN/mcp')
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(setup.getByText('方式一')).toBeVisible()
await expect(
setup.getByRole('heading', { name: '添加自定义连接器' })
).toBeVisible()
await selectClientTab(setup, 'Claude Code Terminal')
await expect(setup.getByRole('heading', { name: '手动安装' })).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
})

View File

@@ -33,6 +33,7 @@ const {
minimal = false,
hideControls = false,
fit = 'cover',
ariaLabel,
class: className
} = defineProps<{
locale?: Locale
@@ -44,6 +45,7 @@ const {
minimal?: boolean
hideControls?: boolean
fit?: 'cover' | 'contain'
ariaLabel?: string
class?: HTMLAttributes['class']
}>()
@@ -93,6 +95,28 @@ watch(videoEl, syncNativeDuration)
useEventListener(videoEl, 'loadedmetadata', syncNativeDuration)
useEventListener(videoEl, 'durationchange', syncNativeDuration)
// The muted attribute only sets defaultMuted, so SSR-rendered autoplay
// videos count as unmuted and get blocked; force the property and kick
// playback. Scoped to hideControls (decorative) clips so chrome-visible
// consumers keep native semantics. flush: 'post' guarantees this runs
// after useMediaControls' internal muted watcher on the same source.
watch(
[videoEl, () => src],
([el]) => {
if (!el || !autoplay || !hideControls) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
el.pause()
return
}
el.muted = true
el.play().catch((error: unknown) => {
if (error instanceof Error && error.name === 'AbortError') return
console.warn('VideoPlayer autoplay failed', error)
})
},
{ flush: 'post' }
)
const effectiveDuration = computed(() => duration.value || nativeDuration.value)
// Scrubber (modeled after VueUse demo Scrubber.vue)
@@ -207,6 +231,7 @@ function toggleFullscreen() {
<video
v-if="src"
ref="videoEl"
:aria-label="ariaLabel"
:class="
cn('size-full', fit === 'contain' ? 'object-contain' : 'object-cover')
"

View File

@@ -1919,42 +1919,48 @@ const translations = {
'zh-CN': '配置 Comfy MCP'
},
'mcp.setup.subtitle': {
en: 'Two ways to connect: ask your agent to install it, or add the server yourself. Sign in once, and the full ComfyUI toolset is available right in your chat.',
en: 'Two ways to connect: add the server yourself, or ask your agent to install it. Sign in once, and the full ComfyUI toolset is available right in your chat.',
'zh-CN':
'两种接入方式:让你的智能体自动安装,或自行添加服务器。登录一次ComfyUI 全套工具即可直接在对话中使用。'
'两种接入方式:自行添加服务器,或让你的智能体自动安装。登录一次ComfyUI 全套工具即可直接在对话中使用。'
},
'mcp.setup.option1.label': { en: 'OPTION 1', 'zh-CN': '方式一' },
'mcp.setup.option1.title': {
en: 'Ask your agent to install Comfy MCP',
'zh-CN': '让你的智能体安装 Comfy MCP'
},
'mcp.setup.option1.command': {
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
},
'mcp.setup.option1.description': {
en: 'Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
'zh-CN':
'将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
},
'mcp.setup.option2.label': { en: 'OPTION 2', 'zh-CN': '方式二' },
'mcp.setup.option2.title': {
'mcp.setup.manual.title': {
en: 'Install manually',
'zh-CN': '手动安装'
},
'mcp.setup.option2.description': {
en: 'Prefer manual setup? Add this URL as a custom connector or remote MCP server in your client, then sign in when prompted.',
'mcp.setup.manual.description': {
en: 'Add this URL as a custom connector or remote MCP server in your client, then sign in when prompted.',
'zh-CN':
'想手动配置?将此 URL 添加为客户端的自定义连接器或远程 MCP 服务器,然后按提示登录。'
'将此 URL 添加为客户端的自定义连接器或远程 MCP 服务器,然后按提示登录。'
},
'mcp.setup.option2.tabsLabel': {
'mcp.setup.manual.tabsLabel': {
en: 'Pick your client',
'zh-CN': '选择你的客户端'
},
'mcp.setup.agent.title': {
en: 'Ask your agent to install Comfy MCP',
'zh-CN': '让你的智能体安装 Comfy MCP'
},
'mcp.setup.agent.command': {
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
},
'mcp.setup.agent.description': {
en: 'Prefer to let your agent do it? Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
'zh-CN':
'想让智能体代劳?将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
},
'mcp.setup.clients.claudeCode.step': {
en: 'Run this in your terminal, then use /mcp to pick comfy-cloud and authenticate.',
'zh-CN': '在终端运行以下命令,然后通过 /mcp 选择 comfy-cloud 并完成认证。'
},
'mcp.setup.walkthroughAlt': {
en: '{client} setup walkthrough',
'zh-CN': '{client} 设置演示'
},
'mcp.setup.clients.claudeDesktop.manualTitle': {
en: 'Add Custom Connector',
'zh-CN': '添加自定义连接器'
},
'mcp.setup.clients.claudeDesktop.step': {
en: 'Click Customize in the sidebar, open Connectors, choose Add custom connector, paste the URL above, and sign in.',
'zh-CN':
@@ -1973,9 +1979,13 @@ const translations = {
en: 'Run this in your terminal, then codex mcp login comfy-cloud to sign in.',
'zh-CN': '在终端运行以下命令,然后执行 codex mcp login comfy-cloud 登录。'
},
'mcp.setup.clients.openclaw.step': {
en: 'Run these in your terminal, then openclaw mcp login comfy to sign in.',
'zh-CN': '在终端运行以下命令,然后执行 openclaw mcp login comfy 登录。'
},
'mcp.setup.clients.other.name': {
en: 'Other clients',
'zh-CN': '其他客户端'
en: 'Others',
'zh-CN': '其他'
},
'mcp.setup.clients.other.step': {
en: 'Add the URL above as a remote MCP server. No OAuth in your client? Use an X-API-Key header instead. Full walkthroughs live in the ',
@@ -2190,9 +2200,9 @@ const translations = {
'zh-CN': '我需要 API 密钥吗?'
},
'mcp.faq.3.a': {
en: 'Not for Claude Code, Claude Desktop, or Codex. You need a Comfy API key for Cursor, Hermes, and OpenClaw for now. Just copy https://docs.comfy.org/agent-tools/cloud and your agent will figure out the installation for you.',
en: 'Not for Claude Code, Claude Desktop, Codex, or OpenClaw. You need a Comfy API key for Cursor and Hermes for now. Just copy https://docs.comfy.org/agent-tools/cloud and your agent will figure out the installation for you.',
'zh-CN':
'Claude Code、Claude DesktopCodex 不需要。CursorHermes 和 OpenClaw 目前需要 Comfy API 密钥。只需复制 https://docs.comfy.org/agent-tools/cloud你的智能体就会为你完成安装。'
'Claude Code、Claude DesktopCodex 和 OpenClaw 不需要。CursorHermes 目前需要 Comfy API 密钥。只需复制 https://docs.comfy.org/agent-tools/cloud你的智能体就会为你完成安装。'
},
'mcp.faq.4.q': {
en: 'Does it cost anything?',

View File

@@ -78,3 +78,28 @@ describe('captureDownloadClick', () => {
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
})
describe('captureMcpClientTabClick', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('captures the tab click with the client id', async () => {
const { initPostHog, captureMcpClientTabClick } = await import('./posthog')
initPostHog()
captureMcpClientTabClick('claude-code')
expect(hoisted.mockCapture).toHaveBeenCalledWith(
'website:mcp_client_tab_clicked',
{ client: 'claude-code' }
)
})
it('does not capture before PostHog is initialized', async () => {
const { captureMcpClientTabClick } = await import('./posthog')
captureMcpClientTabClick('cursor')
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
})

View File

@@ -49,3 +49,12 @@ export function captureDownloadClick(platform: Platform) {
console.error('PostHog download click capture failed', error)
}
}
export function captureMcpClientTabClick(client: string) {
if (!initialized) return
try {
posthog.capture('website:mcp_client_tab_clicked', { client })
} catch (error) {
console.error('PostHog MCP client tab capture failed', error)
}
}

View File

@@ -1,16 +1,18 @@
<script setup lang="ts">
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { computed, ref } from 'vue'
import SectionHeader from '../../components/common/SectionHeader.vue'
import SectionLabel from '../../components/common/SectionLabel.vue'
import VideoPlayer from '../../components/common/VideoPlayer.vue'
import CopyableField from '../../components/ui/copyable-field/CopyableField.vue'
import { externalLinks } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { captureMcpClientTabClick } from '../../scripts/posthog'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const agentCommand = t('mcp.setup.option1.command', locale).replace(
const agentCommand = t('mcp.setup.agent.command', locale).replace(
'{url}',
externalLinks.docsMcp
)
@@ -21,19 +23,35 @@ interface McpClient {
step: string
command?: string
link?: { label: string; href: string }
manualTitle?: string
showAgentCard: boolean
// Walkthrough clip shown in place of the agent card (source: docs.comfy.org/agent-tools/mcp)
video?: string
}
const clients: McpClient[] = [
{
id: 'claude-code',
name: 'Claude Code',
step: t('mcp.setup.clients.claudeCode.step', locale),
command: `claude mcp add --transport http comfy-cloud ${externalLinks.mcpEndpoint}`
},
{
id: 'claude-desktop',
name: 'Claude Desktop',
step: t('mcp.setup.clients.claudeDesktop.step', locale)
step: t('mcp.setup.clients.claudeDesktop.step', locale),
manualTitle: t('mcp.setup.clients.claudeDesktop.manualTitle', locale),
showAgentCard: false,
video: '/videos/mcp/setup-claude-desktop-v1.mp4'
},
{
id: 'claude-code',
name: 'Claude Code Terminal',
step: t('mcp.setup.clients.claudeCode.step', locale),
command: `claude mcp add --transport http comfy-cloud ${externalLinks.mcpEndpoint}`,
showAgentCard: true
},
{
id: 'codex',
name: 'Codex',
step: t('mcp.setup.clients.codex.step', locale),
command: `codex mcp add comfy-cloud --url ${externalLinks.mcpEndpoint}`,
showAgentCard: false,
video: '/videos/mcp/setup-codex-oauth-v1.mp4'
},
{
id: 'cursor',
@@ -42,13 +60,15 @@ const clients: McpClient[] = [
link: {
label: t('mcp.setup.clients.cursor.linkLabel', locale),
href: externalLinks.apiKeys
}
},
showAgentCard: true
},
{
id: 'codex',
name: 'Codex',
step: t('mcp.setup.clients.codex.step', locale),
command: `codex mcp add comfy-cloud --url ${externalLinks.mcpEndpoint}`
id: 'openclaw',
name: 'OpenClaw',
step: t('mcp.setup.clients.openclaw.step', locale),
command: `openclaw skills install @comfy-org/comfy\nopenclaw mcp set comfy '{"url":"${externalLinks.mcpEndpoint}","transport":"streamable-http","auth":"oauth"}'`,
showAgentCard: true
},
{
id: 'other',
@@ -57,10 +77,38 @@ const clients: McpClient[] = [
link: {
label: t('mcp.setup.clients.other.linkLabel', locale),
href: externalLinks.docsMcp
}
},
showAgentCard: true
}
]
const activeClientId = ref(clients[0].id)
const activeClient = computed(
() =>
clients.find((client) => client.id === activeClientId.value) ?? clients[0]
)
const manualTitle = computed(
() => activeClient.value.manualTitle ?? t('mcp.setup.manual.title', locale)
)
// reka-ui re-emits update:modelValue even when the value is unchanged
// (re-clicking the active tab), so dedupe before capturing.
let lastTrackedClientId: string | undefined
function onClientTabChange(value: string | number | undefined) {
if (!value) return
const id = String(value)
if (id === lastTrackedClientId) return
lastTrackedClientId = id
captureMcpClientTabClick(id)
}
const walkthroughLabel = computed(() =>
t('mcp.setup.walkthroughAlt', locale).replace(
'{client}',
activeClient.value.name
)
)
const copyLabel = t('ui.copy', locale)
const copiedLabel = t('ui.copied', locale)
</script>
@@ -83,77 +131,48 @@ const copiedLabel = t('ui.copied', locale)
</template>
</SectionHeader>
<div class="mt-16 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
<TabsRoot
v-model="activeClientId"
activation-mode="manual"
class="mt-10 block"
@update:model-value="onClientTabChange"
>
<TabsList
:aria-label="t('mcp.setup.manual.tabsLabel', locale)"
class="grid grid-cols-1 gap-px rounded-2xl border border-white/15 bg-primary-comfy-ink p-1 min-[360px]:grid-cols-2 lg:inline-flex lg:flex-nowrap"
>
<SectionLabel>{{ t('mcp.setup.option1.label', locale) }}</SectionLabel>
<h3
class="mt-3 text-xl font-light text-primary-comfy-canvas lg:text-2xl"
<TabsTrigger
v-for="client in clients"
:key="client.id"
:value="client.id"
class="focus-visible:ring-primary-comfy-yellow/50 data-[state=active]:bg-primary-comfy-yellow shrink-0 cursor-pointer rounded-lg bg-white/8 px-2 py-2.5 text-[10px] font-bold tracking-wider whitespace-nowrap text-smoke-700 uppercase transition-colors hover:text-primary-comfy-canvas focus-visible:ring-2 focus-visible:outline-none data-[state=active]:text-primary-comfy-ink lg:rounded-none lg:px-6 lg:text-xs lg:first:rounded-l-xl lg:last:rounded-r-xl"
>
{{ t('mcp.setup.option1.title', locale) }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ t('mcp.setup.option1.description', locale) }}
</p>
<div class="mt-6">
<CopyableField
:value="agentCommand"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
<p class="mt-auto pt-6 text-sm text-smoke-700">
{{ t('mcp.setup.skillsNote', locale)
}}<a
:href="externalLinks.mcpSkills"
target="_blank"
rel="noopener noreferrer"
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
>{{ t('mcp.setup.skillsLink', locale) }}</a
>
</p>
</div>
{{ client.name }}
</TabsTrigger>
</TabsList>
<div
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
<SectionLabel>{{ t('mcp.setup.option2.label', locale) }}</SectionLabel>
<h3
class="mt-3 text-xl font-light text-primary-comfy-canvas lg:text-2xl"
<div class="mt-10 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
{{ t('mcp.setup.option2.title', locale) }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ t('mcp.setup.option2.description', locale) }}
</p>
<div class="mt-6">
<CopyableField
:value="externalLinks.mcpEndpoint"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
<TabsRoot default-value="claude-code" class="mt-6">
<TabsList
:aria-label="t('mcp.setup.option2.tabsLabel', locale)"
class="flex flex-wrap gap-2"
>
<TabsTrigger
v-for="client in clients"
:key="client.id"
:value="client.id"
class="bg-transparency-white-t4 focus-visible:ring-primary-comfy-yellow/50 data-[state=active]:bg-primary-comfy-yellow cursor-pointer rounded-full px-4 py-2 text-xs font-bold tracking-wider text-smoke-700 uppercase transition-colors hover:text-primary-comfy-canvas focus-visible:ring-2 focus-visible:outline-none data-[state=active]:text-primary-comfy-ink"
>
{{ client.name }}
</TabsTrigger>
</TabsList>
<h3 class="text-xl font-light text-primary-comfy-canvas lg:text-2xl">
{{ manualTitle }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ t('mcp.setup.manual.description', locale) }}
</p>
<div class="mt-6">
<CopyableField
:value="externalLinks.mcpEndpoint"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
<TabsContent
v-for="client in clients"
:key="client.id"
:value="client.id"
class="mt-4 flex min-h-24 flex-col gap-3"
class="mt-6 flex min-h-36 flex-col gap-3"
>
<p class="text-sm text-smoke-700">
{{ client.step
@@ -173,8 +192,57 @@ const copiedLabel = t('ui.copied', locale)
:copied-label="copiedLabel"
/>
</TabsContent>
</TabsRoot>
</div>
<div
class="bg-transparency-white-t4 flex flex-col rounded-3xl"
:class="
activeClient.showAgentCard
? 'p-6 lg:p-8'
: 'relative overflow-hidden max-lg:aspect-video'
"
>
<template v-if="activeClient.showAgentCard">
<h3
class="text-xl font-light text-primary-comfy-canvas lg:text-2xl"
>
{{ t('mcp.setup.agent.title', locale) }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ t('mcp.setup.agent.description', locale) }}
</p>
<div class="mt-6">
<CopyableField
:value="agentCommand"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
<p class="mt-6 text-sm text-smoke-700">
{{ t('mcp.setup.skillsNote', locale)
}}<a
:href="externalLinks.mcpSkills"
target="_blank"
rel="noopener noreferrer"
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
>{{ t('mcp.setup.skillsLink', locale) }}</a
>
</p>
</template>
<VideoPlayer
v-else-if="activeClient.video"
:key="activeClient.id"
:locale="locale"
:aria-label="walkthroughLabel"
:src="activeClient.video"
autoplay
loop
hide-controls
fit="contain"
class="absolute inset-0 size-full bg-transparent"
/>
</div>
</div>
</div>
</TabsRoot>
</section>
</template>

View File

@@ -54,6 +54,11 @@
"source": "/press",
"destination": "/about",
"permanent": true
},
{
"source": "/login",
"destination": "https://cloud.comfy.org/login",
"permanent": false
}
]
}

View File

@@ -8,11 +8,13 @@ export class ComfyActionbar {
public readonly root: Locator
public readonly queueButton: ComfyQueueButton
public readonly propertiesButton: Locator
public readonly dragHandle: Locator
constructor(public readonly page: Page) {
this.root = page.locator('.actionbar-container')
this.queueButton = new ComfyQueueButton(this)
this.propertiesButton = this.root.getByLabel('Toggle properties panel')
this.dragHandle = this.root.locator('.drag-handle')
}
async isDocked() {

View File

@@ -0,0 +1,21 @@
import type { Locator } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
export class FreeTierQuota {
readonly root: Locator
constructor(comfyPage: ComfyPage) {
this.root = comfyPage.page.getByTestId(TestIds.topbar.freeTierQuota)
}
async getMax() {
const text = await this.root.textContent()
return text?.match(/(\d+) \/ (\d+)/)?.[2]
}
async getAvailable() {
const text = await this.root.textContent()
return text?.match(/(\d+) \/ (\d+)/)?.[1]
}
}

View File

@@ -58,6 +58,17 @@ class MaskEditorHelper {
return dialog
}
async reopenDialog(): Promise<Locator> {
const imagePreview = this.page.locator('.image-preview').first()
await imagePreview.getByRole('region').hover()
await this.page.getByLabel('Edit or mask image').click()
const dialog = this.page.locator('.mask-editor-dialog')
await expect(dialog).toBeVisible()
return dialog
}
async drawStrokeOnPointerZone(dialog: Locator) {
const pointerZone = dialog.getByTestId('pointer-zone')
await expect(pointerZone).toBeVisible()

View File

@@ -0,0 +1,99 @@
import type { Page } from '@playwright/test'
import type { ListAssetsResponse } from '@comfyorg/ingest-types'
import type { PartnerNodePolicyResponse } from '@/platform/workspace/api/partnerNodePolicyApi'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { PromptResponse } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { comfyPageFixture as base } from '@e2e/fixtures/ComfyPage'
import { WORKSPACE_FEATURE_FLAG } from '@e2e/fixtures/data/cloudWorkspace'
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
const DISABLED_NODE = 'DisabledPartnerNode'
interface PartnerNodeGovernanceFixture {
promptRequestCount: () => number
}
function disabledPartnerNode(): ComfyNodeDef {
return {
name: DISABLED_NODE,
display_name: 'Disabled Partner Node',
category: 'partner/image/Acme',
python_module: 'comfy_api_nodes.acme',
description: '',
input: {},
output: [],
output_is_list: [],
output_name: [],
output_node: true,
api_node: true
}
}
async function setupGovernedWorkspace(page: Page) {
await new CloudWorkspaceMockHelper(page).setup()
await page.route('**/api/features', (route) =>
route.fulfill(
jsonRoute({
...WORKSPACE_FEATURE_FLAG,
partner_node_governance_enabled: true
} satisfies RemoteConfig)
)
)
await page.route('**/api/object_info', (route) =>
route.fulfill(jsonRoute({ [DISABLED_NODE]: disabledPartnerNode() }))
)
await page.route(/\/api\/assets(?:\?.*)?$/, (route) =>
route.fulfill(
jsonRoute({
assets: [],
total: 0,
has_more: false
} satisfies ListAssetsResponse)
)
)
await page.route('**/api/workspace/partner-node-policy', (route) =>
route.fulfill(
jsonRoute({
enforcement_enabled: true,
nodes: { [DISABLED_NODE]: false }
} satisfies PartnerNodePolicyResponse)
)
)
}
export const partnerNodeGovernanceTest = base.extend<{
partnerNodeGovernance: PartnerNodeGovernanceFixture
}>({
partnerNodeGovernance: async ({ page }, use) => {
await setupGovernedWorkspace(page)
let promptRequests = 0
await page.route('**/api/prompt', (route) => {
promptRequests++
return route.fulfill(
jsonRoute({
prompt_id: 'unexpected-prompt',
node_errors: {},
error: ''
} satisfies PromptResponse)
)
})
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await page.evaluate((nodeType) => {
const node = window.LiteGraph!.createNode(nodeType)
if (!node) throw new Error(`Failed to create ${nodeType}`)
window.app!.rootGraph.add(node)
}, DISABLED_NODE)
await use({ promptRequestCount: () => promptRequests })
}
})

View File

@@ -103,7 +103,8 @@ export const TestIds = {
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
workflowTabs: 'topbar-workflow-tabs',
integratedTabBarActions: 'integrated-tab-bar-actions',
actionBarButtons: 'action-bar-buttons'
actionBarButtons: 'action-bar-buttons',
freeTierQuota: 'free-tier-quota'
},
nodeLibrary: {
bookmarksSection: 'node-library-bookmarks-section'

View File

@@ -0,0 +1,126 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { ListAssetsResponse } from '@comfyorg/ingest-types'
import type { PartnerNodePolicyResponse } from '@/platform/workspace/api/partnerNodePolicyApi'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { WORKSPACE_FEATURE_FLAG } from '@e2e/fixtures/data/cloudWorkspace'
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
function partnerNode(
name: string,
displayName: string,
provider: string
): ComfyNodeDef {
return {
name,
display_name: displayName,
category: `partner/image/${provider}`,
python_module: `comfy_api_nodes.${provider.toLocaleLowerCase()}`,
description: '',
input: {},
output: [],
output_is_list: [],
output_name: [],
output_node: false,
api_node: true
}
}
async function openAllowlist(page: Page) {
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: 'Allowlist' }).click()
await expect(
content.getByRole('heading', { name: 'Partner nodes' })
).toBeVisible()
return content
}
test.describe('Partner node allowlist', { tag: '@cloud' }, () => {
test.describe.configure({ timeout: 60_000 })
test('saves enforcement and allowlist as one policy', async ({ page }) => {
await new CloudWorkspaceMockHelper(page).setup()
await page.route('**/api/features', (route) =>
route.fulfill(
jsonRoute({
...WORKSPACE_FEATURE_FLAG,
partner_node_governance_enabled: true
} satisfies RemoteConfig)
)
)
await page.route('**/api/object_info', (route) =>
route.fulfill(
jsonRoute({
FluxFill: partnerNode('FluxFill', 'Flux Fill', 'BFL'),
FluxExpand: partnerNode('FluxExpand', 'Flux Expand', 'BFL'),
VeoVideo: partnerNode('VeoVideo', 'Veo Video', 'Google')
})
)
)
await page.route(/\/api\/assets(?:\?.*)?$/, (route) =>
route.fulfill(
jsonRoute({
assets: [],
total: 0,
has_more: false
} satisfies ListAssetsResponse)
)
)
let policy: PartnerNodePolicyResponse = {
enforcement_enabled: false,
nodes: { FluxFill: false, FluxExpand: true, VeoVideo: true }
} satisfies PartnerNodePolicyResponse
const updates: unknown[] = []
await page.route('**/api/workspace/partner-node-policy', (route) => {
if (route.request().method() === 'PUT') {
policy = route.request().postDataJSON() as PartnerNodePolicyResponse
updates.push(policy)
}
return route.fulfill(jsonRoute(policy))
})
const content = await openAllowlist(page)
const fluxFill = content.getByRole('switch', { name: 'Allow Flux Fill' })
await expect(fluxFill).not.toBeChecked()
await expect(
content.getByRole('switch', { name: 'Allow Flux Expand' })
).toBeChecked()
await content
.getByRole('switch', { name: 'Enforce partner node allowlist' })
.click()
await fluxFill.click()
await content.getByRole('button', { name: 'Save' }).click()
await expect(page.getByText('Partner node policy saved')).toBeVisible()
expect(updates).toEqual([
{
enforcement_enabled: true,
nodes: { FluxFill: true, FluxExpand: true, VeoVideo: true }
}
] satisfies PartnerNodePolicyResponse[])
})
})

View File

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

View File

@@ -0,0 +1,63 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
import { FreeTierQuota } from '@e2e/fixtures/components/FreeTierQuota'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
test.describe('Free Tier Quota', { tag: ['@cloud', '@vue-nodes'] }, () => {
test.beforeEach(async ({ page }) => {
const features = {
free_tier_job_allowance_enabled: true,
free_tier_balance: { allowance: 5, remaining: 3, used: 0 }
}
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(features)))
})
wstest('Free Tier Quota', async ({ comfyPage, comfyMouse, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
const freeTierQuota = new FreeTierQuota(comfyPage)
await test.step('Populates initial state from config', async () => {
await expect.poll(() => freeTierQuota.getAvailable()).toBe('3')
expect(await freeTierQuota.getMax()).toBe('5')
})
await test.step('available decrements on run', async () => {
await execution.run()
await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
})
await test.step('connects to detached run button', async () => {
const handle = comfyPage.actionbar.dragHandle
await comfyMouse.dragElementBy(handle, { x: -100, y: 100 })
await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(false)
expect(await freeTierQuota.getAvailable()).toBe('2')
await comfyMouse.dragElementBy(handle, { x: 100, y: -100 })
await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(true)
})
await test.step('Detects workflows with Partner nodes', async () => {
await comfyPage.searchBoxV2.addNode('Node With Price Badge')
const node = await comfyPage.vueNodes.getFixtureByTitle('Price Badge')
await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
await node.delete()
await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
})
await test.step('Does not decrease past 0', async () => {
await execution.run()
await expect.poll(() => freeTierQuota.getAvailable()).toBe('1')
await execution.run()
await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
await execution.run()
await execution.run()
await execution.run()
await comfyPage.nextFrame()
expect(await freeTierQuota.getAvailable()).toBe(undefined)
})
})
})

View File

@@ -67,6 +67,22 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
await expect(dialog).toBeVisible()
})
test('Reopening the editor after save restores the drawn mask', async ({
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeAndExpectPixels(dialog)
const savedMask = await maskEditor.getCanvasSnapshot(MASK_CANVAS_INDEX)
await dialog.getByRole('button', { name: 'Save' }).click()
await expect(dialog).toBeHidden()
await maskEditor.reopenDialog()
await expect
.poll(() => maskEditor.getCanvasSnapshot(MASK_CANVAS_INDEX))
.toBe(savedMask)
})
test('Save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeAndExpectPixels(dialog)

View File

@@ -0,0 +1,97 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { ListAssetsResponse } from '@comfyorg/ingest-types'
import type { PartnerNodePolicyResponse } from '@/platform/workspace/api/partnerNodePolicyApi'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { WORKSPACE_FEATURE_FLAG } from '@e2e/fixtures/data/cloudWorkspace'
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
function partnerNode(name: string): ComfyNodeDef {
return {
name,
display_name: name,
category: 'partner/image/Acme',
python_module: 'comfy_api_nodes.acme',
description: '',
input: {},
output: [],
output_is_list: [],
output_name: [],
output_node: false,
api_node: true
}
}
async function setupGovernedWorkspace(page: Page) {
await new CloudWorkspaceMockHelper(page).setup()
await page.route('**/api/features', (route) =>
route.fulfill(
jsonRoute({
...WORKSPACE_FEATURE_FLAG,
partner_node_governance_enabled: true
} satisfies RemoteConfig)
)
)
await page.route('**/api/object_info', (route) =>
route.fulfill(
jsonRoute({
AllowedPartnerNode: partnerNode('AllowedPartnerNode'),
DisabledPartnerNode: partnerNode('DisabledPartnerNode')
})
)
)
await page.route(/\/api\/assets(?:\?.*)?$/, (route) =>
route.fulfill(
jsonRoute({
assets: [],
total: 0,
has_more: false
} satisfies ListAssetsResponse)
)
)
await page.route('**/api/workspace/partner-node-policy', (route) =>
route.fulfill(
jsonRoute({
enforcement_enabled: true,
nodes: {
AllowedPartnerNode: true,
DisabledPartnerNode: false
}
} satisfies PartnerNodePolicyResponse)
)
)
}
test.describe('Partner node governance discovery', { tag: '@cloud' }, () => {
test('hides disabled nodes from search', async ({ page }) => {
await setupGovernedWorkspace(page)
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await page.evaluate(async () => {
await window.app!.extensionManager.setting.set(
'Comfy.NodeSearchBoxImpl',
'default'
)
await window.app!.extensionManager.command.execute(
'Workspace.SearchBox.Toggle'
)
})
const search = page.getByRole('search')
await expect(search).toBeVisible()
await search.getByRole('combobox').fill('PartnerNode')
await expect(search.getByText('AllowedPartnerNode')).toBeVisible()
await expect(search.getByText('DisabledPartnerNode')).toHaveCount(0)
})
})

View File

@@ -0,0 +1,18 @@
import { expect } from '@playwright/test'
import { partnerNodeGovernanceTest as test } from '@e2e/fixtures/partnerNodeGovernanceFixture'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Partner node governance workbench', { tag: '@cloud' }, () => {
test('blocks queueing a disabled partner node', async ({
page,
partnerNodeGovernance
}) => {
await page.getByTestId(TestIds.topbar.queueButton).click()
await expect(page.getByRole('alert')).toContainText(
'Workflow blocked by workspace policy'
)
expect(partnerNodeGovernance.promptRequestCount()).toBe(0)
})
})

View File

@@ -29,6 +29,8 @@
"dev:test": "cross-env VITE_USE_LEGACY_DEFAULT_GRAPH=true vite --config vite.config.mts",
"dev": "vite --config vite.config.mts",
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
"fallow": "fallow",
"fallow:audit": "fallow audit",
"format:check": "oxfmt --check",
"format": "oxfmt --write",
"json-schema": "tsx scripts/generate-json-schema.ts",
@@ -172,6 +174,7 @@
"eslint-plugin-testing-library": "catalog:",
"eslint-plugin-unused-imports": "catalog:",
"eslint-plugin-vue": "catalog:",
"fallow": "catalog:",
"fast-check": "catalog:",
"fs-extra": "^11.2.0",
"globals": "catalog:",

88
pnpm-lock.yaml generated
View File

@@ -240,6 +240,9 @@ catalogs:
eslint-plugin-vue:
specifier: ^10.9.1
version: 10.9.1
fallow:
specifier: ^2.102.0
version: 2.102.0
fast-check:
specifier: ^4.5.3
version: 4.5.3
@@ -763,6 +766,9 @@ importers:
eslint-plugin-vue:
specifier: 'catalog:'
version: 10.9.1(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.4.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.4.0(jiti@2.6.1)))
fallow:
specifier: 'catalog:'
version: 2.102.0
fast-check:
specifier: 'catalog:'
version: 4.5.3
@@ -1908,6 +1914,46 @@ packages:
'@exodus/crypto':
optional: true
'@fallow-cli/darwin-arm64@2.102.0':
resolution: {integrity: sha512-B8wzfzJgoX6h5Gv2xQ9ZidO5Jb8/PWdssAxYbWs1pb5oJHZ6S5PLwXuUdINmNSIaRGQwTk4DC9/tIMFHFvd9uw==}
cpu: [arm64]
os: [darwin]
'@fallow-cli/darwin-x64@2.102.0':
resolution: {integrity: sha512-aLbTWWzQnleKdi56obAPXMJm7YA3qAnkIX9T3eocRHiagYqp8nsf4cslM0rZKvu2WwK34NaBm8x886gl4cl+zg==}
cpu: [x64]
os: [darwin]
'@fallow-cli/linux-arm64-gnu@2.102.0':
resolution: {integrity: sha512-8nYeOSLSewqcKH/KUcKZaCq5QII5VTRX62l60B6UM1iKe/0jcmlQ2mOtx4rkHpeq3LL5emvT/ph4NNgGmWKSBg==}
cpu: [arm64]
os: [linux]
'@fallow-cli/linux-arm64-musl@2.102.0':
resolution: {integrity: sha512-2Zi33PXzZxD7HU5sPVwyElZGP7zyEdGo4hK0ewy6gMYgQ9BDfLnFhgaSSOzN1J4paIhYtBmVsLmqakyDKy22Jw==}
cpu: [arm64]
os: [linux]
'@fallow-cli/linux-x64-gnu@2.102.0':
resolution: {integrity: sha512-7Hys4X6hKuR/lqUaGXwezRzDrwXwu9KfahUy85WTuiG1to+ZbzDCqdbZ04LtnI8kK8ufrPDcq+ZXdyt5ksOJHA==}
cpu: [x64]
os: [linux]
'@fallow-cli/linux-x64-musl@2.102.0':
resolution: {integrity: sha512-RuDY1jOEPgJOuHBgEpHVl6J7Xf2QLFklnNy6zO1nw8R1fLgWUAAKlFirn1Y93pb9bXzqpn5Gre3YlhgIZ3+LBA==}
cpu: [x64]
os: [linux]
'@fallow-cli/win32-arm64-msvc@2.102.0':
resolution: {integrity: sha512-wsvHjLzWFvsYmCqnQLm1doSEHb9Z038+sFp1RzMcffPUBC5tqS3vCr8J8nolcLlPk7o3QWHQMXAqb1xSJe+doA==}
cpu: [arm64]
os: [win32]
'@fallow-cli/win32-x64-msvc@2.102.0':
resolution: {integrity: sha512-rH1hd0PD0mm6pCxh1pw5jubpJsvV6f5rjixMoD5AZLzWa6NPJBpPPuzruLGtH/9CYy8B0y7zPrGGTKRO2PCGzg==}
cpu: [x64]
os: [win32]
'@firebase/analytics-compat@0.2.18':
resolution: {integrity: sha512-Hw9mzsSMZaQu6wrTbi3kYYwGw9nBqOHr47pVLxfr5v8CalsdrG5gfs9XUlPOZjHRVISp3oQrh1j7d3E+ulHPjQ==}
peerDependencies:
@@ -5682,6 +5728,11 @@ packages:
extendable-media-recorder@9.2.27:
resolution: {integrity: sha512-2X+Ixi1cxLek0Cj9x9atmhQ+apG+LwJpP2p3ypP8Pxau0poDnicrg7FTfPVQV5PW/3DHFm/eQ16vbgo5Yk3HGQ==}
fallow@2.102.0:
resolution: {integrity: sha512-bkOT58kPVCB12d2apQjIKBw/qSdsGRPQFrN5ff9Yl5WzXRqlDTbT/MVdMXld4sJD5JQW1ftw2bTxJWCINggh6g==}
engines: {node: '>=16'}
hasBin: true
fast-check@4.5.3:
resolution: {integrity: sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==}
engines: {node: '>=12.17.0'}
@@ -10044,6 +10095,30 @@ snapshots:
'@exodus/bytes@1.7.0': {}
'@fallow-cli/darwin-arm64@2.102.0':
optional: true
'@fallow-cli/darwin-x64@2.102.0':
optional: true
'@fallow-cli/linux-arm64-gnu@2.102.0':
optional: true
'@fallow-cli/linux-arm64-musl@2.102.0':
optional: true
'@fallow-cli/linux-x64-gnu@2.102.0':
optional: true
'@fallow-cli/linux-x64-musl@2.102.0':
optional: true
'@fallow-cli/win32-arm64-msvc@2.102.0':
optional: true
'@fallow-cli/win32-x64-msvc@2.102.0':
optional: true
'@firebase/analytics-compat@0.2.18(@firebase/app-compat@0.2.53)(@firebase/app@0.11.4)':
dependencies:
'@firebase/analytics': 0.10.12(@firebase/app@0.11.4)
@@ -14095,6 +14170,19 @@ snapshots:
subscribable-things: 2.1.53
tslib: 2.8.1
fallow@2.102.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
'@fallow-cli/darwin-arm64': 2.102.0
'@fallow-cli/darwin-x64': 2.102.0
'@fallow-cli/linux-arm64-gnu': 2.102.0
'@fallow-cli/linux-arm64-musl': 2.102.0
'@fallow-cli/linux-x64-gnu': 2.102.0
'@fallow-cli/linux-x64-musl': 2.102.0
'@fallow-cli/win32-arm64-msvc': 2.102.0
'@fallow-cli/win32-x64-msvc': 2.102.0
fast-check@4.5.3:
dependencies:
pure-rand: 7.0.1

View File

@@ -89,6 +89,7 @@ catalog:
eslint-plugin-testing-library: ^7.16.1
eslint-plugin-unused-imports: ^4.4.1
eslint-plugin-vue: ^10.9.1
fallow: ^2.102.0
fast-check: ^4.5.3
firebase: ^11.6.0
glob: ^13.0.6

View File

@@ -629,7 +629,7 @@ describe('TopMenuSection', () => {
await nextTick()
expect(querySpy).toHaveBeenCalledTimes(1)
expect(actionbarContainer!.classList).toContain('px-2')
expect(actionbarContainer!.classList).not.toContain('w-0')
} finally {
unmount()
vi.unstubAllGlobals()

View File

@@ -11,7 +11,7 @@
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div class="flex items-start gap-2">
<div
v-if="managerState.shouldShowManagerButtons.value || isCloud"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
@@ -34,61 +34,75 @@
</Button>
</div>
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
class="pointer-events-auto z-1 flex flex-col rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 py-1.75 shadow-interface"
>
<div
ref="legacyCommandsContainerRef"
data-testid="legacy-topbar-container"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.bottom="shareTooltipConfig"
variant="secondary"
:aria-label="t('actionbar.shareTooltip')"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
ref="actionbarContainerRef"
:class="
cn(
'actionbar-container relative flex items-center gap-2',
isActionbarContainerEmpty &&
'-ml-2 w-0 min-w-0 border-transparent shadow-none has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
)
"
>
<i class="icon-[comfy--send] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
</Button>
<div v-if="!isRightSidePanelOpen" class="relative">
<Button
v-tooltip.bottom="rightSidePanelTooltipConfig"
:class="
cn(
showErrorIndicatorOnPanelButton &&
'outline-1 outline-destructive-background'
)
"
variant="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="openRightSidePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
<StatusBadge
v-if="showErrorIndicatorOnPanelButton"
variant="dot"
severity="danger"
class="absolute -top-1 -right-1"
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
data-testid="legacy-topbar-container"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.bottom="shareTooltipConfig"
variant="secondary"
:aria-label="t('actionbar.shareTooltip')"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[comfy--send] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
</Button>
<div v-if="!isRightSidePanelOpen" class="relative">
<Button
v-tooltip.bottom="rightSidePanelTooltipConfig"
:class="
cn(
showErrorIndicatorOnPanelButton &&
'outline-1 outline-destructive-background'
)
"
variant="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="openRightSidePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
<StatusBadge
v-if="showErrorIndicatorOnPanelButton"
variant="dot"
severity="danger"
class="absolute -top-1 -right-1"
/>
</div>
</div>
<FreeTierQuota v-if="!isActionbarFloating" />
</div>
</div>
<ErrorOverlay />
@@ -147,6 +161,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
@@ -209,21 +224,6 @@ const hasDockedButtons = computed(() => {
const isActionbarContainerEmpty = computed(
() => isActionbarFloating.value && !hasDockedButtons.value
)
const actionbarContainerClass = computed(() => {
const base =
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
if (isActionbarContainerEmpty.value) {
return cn(
base,
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
)
}
return cn(base, 'px-2', 'border-interface-stroke')
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)

View File

@@ -75,6 +75,7 @@
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
</div>
<FreeTierQuota v-if="!isDocked" />
</Panel>
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
@@ -109,6 +110,7 @@ import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'

View File

@@ -4,11 +4,11 @@ import { nextTick, ref } from 'vue'
import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue'
const mockIsActiveSubscription = ref(true)
const mockCanRunWorkflows = ref(true)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: mockIsActiveSubscription
canRunWorkflows: mockCanRunWorkflows
})
}))
@@ -32,7 +32,7 @@ function renderWrapper() {
describe('CloudRunButtonWrapper', () => {
beforeEach(() => {
mockIsActiveSubscription.value = true
mockCanRunWorkflows.value = true
})
it('renders the runnable queue button when the subscription is active', () => {
@@ -45,7 +45,7 @@ describe('CloudRunButtonWrapper', () => {
})
it('locks the run button when the subscription is inactive', () => {
mockIsActiveSubscription.value = false
mockCanRunWorkflows.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
@@ -53,12 +53,12 @@ describe('CloudRunButtonWrapper', () => {
})
it('unlocks the run button once the subscription becomes active again', async () => {
mockIsActiveSubscription.value = false
mockCanRunWorkflows.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
mockIsActiveSubscription.value = true
mockCanRunWorkflows.value = true
await nextTick()
expect(screen.getByTestId('queue-button')).toBeInTheDocument()

View File

@@ -1,7 +1,7 @@
<template>
<component
:is="currentButton"
:key="isActiveSubscription ? 'queue' : 'subscribe'"
:key="canRunWorkflows ? 'queue' : 'subscribe'"
/>
</template>
<script setup lang="ts">
@@ -11,9 +11,9 @@ import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueBu
import { useBillingContext } from '@/composables/billing/useBillingContext'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
const { isActiveSubscription } = useBillingContext()
const { canRunWorkflows } = useBillingContext()
const currentButton = computed(() =>
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
canRunWorkflows.value ? ComfyQueueButton : SubscribeToRunButton
)
</script>

View File

@@ -1,26 +1,11 @@
import type { ComponentProps } from 'vue-component-type-helpers'
import { render, screen } from '@testing-library/vue'
import { fireEvent, render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
const useImageMock = vi.hoisted(() => ({
error: null as Ref<unknown> | null
}))
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
const { ref } = await import('vue')
useImageMock.error = ref<unknown>(null)
return {
...(actual as Record<string, unknown>),
useImage: () => ({ error: useImageMock.error })
}
})
import UserAvatar from './UserAvatar.vue'
const i18n = createI18n({
@@ -38,10 +23,6 @@ const i18n = createI18n({
})
describe('UserAvatar', () => {
beforeEach(() => {
if (useImageMock.error) useImageMock.error.value = null
})
function renderComponent(props: ComponentProps<typeof UserAvatar> = {}) {
return render(UserAvatar, {
global: {
@@ -86,10 +67,10 @@ describe('UserAvatar', () => {
photoUrl: 'https://example.com/broken-image.jpg'
})
expect(screen.getByRole('img')).toBeInTheDocument()
const img = screen.getByRole('img')
expect(screen.queryByTestId('avatar-icon')).not.toBeInTheDocument()
useImageMock.error!.value = new Event('error')
await fireEvent.error(img)
await nextTick()
expect(screen.getByTestId('avatar-icon')).toBeInTheDocument()

View File

@@ -11,24 +11,22 @@
}"
shape="circle"
:aria-label="ariaLabel ?? $t('auth.login.userAvatar')"
@error="handleImageError"
/>
</template>
<script setup lang="ts">
import { useImage } from '@vueuse/core'
import Avatar from 'primevue/avatar'
import { computed } from 'vue'
import { computed, ref } from 'vue'
const { photoUrl, ariaLabel } = defineProps<{
photoUrl?: string | null
ariaLabel?: string
}>()
const { error: imageError } = useImage(
computed(() => ({
src: photoUrl ?? '',
alt: ariaLabel ?? ''
}))
)
const imageError = ref(false)
const handleImageError = () => {
imageError.value = true
}
const hasAvatar = computed(() => photoUrl && !imageError.value)
</script>

View File

@@ -21,6 +21,8 @@ function createDefaultCropState() {
isLockEnabled: ref(false),
cropBoxStyle: ref({}),
resizeHandles: ref([]),
handleImageLoad: () => {},
handleImageError: () => {},
handleDragStart: () => {},
handleDragMove: () => {},
handleDragEnd: () => {},

View File

@@ -29,6 +29,8 @@
:alt="$t('imageCrop.cropPreviewAlt')"
draggable="false"
class="block size-full object-contain select-none"
@load="handleImageLoad"
@error="handleImageError"
@dragstart.prevent
/>
@@ -179,6 +181,8 @@ const {
cropBoxStyle,
resizeHandles,
handleImageLoad,
handleImageError,
handleDragStart,
handleDragMove,
handleDragEnd,

View File

@@ -112,5 +112,13 @@ export interface BillingContext extends BillingState, BillingActions {
* (legacy) per-member tier plan, which keeps the old team pricing table.
*/
isLegacyTeamPlan: ComputedRef<boolean>
/**
* True when the subscription is a team plan of either generation. Unlike
* `isLegacyTeamPlan` this does not require an active subscription: the spend
* gate folds billing_status into is_active, so a paused or payment-failed team
* plan reports is_active=false and must still read as a team plan.
*/
isTeamPlan: ComputedRef<boolean>
getMaxSeats: (tierKey: TierKey) => number
canRunWorkflows: ComputedRef<boolean>
}

View File

@@ -19,7 +19,7 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
const {
mockTeamWorkspacesEnabled,
mockConsolidatedBillingEnabled,
mockBillingControlEnabled,
mockIsPersonal,
mockPlans,
mockPurchaseCredits,
@@ -27,7 +27,7 @@ const {
mockBillingStatus
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockConsolidatedBillingEnabled: { value: false },
mockBillingControlEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn(),
@@ -59,13 +59,11 @@ vi.mock('@/composables/useFeatureFlags', async () => {
teamWorkspacesEnabledRef.value = value
}
})
const consolidatedBillingEnabledRef = ref(
mockConsolidatedBillingEnabled.value
)
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
get: () => consolidatedBillingEnabledRef.value,
const billingControlEnabledRef = ref(mockBillingControlEnabled.value)
Object.defineProperty(mockBillingControlEnabled, 'value', {
get: () => billingControlEnabledRef.value,
set: (value: boolean) => {
consolidatedBillingEnabledRef.value = value
billingControlEnabledRef.value = value
}
})
return {
@@ -74,8 +72,8 @@ vi.mock('@/composables/useFeatureFlags', async () => {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
},
get consolidatedBillingEnabled() {
return mockConsolidatedBillingEnabled.value
get billingControlEnabled() {
return mockBillingControlEnabled.value
}
}
})
@@ -165,7 +163,7 @@ describe('useBillingContext', () => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockTeamWorkspacesEnabled.value = false
mockConsolidatedBillingEnabled.value = false
mockBillingControlEnabled.value = false
mockIsPersonal.value = true
mockPlans.value = []
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
@@ -177,27 +175,27 @@ describe('useBillingContext', () => {
expect(type.value).toBe('legacy')
})
it('keeps personal on legacy when consolidated billing is disabled', () => {
it('keeps personal on legacy when billing control is disabled', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockBillingControlEnabled.value = false
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('legacy')
})
it('selects workspace type for personal when consolidated billing is enabled', () => {
it('selects workspace type for personal when billing control is enabled', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = true
mockBillingControlEnabled.value = true
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('workspace')
})
it('selects workspace type for team regardless of consolidated billing', () => {
it('selects workspace type for team regardless of billing control', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockBillingControlEnabled.value = false
mockIsPersonal.value = false
const { type } = useBillingContext()
@@ -298,7 +296,7 @@ describe('useBillingContext', () => {
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
// Authenticated remote config resolves the flag on for the same workspace
mockConsolidatedBillingEnabled.value = true
mockBillingControlEnabled.value = true
mockTeamWorkspacesEnabled.value = true
await vi.waitFor(() => {
@@ -307,16 +305,16 @@ describe('useBillingContext', () => {
})
})
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
it('moves a personal workspace to workspace billing when billing control flips on', async () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockBillingControlEnabled.value = false
mockIsPersonal.value = true
const { type } = useBillingContext()
await nextTick()
expect(type.value).toBe('legacy')
mockConsolidatedBillingEnabled.value = true
mockBillingControlEnabled.value = true
await vi.waitFor(() => {
expect(type.value).toBe('workspace')
@@ -325,9 +323,9 @@ describe('useBillingContext', () => {
})
describe('subscription mirror to workspace store', () => {
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
it('mirrors subscription for personal workspaces on the billing control flow', async () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = true
mockBillingControlEnabled.value = true
mockIsPersonal.value = true
const { initialize } = useBillingContext()
@@ -555,4 +553,110 @@ describe('useBillingContext', () => {
expect(isLegacyTeamPlan.value).toBe(true)
})
})
describe('isTeamPlan', () => {
it('is false for a personal workspace', () => {
const { isTeamPlan } = useBillingContext()
expect(isTeamPlan.value).toBe(false)
})
// subscription_tier is omitted throughout: the backend sends 'TEAM' here, but
// the FE's SubscriptionTier resolves to the registry spec, which has no TEAM
// (tierPricing.ts imports comfyRegistryTypes for what is an ingest field).
// isTeamPlan reads the credit stop and the slug, never the tier — which is
// what keeps it working despite that divergence.
it('is true for a credit-slider team sub, which carries a credit stop', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
plan_slug: 'team_per_credit_monthly',
team_credit_stop: {
id: 'team_700',
credits_monthly: 700,
stop_usd: 332
}
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(true)
})
it('is true for a legacy team sub, identified by slug rather than credit stop', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'STANDARD',
plan_slug: 'team-standard-annual'
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(true)
})
// The banner states that need isTeamPlan most — paused and payment_failed —
// are exactly the ones the backend reports with is_active=false, because the
// spend gate folds billing_status into it. Coupling isTeamPlan to an active
// subscription would blank the banner precisely when it is needed.
it('stays true for a paused team plan, which the backend reports inactive', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: false,
has_funds: true,
billing_status: 'paused',
plan_slug: 'team_per_credit_monthly',
team_credit_stop: {
id: 'team_700',
credits_monthly: 700,
stop_usd: 332
}
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(true)
})
it('stays true for a legacy team plan whose payment failed', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: false,
has_funds: true,
billing_status: 'payment_failed',
subscription_tier: 'STANDARD',
plan_slug: 'team-standard-annual'
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(true)
})
it('is false for a team workspace on a personal-tier plan', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
plan_slug: 'pro-monthly'
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(false)
})
})
})

View File

@@ -6,6 +6,7 @@ import {
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
PreviewSubscribeOptions,
@@ -35,8 +36,8 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
*
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
* - Team workspaces enabled: workspace billing via /api/billing/* for team
* workspaces, and for personal workspaces once consolidated billing is
* enabled; personal workspaces otherwise stay on legacy billing
* workspaces, and for personal workspaces once billing control is enabled;
* personal workspaces otherwise stay on legacy billing
*
* The context automatically initializes when the workspace changes and provides
* a unified interface for subscription status, balance, and billing actions.
@@ -129,6 +130,16 @@ function useBillingContextInternal(): BillingContext {
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
const freeTierQuota = useFreeTierQuota()
const canRunWorkflows = computed(
() =>
isActiveSubscription.value &&
(!isFreeTier.value ||
!freeTierQuota.quotaEnabled.value ||
freeTierQuota.freeTierExecutionPermitted.value)
)
const isLegacyTeamPlan = computed(
() =>
type.value === 'workspace' &&
@@ -141,6 +152,21 @@ function useBillingContextInternal(): BillingContext {
false)
)
// Plan identity, independent of subscription health: the per-credit Team plan
// carries a credit stop, the retired seat-based ones a `team-` slug. Kept off
// isActiveSubscription on purpose — paused and payment_failed both force
// is_active=false, which is exactly when callers still need to know this is a
// team plan.
const isTeamPlan = computed(
() =>
type.value === 'workspace' &&
(currentTeamCreditStop.value !== null ||
(currentPlanSlug.value
?.toLowerCase()
.startsWith(LEGACY_TEAM_PLAN_SLUG_PREFIX) ??
false))
)
const billingStatus = computed(() =>
toValue(activeContext.value.billingStatus)
)
@@ -191,9 +217,9 @@ function useBillingContextInternal(): BillingContext {
error.value = null
}
// type flips when the team-workspaces or consolidated-billing flag resolves
// from authenticated config, swapping the active backend. Reset then reinit
// on every workspace-id or type change.
// type flips when the team-workspaces or billing-control flag resolves from
// authenticated config, swapping the active backend. Reset then reinit on
// every workspace-id or type change.
watch(
[() => store.activeWorkspace?.id, () => type.value],
async ([newWorkspaceId]) => {
@@ -297,8 +323,10 @@ function useBillingContextInternal(): BillingContext {
isLoading,
error,
isActiveSubscription,
canRunWorkflows,
isFreeTier,
isLegacyTeamPlan,
isTeamPlan,
billingStatus,
subscriptionStatus,
tier,

View File

@@ -5,7 +5,7 @@ import { useBillingRouting } from './useBillingRouting'
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
mockFlags: {
teamWorkspacesEnabled: false,
consolidatedBillingEnabled: false
billingControlEnabled: false
},
mockActiveWorkspace: {
value: null as { id: string; type: 'personal' | 'team' } | null
@@ -30,7 +30,7 @@ const team = { id: 'w-team', type: 'team' as const }
describe('useBillingRouting', () => {
beforeEach(() => {
mockFlags.teamWorkspacesEnabled = false
mockFlags.consolidatedBillingEnabled = false
mockFlags.billingControlEnabled = false
mockActiveWorkspace.value = personal
})
@@ -44,9 +44,9 @@ describe('useBillingRouting', () => {
expect(shouldUseWorkspaceBilling.value).toBe(false)
})
it('keeps personal on legacy when consolidated billing is disabled', () => {
it('keeps personal on legacy when billing control is disabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = false
mockFlags.billingControlEnabled = false
mockActiveWorkspace.value = personal
const { type } = useBillingRouting()
@@ -54,9 +54,9 @@ describe('useBillingRouting', () => {
expect(type.value).toBe('legacy')
})
it('moves personal to workspace billing when consolidated billing is enabled', () => {
it('moves personal to workspace billing when billing control is enabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockFlags.billingControlEnabled = true
mockActiveWorkspace.value = personal
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
@@ -65,9 +65,9 @@ describe('useBillingRouting', () => {
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
it('uses workspace billing for team workspaces regardless of billing control', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = false
mockFlags.billingControlEnabled = false
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
@@ -76,9 +76,9 @@ describe('useBillingRouting', () => {
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
it('uses workspace billing for team workspaces with billing control enabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockFlags.billingControlEnabled = true
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
@@ -89,7 +89,7 @@ describe('useBillingRouting', () => {
it('defaults to legacy while the workspace has not loaded', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockFlags.billingControlEnabled = true
mockActiveWorkspace.value = null
const { type } = useBillingRouting()

View File

@@ -8,7 +8,7 @@ import type { BillingType } from './types'
/**
* Selects the billing backend for the active workspace: legacy user-scoped
* (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
* stay legacy until `billingControlEnabled`; team workspaces are always
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
*/
export function useBillingRouting() {
@@ -23,7 +23,7 @@ export function useBillingRouting() {
const workspaceType = workspaceStore.activeWorkspace?.type
if (!workspaceType) return 'legacy'
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
if (workspaceType === 'personal' && !flags.billingControlEnabled) {
return 'legacy'
}

View File

@@ -0,0 +1,193 @@
import { fromAny } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { api } from '@/scripts/api'
import { useMaskEditorLoader } from './useMaskEditorLoader'
// ---- Module Mocks ----
const mockDataStore: Record<string, unknown> = {
inputData: null,
sourceNode: null,
setLoading: vi.fn()
}
vi.mock('@/stores/maskEditorDataStore', () => ({
useMaskEditorDataStore: vi.fn(() => mockDataStore)
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: vi.fn(() => ({
getNodeOutputs: vi.fn(() => undefined)
}))
}))
const distribution = vi.hoisted(() => ({ isCloud: false }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
}
}))
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
apiURL: vi.fn((route: string) => `http://localhost:8188/api${route}`)
}
}))
vi.mock('@/scripts/app', () => ({
app: {
getPreviewFormatParam: vi.fn(() => ''),
getRandParam: vi.fn(() => '')
}
}))
// Mock Image constructor so the loader's image fetches resolve without a
// network. Records every requested URL; URLs matching failUrlPattern reject
// like a 404 would (reset per test in beforeEach).
const requestedUrls: string[] = []
let failUrlPattern: RegExp | null = null
class MockImage {
crossOrigin = ''
onload: ((ev: Event) => void) | null = null
onerror: ((ev: unknown) => void) | null = null
private _src = ''
get src() {
return this._src
}
set src(value: string) {
this._src = value
requestedUrls.push(value)
queueMicrotask(() => {
if (failUrlPattern?.test(value)) this.onerror?.(new Event('error'))
else this.onload?.(new Event('load'))
})
}
}
function createLoadImageNode(widgetValue: string): LGraphNode {
return fromAny<LGraphNode, unknown>({
id: 7,
type: 'LoadImage',
imgs: [{ src: 'http://localhost:8188/api/view?filename=whatever.png' }],
images: undefined,
widgets: [{ name: 'image', value: widgetValue }]
})
}
function subfolderOf(url: string): string | null {
return new URL(url).searchParams.get('subfolder')
}
function requestedLayerUrls(layerFilename: string): string[] {
return requestedUrls.filter((url) => url.includes(layerFilename))
}
describe('useMaskEditorLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
requestedUrls.length = 0
failUrlPattern = null
distribution.isCloud = false
mockDataStore.inputData = null
mockDataStore.sourceNode = null
vi.stubGlobal('Image', MockImage)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('loads layer files from the input root for saves without a subfolder prefix', async () => {
const node = createLoadImageNode('clipspace-painted-masked-123.png [input]')
await useMaskEditorLoader().loadFromNode(node)
for (const layerFilename of [
'clipspace-mask-123.png',
'clipspace-paint-123.png'
]) {
const layerUrls = requestedLayerUrls(layerFilename)
expect(layerUrls.length).toBeGreaterThan(0)
for (const url of layerUrls) {
expect(subfolderOf(url)).toBeNull()
}
}
expect(mockDataStore.inputData).toMatchObject({
sourceRef: { filename: 'clipspace-mask-123.png', type: 'input' },
nodeId: 7
})
})
it('loads layer files from the clipspace subfolder for legacy saves', async () => {
const node = createLoadImageNode(
'clipspace/clipspace-painted-masked-123.png [input]'
)
await useMaskEditorLoader().loadFromNode(node)
for (const layerFilename of [
'clipspace-mask-123.png',
'clipspace-paint-123.png'
]) {
const layerUrls = requestedLayerUrls(layerFilename)
expect(layerUrls.length).toBeGreaterThan(0)
for (const url of layerUrls) {
expect(subfolderOf(url)).toBe('clipspace')
}
}
})
it('keeps the clipspace subfolder for cloud-resolved layer files', async () => {
distribution.isCloud = true
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
painted_masked: 'hash-painted-masked.png',
painted: 'hash-painted.png',
paint: 'hash-paint.png',
mask: 'hash-mask.png'
})
} as Response)
const node = createLoadImageNode('clipspace-painted-masked-123.png [input]')
await useMaskEditorLoader().loadFromNode(node)
for (const layerFilename of ['hash-painted-masked.png', 'hash-paint.png']) {
const layerUrls = requestedLayerUrls(layerFilename)
expect(layerUrls.length).toBeGreaterThan(0)
for (const url of layerUrls) {
expect(subfolderOf(url)).toBe('clipspace')
}
}
})
it('falls back to the node image when layer files are missing', async () => {
failUrlPattern = /clipspace-(mask|paint)-123\.png/
const node = createLoadImageNode('clipspace-painted-masked-123.png [input]')
await useMaskEditorLoader().loadFromNode(node)
expect(mockDataStore.inputData).toMatchObject({
sourceRef: { filename: 'clipspace-painted-masked-123.png' },
paintLayer: undefined,
nodeId: 7
})
})
it('still fails when the node image itself cannot be loaded', async () => {
failUrlPattern = /./
const node = createLoadImageNode('some-regular-image.png [input]')
await expect(useMaskEditorLoader().loadFromNode(node)).rejects.toThrow()
expect(mockDataStore.setLoading).toHaveBeenLastCalledWith(
false,
expect.any(String)
)
})
})

View File

@@ -53,10 +53,10 @@ function imageLayerFilenamesIfApplicable(
}
}
function toRef(filename: string): ImageRef {
function toRef(filename: string, subfolder: string): ImageRef {
return {
filename,
subfolder: 'clipspace',
subfolder,
type: 'input'
}
}
@@ -150,34 +150,72 @@ export function useMaskEditorLoader() {
}
}
const baseImageUrl = imageLayerFilenames?.maskedImage
? mkFileUrl({ ref: toRef(imageLayerFilenames.maskedImage) })
: nodeImageUrl
// Pattern-matched layer files live next to the painted-masked image the
// widget points at: the input root for saves under the unified upload
// contract (#12318), or the clipspace subfolder for saves made before
// it. Cloud resolves the hash filenames from its mask-layers API
// server-side, so its refs keep the subfolder they always used.
const layerSubfolder = maskLayersFromApi
? 'clipspace'
: (nodeImageRef.subfolder ?? '')
const sourceRef = imageLayerFilenames?.maskedImage
? parseImageRef(baseImageUrl)
: nodeImageRef
const layeredBaseUrl = imageLayerFilenames?.maskedImage
? mkFileUrl({
ref: toRef(imageLayerFilenames.maskedImage, layerSubfolder)
})
: undefined
const baseImageUrl = layeredBaseUrl ?? nodeImageUrl
let paintLayerUrl: string | null = null
if (maskLayersFromApi?.paint) {
paintLayerUrl = mkFileUrl({ ref: toRef(maskLayersFromApi.paint) })
paintLayerUrl = mkFileUrl({
ref: toRef(maskLayersFromApi.paint, layerSubfolder)
})
} else if (imageLayerFilenames?.paint) {
paintLayerUrl = mkFileUrl({ ref: toRef(imageLayerFilenames.paint) })
paintLayerUrl = mkFileUrl({
ref: toRef(imageLayerFilenames.paint, layerSubfolder)
})
}
const [baseLayer, maskLayer, paintLayer] = await Promise.all([
loadImageLayer(baseImageUrl, 'rgb'),
loadImageLayer(baseImageUrl, 'a'),
paintLayerUrl
? loadPaintLayer(paintLayerUrl)
: Promise.resolve(undefined)
])
const loadLayers = (baseUrl: string, paintUrl: string | null) =>
Promise.all([
loadImageLayer(baseUrl, 'rgb'),
loadImageLayer(baseUrl, 'a'),
paintUrl ? loadPaintLayer(paintUrl) : Promise.resolve(undefined)
])
const loadInputData = async () => {
try {
const [baseLayer, maskLayer, paintLayer] = await loadLayers(
baseImageUrl,
paintLayerUrl
)
return {
baseLayer,
maskLayer,
paintLayer,
sourceRef: layeredBaseUrl
? parseImageRef(baseImageUrl)
: nodeImageRef
}
} catch (error) {
if (!layeredBaseUrl) throw error
console.warn(
'[MaskEditorLoader] Failed to load editor layer files, falling back to the node image',
error
)
const [baseLayer, maskLayer] = await loadLayers(nodeImageUrl, null)
return {
baseLayer,
maskLayer,
paintLayer: undefined,
sourceRef: nodeImageRef
}
}
}
dataStore.inputData = {
baseLayer,
maskLayer,
paintLayer,
sourceRef,
...(await loadInputData()),
nodeId: node.id
}

View File

@@ -1,12 +1,19 @@
import { createSharedComposable } from '@vueuse/core'
import { computed, toValue } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodePricing } from '@/composables/node/useNodePricing'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { mapAllNodes } from '@/utils/graphTraversalUtil'
type LinkedWidgetInput = INodeInputSlot & {
_subgraphSlot?: SubgraphInput
@@ -150,3 +157,20 @@ export const usePriceBadge = () => {
updateSubgraphCredits
}
}
export const useCreditsBadgesInGraph = createSharedComposable(() => {
const { isCreditsBadge } = usePriceBadge()
const vueNodeLifecycle = useVueNodeLifecycle()
return computed(() => {
void vueNodeLifecycle.nodeManager.value?.vueNodeData.size
if (!app.graph) return []
return mapAllNodes(app.graph, (node) => {
if (node.isSubgraphNode()) return
const priceBadge = node.badges.find(isCreditsBadge)
if (!priceBadge) return
trackNodePrice(node)
return [node.title, toValue(priceBadge).text, node.id] as const
})
})
})

View File

@@ -7,7 +7,7 @@ import {
} from '@/composables/useFeatureFlags'
import * as distributionTypes from '@/platform/distribution/types'
import {
cachedConsolidatedBillingEnabled,
cachedBillingControlEnabled,
cachedTeamWorkspacesEnabled,
remoteConfig,
remoteConfigState
@@ -182,6 +182,30 @@ describe('useFeatureFlags', () => {
})
})
describe('partnerNodeGovernanceEnabled', () => {
afterEach(() => {
remoteConfig.value = {}
})
it('uses the workspace eligibility flag', () => {
remoteConfig.value = { partner_node_governance_enabled: true }
const { flags } = useFeatureFlags()
expect(flags.partnerNodeGovernanceEnabled).toBe(true)
})
it('defaults to false when the remote flag is unset', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(_path, defaultValue) => defaultValue
)
const { flags } = useFeatureFlags()
expect(flags.partnerNodeGovernanceEnabled).toBe(false)
})
})
describe('dev override via localStorage', () => {
afterEach(() => {
localStorage.clear()
@@ -226,19 +250,19 @@ describe('useFeatureFlags', () => {
expect(flags.teamWorkspacesEnabled).toBe(true)
})
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
it('billingControlEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
vi.mocked(distributionTypes).isCloud = false
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
localStorage.setItem('ff:billing_control_enabled', 'true')
const { flags } = useFeatureFlags()
expect(flags.consolidatedBillingEnabled).toBe(true)
expect(flags.billingControlEnabled).toBe(true)
})
it('consolidatedBillingEnabled is false off-cloud even without an override', () => {
it('billingControlEnabled is false off-cloud even without an override', () => {
vi.mocked(distributionTypes).isCloud = false
const { flags } = useFeatureFlags()
expect(flags.consolidatedBillingEnabled).toBe(false)
expect(flags.billingControlEnabled).toBe(false)
})
})
@@ -248,7 +272,7 @@ describe('useFeatureFlags', () => {
remoteConfigState.value = 'unloaded'
remoteConfig.value = {}
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
cachedBillingControlEnabled.value = undefined
localStorage.clear()
})
@@ -257,36 +281,36 @@ describe('useFeatureFlags', () => {
remoteConfigState.value = 'unloaded'
remoteConfig.value = {}
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
cachedBillingControlEnabled.value = undefined
localStorage.clear()
})
it('returns the cached session value during the auth window', () => {
cachedTeamWorkspacesEnabled.value = false
cachedConsolidatedBillingEnabled.value = true
cachedBillingControlEnabled.value = true
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
expect(flags.consolidatedBillingEnabled).toBe(true)
expect(flags.billingControlEnabled).toBe(true)
})
it('defaults to false during the auth window when nothing is cached', () => {
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
expect(flags.consolidatedBillingEnabled).toBe(false)
expect(flags.billingControlEnabled).toBe(false)
})
it('prefers authenticated remoteConfig over the server feature fallback', () => {
remoteConfigState.value = 'authenticated'
remoteConfig.value = {
team_workspaces_enabled: true,
consolidated_billing_enabled: true
billing_control_enabled: true
}
vi.mocked(api.getServerFeature).mockReturnValue(false)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(flags.consolidatedBillingEnabled).toBe(true)
expect(flags.billingControlEnabled).toBe(true)
})
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
@@ -295,15 +319,14 @@ describe('useFeatureFlags', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
return true
if (path === ServerFeatureFlag.BILLING_CONTROL_ENABLED) return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(flags.consolidatedBillingEnabled).toBe(true)
expect(flags.billingControlEnabled).toBe(true)
})
})

View File

@@ -3,7 +3,7 @@ import type { Ref } from 'vue'
import { isCloud, isNightly } from '@/platform/distribution/types'
import {
cachedConsolidatedBillingEnabled,
cachedBillingControlEnabled,
cachedTeamWorkspacesEnabled,
isAuthenticatedConfigLoaded,
remoteConfig
@@ -24,6 +24,7 @@ export enum ServerFeatureFlag {
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
PARTNER_NODE_GOVERNANCE_ENABLED = 'partner_node_governance_enabled',
USER_SECRETS_ENABLED = 'user_secrets_enabled',
NODE_REPLACEMENTS = 'node_replacements',
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled',
@@ -32,7 +33,8 @@ export enum ServerFeatureFlag {
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
BILLING_CONTROL_ENABLED = 'billing_control_enabled',
FREE_TIER_JOB_ALLOWANCE_ENABLED = 'free_tier_job_allowance_enabled',
SIGNUP_TURNSTILE = 'signup_turnstile'
}
@@ -133,6 +135,13 @@ export function useFeatureFlags() {
cachedTeamWorkspacesEnabled
)
},
get partnerNodeGovernanceEnabled() {
return resolveFlag(
ServerFeatureFlag.PARTNER_NODE_GOVERNANCE_ENABLED,
remoteConfig.value.partner_node_governance_enabled,
false
)
},
get userSecretsEnabled() {
return resolveFlag(
ServerFeatureFlag.USER_SECRETS_ENABLED,
@@ -191,15 +200,25 @@ export function useFeatureFlags() {
)
},
/**
* Whether personal workspaces use the consolidated (workspace-scoped)
* billing flow. While false (default), personal workspaces stay on the
* legacy per-user billing flow; team workspaces are unaffected.
* Whether personal workspaces use the workspace-scoped billing flow. While
* false (default), personal workspaces stay on the legacy per-user billing
* flow; team workspaces are unaffected.
*/
get consolidatedBillingEnabled() {
get billingControlEnabled() {
return resolveAuthGatedFlag(
ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED,
remoteConfig.value.consolidated_billing_enabled,
cachedConsolidatedBillingEnabled
ServerFeatureFlag.BILLING_CONTROL_ENABLED,
remoteConfig.value.billing_control_enabled,
cachedBillingControlEnabled
)
},
get freeTierJobAllowanceEnabled() {
const config = remoteConfig.value as typeof remoteConfig.value & {
free_tier_job_allowance_enabled?: boolean
}
return resolveFlag(
ServerFeatureFlag.FREE_TIER_JOB_ALLOWANCE_ENABLED,
config.free_tier_job_allowance_enabled,
false
)
},
get signupTurnstileMode() {

View File

@@ -4,7 +4,6 @@ import userEvent from '@testing-library/user-event'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { createApp, defineComponent, nextTick, reactive, ref } from 'vue'
import type { Ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -18,35 +17,10 @@ import {
createMockSubgraphNode
} from '@/utils/__tests__/litegraphTestUtils'
import { useImageCrop } from './useImageCrop'
import { imageCropLoadingAfterUrlChange, useImageCrop } from './useImageCrop'
const resizeObserverCallbacks: Array<() => void> = []
const useImageMockState = vi.hoisted(() => {
return {
state: null as null | {
state: Ref<HTMLImageElement | undefined>
isReady: Ref<boolean>
error: Ref<unknown>
}
}
})
function getUseImageMock() {
if (!useImageMockState.state) {
useImageMockState.state = {
state: ref<HTMLImageElement | undefined>(undefined),
isReady: ref(false),
error: ref<unknown>(null)
}
}
return useImageMockState.state
}
function resetUseImageMock() {
useImageMockState.state = null
}
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
@@ -54,8 +28,7 @@ vi.mock('@vueuse/core', async () => {
useResizeObserver: (_target: unknown, cb: () => void) => {
resizeObserverCallbacks.push(cb)
return { stop: vi.fn() }
},
useImage: () => getUseImageMock()
}
}
})
@@ -205,33 +178,10 @@ function setupImageLayout(vm: CropVm, nw: number, nh: number) {
value: nh
})
}
triggerImageLoad(nw, nh)
;(vm.handleImageLoad as () => void)()
flushResizeObservers()
}
function triggerImageLoad(nw: number, nh: number) {
const mock = getUseImageMock()
const fakeImg = new Image()
Object.defineProperty(fakeImg, 'naturalWidth', {
configurable: true,
value: nw
})
Object.defineProperty(fakeImg, 'naturalHeight', {
configurable: true,
value: nh
})
mock.state.value = fakeImg
mock.error.value = null
mock.isReady.value = true
}
function triggerImageError() {
const mock = getUseImageMock()
mock.state.value = undefined
mock.isReady.value = false
mock.error.value = new Error('image failed to load')
}
const harnessCleanups: Array<() => void> = []
async function mountHarness(nodeId: NodeId = toNodeId(2)) {
@@ -253,6 +203,28 @@ async function flushTicks() {
await nextTick()
}
describe('imageCropLoadingAfterUrlChange', () => {
it('clears loading when url becomes null', () => {
expect(imageCropLoadingAfterUrlChange(null, 'https://a/b.png')).toBe(false)
})
it('keeps loading off when url stays null', () => {
expect(imageCropLoadingAfterUrlChange(null, null)).toBe(false)
})
it('starts loading when url changes to a new string', () => {
expect(imageCropLoadingAfterUrlChange('https://b', 'https://a')).toBe(true)
})
it('starts loading when first url is set', () => {
expect(imageCropLoadingAfterUrlChange('https://a', undefined)).toBe(true)
})
it('returns null when url is unchanged so caller can skip updating', () => {
expect(imageCropLoadingAfterUrlChange('https://a', 'https://a')).toBe(null)
})
})
describe('useImageCrop', () => {
let sourceNode: LGraphNode
let cropNode: LGraphNode
@@ -260,7 +232,6 @@ describe('useImageCrop', () => {
beforeEach(() => {
resizeObserverCallbacks.length = 0
resetUseImageMock()
vi.clearAllMocks()
outputStore = {
nodeOutputs: reactive<Record<string, unknown>>({}),
@@ -412,8 +383,7 @@ describe('useImageCrop', () => {
configurable: true,
value: 0
})
triggerImageLoad(0, 0)
await flushTicks()
;(vm.handleImageLoad as () => void)()
vm.modelValue = { x: 0, y: 0, width: 100, height: 80 }
const style = vm.cropBoxStyle as Record<string, string>
expect(parseFloat(style.width)).toBeCloseTo(100, 1)
@@ -441,16 +411,14 @@ describe('useImageCrop', () => {
expect(vm.imageUrl).toBe('https://example.com/b.png')
expect(vm.isLoading).toBe(true)
triggerImageLoad(800, 600)
await flushTicks()
;(vm.handleImageLoad as () => void)()
expect(vm.isLoading).toBe(false)
})
it('clears imageUrl on image error', async () => {
const vm = await mountHarness()
expect(vm.imageUrl).toBeTruthy()
triggerImageError()
await flushTicks()
;(vm.handleImageError as () => void)()
expect(vm.imageUrl).toBeNull()
expect(vm.isLoading).toBe(false)
})
@@ -657,7 +625,6 @@ describe('WidgetImageCrop', () => {
beforeEach(() => {
resizeObserverCallbacks.length = 0
resetUseImageMock()
vi.clearAllMocks()
const outputStore: MockOutputStore = {
nodeOutputs: reactive<Record<string, unknown>>({}),
@@ -746,7 +713,7 @@ describe('WidgetImageCrop', () => {
configurable: true,
value: 400
})
triggerImageLoad(400, 400)
img.dispatchEvent(new Event('load'))
await flushTicks()
expect(screen.getByTestId('crop-overlay')).toBeTruthy()
unmount()
@@ -790,7 +757,7 @@ describe('WidgetImageCrop', () => {
configurable: true,
value: 400
})
triggerImageLoad(400, 400)
img.dispatchEvent(new Event('load'))
await flushTicks()
await user.click(screen.getByRole('button', { name: 'Lock aspect ratio' }))

View File

@@ -1,4 +1,4 @@
import { useImage, useResizeObserver } from '@vueuse/core'
import { useResizeObserver } from '@vueuse/core'
import type { Ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
@@ -35,6 +35,19 @@ const CORNER_SIZE = 10
const MIN_CROP_SIZE = 16
const CROP_BOX_BORDER = 2
/**
* Next `isLoading` when `imageUrl` transitions. `null` means do not change
* `isLoading` (e.g. same URL).
*/
export function imageCropLoadingAfterUrlChange(
url: string | null,
previous: string | null | undefined
): boolean | null {
if (url == null) return false
if (url !== previous) return true
return null
}
export const ASPECT_RATIOS = {
'1:1': 1,
'3:4': 3 / 4,
@@ -192,37 +205,17 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
imageUrl.value = getInputImageUrl()
}
const {
state: imageState,
isReady: imageIsReady,
error: imageLoadError
} = useImage(computed(() => ({ src: imageUrl.value ?? '', alt: '' })))
watch(imageUrl, (url, previous) => {
if (url == null) {
isLoading.value = false
} else if (url !== previous) {
isLoading.value = true
const next = imageCropLoadingAfterUrlChange(url, previous)
if (next !== null) {
isLoading.value = next
}
})
watch([imageIsReady, imageState], ([ready, img]) => {
if (!ready || !img) return
isLoading.value = false
updateDisplayedDimensions(img)
})
watch(imageLoadError, (err) => {
if (err) {
isLoading.value = false
imageUrl.value = null
}
})
const updateDisplayedDimensions = (loadedImg?: HTMLImageElement | null) => {
const img = loadedImg ?? imageEl.value
if (!img || !containerEl.value) return
const updateDisplayedDimensions = () => {
if (!imageEl.value || !containerEl.value) return
const img = imageEl.value
const container = containerEl.value
naturalWidth.value = img.naturalWidth
@@ -378,6 +371,16 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
)
})
const handleImageLoad = () => {
isLoading.value = false
updateDisplayedDimensions()
}
const handleImageError = () => {
isLoading.value = false
imageUrl.value = null
}
const capturePointer = (e: PointerEvent) => {
if (e.target instanceof HTMLElement) e.target.setPointerCapture(e.pointerId)
}
@@ -615,6 +618,8 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
cropBoxStyle,
resizeHandles,
handleImageLoad,
handleImageError,
handleDragStart,
handleDragMove,
handleDragEnd,

View File

@@ -0,0 +1,91 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ComfyApp } from '@/scripts/app'
import type { QueuePromptGuard } from '@/services/queuePromptGuardService'
import type { ComfyExtension } from '@/types/comfy'
const {
addToast,
isNodeDisabled,
registerQueuePromptGuard,
registerExtension,
usePartnerNodeGovernanceStore
} = vi.hoisted(() => {
const isNodeDisabled = vi.fn()
return {
addToast: vi.fn(),
isNodeDisabled,
registerQueuePromptGuard: vi.fn<
(id: string, guard: QueuePromptGuard) => () => void
>(() => () => {}),
registerExtension: vi.fn<(extension: ComfyExtension) => void>(),
usePartnerNodeGovernanceStore: vi.fn(() => ({ isNodeDisabled }))
}
})
vi.mock('@/platform/workspace/stores/partnerNodeGovernanceStore', () => ({
usePartnerNodeGovernanceStore
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: addToast })
}))
vi.mock('@/services/queuePromptGuardService', () => ({
registerQueuePromptGuard
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({ registerExtension })
}))
describe('cloudPartnerNodeGovernance', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
})
async function loadExtension(): Promise<ComfyExtension> {
await import('./cloudPartnerNodeGovernance')
const extension = registerExtension.mock.calls[0]?.[0]
if (!extension)
throw new Error('Expected governance extension registration')
return extension
}
it('initializes governance during Cloud setup', async () => {
const extension = await loadExtension()
expect(extension.name).toBe('Comfy.Cloud.PartnerNodeGovernance')
extension.setup?.(fromPartial<ComfyApp>({}))
expect(usePartnerNodeGovernanceStore).toHaveBeenCalledOnce()
expect(registerQueuePromptGuard).toHaveBeenCalledOnce()
})
it('blocks queueing when the graph contains a disabled partner node', async () => {
const graph = new LGraph()
graph.add(new LGraphNode('DisabledPartnerNode', 'DisabledPartnerNode'))
isNodeDisabled.mockImplementation(
(nodeType) => nodeType === 'DisabledPartnerNode'
)
const extension = await loadExtension()
extension.setup?.(fromPartial<ComfyApp>({}))
const guard = registerQueuePromptGuard.mock.calls[0]?.[1]
if (!guard) throw new Error('Expected queue guard registration')
const result = await guard({ rootGraph: graph })
expect(result).toBe(false)
expect(isNodeDisabled).toHaveBeenCalledWith('DisabledPartnerNode')
expect(addToast).toHaveBeenCalledWith({
severity: 'error',
summary: 'Workflow blocked by workspace policy',
detail:
'Remove disabled partner nodes from this workflow or ask a workspace owner to update the policy.',
life: 6000
})
})
})

View File

@@ -0,0 +1,35 @@
import { t } from '@/i18n'
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useExtensionService } from '@/services/extensionService'
import { registerQueuePromptGuard } from '@/services/queuePromptGuardService'
import { forEachNode } from '@/utils/graphTraversalUtil'
const QUEUE_GUARD_ID = 'workspace.partner-node-governance'
useExtensionService().registerExtension({
name: 'Comfy.Cloud.PartnerNodeGovernance',
setup: () => {
usePartnerNodeGovernanceStore()
registerQueuePromptGuard(QUEUE_GUARD_ID, (context) => {
const governanceStore = usePartnerNodeGovernanceStore()
let hasDisabledPartnerNode = false
forEachNode(context.rootGraph, (node) => {
if (node.type && governanceStore.isNodeDisabled(node.type)) {
hasDisabledPartnerNode = true
}
})
if (!hasDisabledPartnerNode) return true
useToastStore().add({
severity: 'error',
summary: t('workspacePanel.allowlist.runBlocked.summary'),
detail: t('workspacePanel.allowlist.runBlocked.detail'),
life: 6000
})
return false
})
}
})

View File

@@ -38,6 +38,7 @@ if (isCloud) {
await import('./cloudRemoteConfig')
await import('./cloudBadges')
await import('./cloudSessionCookie')
await import('./cloudPartnerNodeGovernance')
}
// Feedback button for cloud and nightly builds

View File

@@ -2473,6 +2473,11 @@
},
"credits": {
"activity": "Activity",
"insufficient": {
"memberTitle": "This workspace is out of credits",
"memberDescription": "Your team has used all its credits. Your workspace admins need to add more credits to run workflows.",
"memberCta": "Ok, got it"
},
"credits": "Credits",
"yourCreditBalance": "Your credit balance",
"purchaseCredits": "Purchase Credits",
@@ -2882,13 +2887,63 @@
"updatePassword": "Update Password"
},
"workspacePanel": {
"billingStatus": {
"warning": {
"title": "Payment declined",
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
"bodyNoDate": "Your last payment didn't go through. Update payment to avoid a pause."
},
"paused": {
"title": "Subscription paused",
"body": "This workspace's subscription is paused. Update payment to resume.",
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method."
},
"outOfCredits": {
"title": "Out of credits",
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
"memberBody": "Your team has used all its credits. Your workspace admins need to add more credits to continue generating.",
"addCredits": "Add credits",
"dismiss": "Dismiss"
},
"ending": {
"title": "Your team plan ends on {date}",
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
"reactivate": "Reactivate plan"
},
"updatePayment": "Update payment"
},
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of {count} members",
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
"membersCount": "Members ({count})"
"membersCount": "Members ({count})",
"allowlist": "Allowlist"
},
"allowlist": {
"title": "Partner nodes",
"description": "Choose which partner nodes are allowed when workspace enforcement is enabled.",
"searchPlaceholder": "Search partner nodes...",
"loading": "Loading partner node policy...",
"loadError": "Partner node policy could not be loaded.",
"retry": "Try again",
"empty": "No partner nodes are available.",
"noMatches": "No partner nodes match your search.",
"allowedCount": "{enabled} of {total} allowed",
"nodeToggle": "Allow {name}",
"saved": "Partner node policy saved",
"saveError": "Partner node policy could not be saved.",
"runBlocked": {
"summary": "Workflow blocked by workspace policy",
"detail": "Remove disabled partner nodes from this workflow or ask a workspace owner to update the policy."
},
"enforcement": {
"title": "Enforce partner node allowlist",
"description": "Block partner nodes that are not explicitly allowed.",
"toggle": "Enforce partner node allowlist"
}
},
"dashboard": {
"placeholder": "Dashboard workspace settings"
@@ -3556,6 +3611,9 @@
"dockToTop": "Dock to top",
"feedback": "Feedback",
"feedbackTooltip": "Feedback",
"freeTierRuns": "{available} / {MAX_AVAILABLE} runs left",
"freeTierRunsExhausted": "No runs left",
"freeTierPartner": "Partner nodes need a paid plan",
"share": "Share",
"shareTooltip": "Share workflow"
},

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
const DOT_COLORS = [
'bg-destructive-background',
'bg-warning-background',
'bg-success-background'
]
const { showSubscriptionDialog } = useBillingContext()
const { t } = useI18n()
const { available, hasInvalidNodes, maxAvailable, quotaEnabled } =
useFreeTierQuota()
const dotColor = computed(() => {
const ratio = maxAvailable.value ? available.value / maxAvailable.value : 0
return DOT_COLORS[
Math.min(Math.floor(ratio * DOT_COLORS.length), DOT_COLORS.length - 1)
]
})
const label = computed(() =>
available.value === 0
? t('actionbar.freeTierRunsExhausted')
: t('actionbar.freeTierRuns', {
available: available.value,
MAX_AVAILABLE: maxAvailable.value
})
)
</script>
<template>
<div
v-if="quotaEnabled"
class="mt-2 w-full cursor-pointer border-t border-border-subtle bg-comfy-menu-bg px-4 pt-2 select-none"
data-testid="free-tier-quota"
@click="showSubscriptionDialog({ reason: 'free_tier_quota' })"
>
<div
v-if="hasInvalidNodes"
class="flex w-full items-center justify-center gap-2"
>
<i class="icon-[comfy--credits] bg-amber-400" />
{{ t('actionbar.freeTierPartner') }}
</div>
<div v-else class="flex w-full items-center justify-between">
<div class="flex gap-2" :aria-label="label" role="img">
<div
v-for="index in maxAvailable"
:key="index"
:class="
cn(
'size-1.5 rounded-full',
index > available ? 'bg-secondary-background-selected' : dotColor
)
"
/>
</div>
<div v-text="label" />
</div>
</div>
</template>

View File

@@ -8,10 +8,19 @@ const mockDialogService = {
showTopUpCreditsDialog: vi.fn()
}
const mockBilling = {
fetchStatus: vi.fn(),
fetchBalance: vi.fn()
}
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => mockDialogService)
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => mockBilling)
}))
describe('useAccountPreconditionDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -55,4 +64,18 @@ describe('useAccountPreconditionDialog', () => {
mockDialogService.showSubscriptionRequiredDialog
).not.toHaveBeenCalled()
})
it('refreshes the billing snapshot on a credit precondition so exhausted-state surfaces converge', () => {
useAccountPreconditionDialog().open('credits')
expect(mockBilling.fetchStatus).toHaveBeenCalledTimes(1)
expect(mockBilling.fetchBalance).toHaveBeenCalledTimes(1)
})
it('does not touch billing state for non-credit preconditions', () => {
useAccountPreconditionDialog().open('subscription')
expect(mockBilling.fetchStatus).not.toHaveBeenCalled()
expect(mockBilling.fetchBalance).not.toHaveBeenCalled()
})
})

View File

@@ -1,3 +1,4 @@
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type { AccountPrecondition } from '@/platform/errorCatalog/accountPreconditionRouting'
import { useDialogService } from '@/services/dialogService'
@@ -28,11 +29,19 @@ export function useAccountPreconditionDialog() {
reason: 'subscription_required'
})
return
case 'credits':
case 'credits': {
// The server just declared the balance exhausted; there is no push or
// polling for billing state, so refresh it here to converge
// hasFunds-keyed surfaces such as the credits-exhausted banner. The
// refresh is best-effort: allSettled keeps a flaky billing API from
// surfacing as unhandled rejections.
const { fetchStatus, fetchBalance } = useBillingContext()
void Promise.allSettled([fetchStatus(), fetchBalance()])
void dialogService.showTopUpCreditsDialog({
isInsufficientCredits: true
})
return
}
}
}

View File

@@ -0,0 +1,45 @@
import { createSharedComposable } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useCreditsBadgesInGraph } from '@/composables/node/usePriceBadge'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
export const useFreeTierQuota = createSharedComposable(function () {
const { flags } = useFeatureFlags()
const creditsBadges = useCreditsBadgesInGraph()
const available = ref(0)
const maxAvailable = ref(0)
watch(
() => remoteConfig.value.free_tier_balance?.remaining,
(val) => (available.value = val ?? 0),
{ immediate: true }
)
watch(
() => remoteConfig.value.free_tier_balance?.allowance,
(val) => (maxAvailable.value = val ?? 0),
{ immediate: true }
)
const quotaEnabled = computed(
() => flags.freeTierJobAllowanceEnabled && maxAvailable.value > 0
)
const hasInvalidNodes = computed(() => creditsBadges.value.length > 0)
const freeTierExecutionPermitted = computed(
() => !hasInvalidNodes.value && quotaEnabled.value && available.value > 0
)
function trackRun() {
if (available.value > 0) available.value--
}
return {
available,
freeTierExecutionPermitted,
hasInvalidNodes,
maxAvailable,
quotaEnabled,
trackRun
}
})

View File

@@ -266,6 +266,21 @@ describe('useSubscriptionDialog', () => {
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
it('shows the read-only member dialog for out-of-credits too, not the pricing table', () => {
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = false
mockCanManageSubscription.value = false
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'out_of_credits' })
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
const props = mockShowLayoutDialog.mock.calls[0][0].props
expect(props).toHaveProperty('onClose')
expect(props).not.toHaveProperty('reason')
expect(props).not.toHaveProperty('initialPlanMode')
})
it('does not track on non-cloud', () => {
mockIsCloud.value = false
const { showPricingTable } = useSubscriptionDialog()

View File

@@ -55,12 +55,12 @@ export const useSubscriptionDialog = () => {
// Members can't manage the workspace subscription, so a blocked run shows a
// small read-only "ask your owner to reactivate" modal instead of the
// pricing table. Out-of-credits still routes everyone to the credits flow.
// pricing table — including out-of-credits, whose member recovery path is
// also owner-only (FE-1246).
if (
shouldUseWorkspaceBilling.value &&
!workspaceStore.isInPersonalWorkspace &&
!permissions.value.canManageSubscription &&
options?.reason !== 'out_of_credits'
!permissions.value.canManageSubscription
) {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
@@ -88,9 +88,9 @@ export const useSubscriptionDialog = () => {
} as const
// Jun-5 model: a single unified pricing table (personal/team plan toggle on
// one workspace) for workspaces on the consolidated billing flow. Replaces
// the old personal-vs-team workspace fork. Personal workspaces still on the
// legacy flow (consolidated billing disabled) get the legacy table.
// one workspace) for workspaces on the workspace-scoped billing flow.
// Replaces the old personal-vs-team workspace fork. Personal workspaces
// still on the legacy flow (billing control disabled) get the legacy table.
if (shouldUseWorkspaceBilling.value) {
// Existing per-member (legacy) team subscribers keep the old tier-based
// team table; the unified credit-slider table is for everyone else.

View File

@@ -73,6 +73,33 @@ describe('resolveAccountPrecondition', () => {
).toBe('credits')
})
it('classifies the submit-time 402 body by its insufficient_credits type regardless of message', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'insufficient_credits',
exceptionMessage: 'Workspace balance exhausted'
})
).toBe('credits')
})
it('classifies the team submit-time 429 (PAYMENT_REQUIRED / insufficient credits) as a credits precondition', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'PAYMENT_REQUIRED',
exceptionMessage: 'Insufficient credits to queue workflows'
})
).toBe('credits')
})
it('keeps the team submit-time 429 for an inactive subscription on the subscription precondition', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'PAYMENT_REQUIRED',
exceptionMessage: 'Subscription required to queue workflows'
})
).toBe('subscription')
})
it('returns undefined for an ordinary workflow error', () => {
expect(
resolveAccountPrecondition({

View File

@@ -33,7 +33,13 @@ const INSUFFICIENT_CREDITS_MESSAGES = new Set([
'Payment Required: Please add credits to your account to use this node.'
])
const WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES = new Set([
'Payment Required: Please add credits to your workspace to continue.'
// Execution-time (pre-GPU) WebSocket failure for a queued team job.
'Payment Required: Please add credits to your workspace to continue.',
// Submit-time 429 rejection for a team workspace out of credits
// (checkTeamWorkspaceSubscription). It shares the PAYMENT_REQUIRED error type
// with the subscription-required rejection, so it can only be told apart by
// message.
'Insufficient credits to queue workflows'
])
const SUBSCRIPTION_REQUIRED_MESSAGES = new Set([
'Workspace has no active subscription. Please subscribe to a plan to continue.',
@@ -243,8 +249,12 @@ const RUNTIME_MATCH_RULES: RuntimeMatchRule[] = [
resolve: () => catalogMatch(WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID)
},
{
// 'insufficient_credits' is the error.type BE-2866 will emit on the
// personal submit-time 402; the team submit path is a 429 matched by
// message in WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES above.
matches: (info, message) =>
info.exceptionType === 'InsufficientFundsError' ||
info.exceptionType === 'insufficient_credits' ||
INSUFFICIENT_CREDITS_MESSAGES.has(message),
resolve: () => catalogMatch(INSUFFICIENT_CREDITS_CATALOG_ID)
},

View File

@@ -1,5 +1,5 @@
import {
cachedConsolidatedBillingEnabled,
cachedBillingControlEnabled,
cachedTeamWorkspacesEnabled,
remoteConfig,
remoteConfigState
@@ -60,8 +60,8 @@ export async function refreshRemoteConfig(
cachedTeamWorkspacesEnabled.value = Boolean(
config.team_workspaces_enabled
)
cachedConsolidatedBillingEnabled.value = Boolean(
config.consolidated_billing_enabled
cachedBillingControlEnabled.value = Boolean(
config.billing_control_enabled
)
}
return

View File

@@ -60,7 +60,7 @@ export const cachedTeamWorkspacesEnabled = useStorage<boolean | undefined>(
undefined
)
export const cachedConsolidatedBillingEnabled = useStorage<boolean | undefined>(
'consolidated_billing_enabled' satisfies `${ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED}`,
export const cachedBillingControlEnabled = useStorage<boolean | undefined>(
'billing_control_enabled' satisfies `${ServerFeatureFlag.BILLING_CONTROL_ENABLED}`,
undefined
)

View File

@@ -107,15 +107,21 @@ export type RemoteConfig = {
manager_survey_url?: string
linear_toggle_enabled?: boolean
team_workspaces_enabled?: boolean
partner_node_governance_enabled?: boolean
user_secrets_enabled?: boolean
node_library_essentials_enabled?: boolean
free_tier_credits?: number
free_tier_balance?: {
allowance: number
used: number
remaining: number
}
new_free_tier_subscriptions?: boolean
workflow_sharing_enabled?: boolean
comfyhub_upload_enabled?: boolean
comfyhub_profile_gate_enabled?: boolean
unified_cloud_auth?: boolean
consolidated_billing_enabled?: boolean
billing_control_enabled?: boolean
sentry_dsn?: string
turnstile_sitekey?: string
// Raw, unvalidated wire value (a server typo like 'enfroce' is possible).

View File

@@ -33,6 +33,7 @@ export type PaymentIntentSource =
| 'invite_member_upsell'
| 'upload_model_upgrade'
| 'team_upgrade_resume'
| 'free_tier_quota'
export type SubscriptionCheckoutType = 'new' | 'change'
export type SubscriptionCheckoutTier = TierKey | 'team'

View File

@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getPartnerNodePolicy,
PartnerNodePolicyApiError,
updatePartnerNodePolicy
} from '@/platform/workspace/api/partnerNodePolicyApi'
const mockFetchApi = vi.fn()
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: (...args: unknown[]) => mockFetchApi(...args)
}
}))
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(body), init)
}
describe('partnerNodePolicyApi', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('normalizes the configured policy response', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({
enforcement_enabled: true,
nodes: { AllowedNode: true, DisabledNode: false }
})
)
await expect(getPartnerNodePolicy()).resolves.toEqual({
enforcementEnabled: true,
nodes: { AllowedNode: true, DisabledNode: false }
})
expect(mockFetchApi).toHaveBeenCalledWith(
'/workspace/partner-node-policy',
{ cache: 'no-store' }
)
})
it('maps 404 to an unconfigured policy', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({}, { status: 404, statusText: 'Not Found' })
)
await expect(getPartnerNodePolicy()).resolves.toBeNull()
})
it('preserves non-404 status codes for policy decisions', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({}, { status: 503, statusText: 'Service Unavailable' })
)
await expect(getPartnerNodePolicy()).rejects.toEqual(
new PartnerNodePolicyApiError(503, 'Service Unavailable')
)
})
it('rejects malformed policy responses', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({ enforcement_enabled: 'yes', nodes: [] })
)
await expect(getPartnerNodePolicy()).rejects.toMatchObject({
name: 'ZodError'
})
})
it('serializes and validates a whole-policy update', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({
enforcement_enabled: false,
nodes: { AllowedNode: true, DisabledNode: true }
})
)
await expect(
updatePartnerNodePolicy({
enforcementEnabled: false,
nodes: { AllowedNode: true, DisabledNode: true }
})
).resolves.toEqual({
enforcementEnabled: false,
nodes: { AllowedNode: true, DisabledNode: true }
})
expect(mockFetchApi).toHaveBeenCalledWith(
'/workspace/partner-node-policy',
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enforcement_enabled: false,
nodes: { AllowedNode: true, DisabledNode: true }
})
}
)
})
it('preserves update failures for the editor', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({}, { status: 409, statusText: 'Conflict' })
)
await expect(
updatePartnerNodePolicy({ enforcementEnabled: false, nodes: {} })
).rejects.toEqual(new PartnerNodePolicyApiError(409, 'Conflict'))
})
it('rejects malformed update responses', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({ enforcement_enabled: false, nodes: [] })
)
await expect(
updatePartnerNodePolicy({ enforcementEnabled: false, nodes: {} })
).rejects.toMatchObject({ name: 'ZodError' })
})
})

View File

@@ -0,0 +1,67 @@
import { z } from 'zod'
import { api } from '@/scripts/api'
const PARTNER_NODE_POLICY_PATH = '/workspace/partner-node-policy'
const partnerNodePolicyResponseSchema = z.object({
enforcement_enabled: z.boolean(),
nodes: z.record(z.string(), z.boolean())
})
export type PartnerNodePolicyResponse = z.infer<
typeof partnerNodePolicyResponseSchema
>
export interface PartnerNodePolicy {
enforcementEnabled: boolean
nodes: Readonly<Record<string, boolean>>
}
export class PartnerNodePolicyApiError extends Error {
constructor(
public readonly status: number,
message: string
) {
super(message)
this.name = 'PartnerNodePolicyApiError'
}
}
function parsePartnerNodePolicy(data: unknown): PartnerNodePolicy {
const policy = partnerNodePolicyResponseSchema.parse(data)
return {
enforcementEnabled: policy.enforcement_enabled,
nodes: policy.nodes
}
}
function throwResponseError(response: Response): never {
throw new PartnerNodePolicyApiError(response.status, response.statusText)
}
export async function getPartnerNodePolicy(): Promise<PartnerNodePolicy | null> {
const response = await api.fetchApi(PARTNER_NODE_POLICY_PATH, {
cache: 'no-store'
})
if (response.status === 404) return null
if (!response.ok) throwResponseError(response)
return parsePartnerNodePolicy(await response.json())
}
export async function updatePartnerNodePolicy(
policy: PartnerNodePolicy
): Promise<PartnerNodePolicy> {
const response = await api.fetchApi(PARTNER_NODE_POLICY_PATH, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enforcement_enabled: policy.enforcementEnabled,
nodes: policy.nodes
})
})
if (!response.ok) throwResponseError(response)
return parsePartnerNodePolicy(await response.json())
}

View File

@@ -250,6 +250,9 @@ export type BillingStatus =
| 'pending_payment'
| 'paid'
| 'payment_failed'
// A Stripe-paused subscription stays `active` on the activity axis; the pause
// is a payment-lifecycle fact. Not emitted until cloud#5075 ships.
| 'paused'
| 'inactive'
export interface CurrentTeamCreditStop {

View File

@@ -0,0 +1,42 @@
<template>
<div
class="flex flex-col overflow-hidden rounded-2xl border border-border-default bg-base-background"
data-testid="insufficient-credits-member-message"
>
<div
class="flex h-12 items-center gap-2 border-b border-border-default p-4"
>
<p class="m-0 min-w-0 flex-1 font-inter text-sm text-base-foreground">
{{ $t('credits.insufficient.memberTitle') }}
</p>
<button
type="button"
:aria-label="$t('g.close')"
class="flex size-4 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent text-base-foreground"
@click="onClose"
>
<i class="icon-[lucide--x] size-3" />
</button>
</div>
<div class="p-4">
<p class="m-0 font-inter text-sm text-muted-foreground">
{{ $t('credits.insufficient.memberDescription') }}
</p>
</div>
<div class="flex items-center justify-end p-4">
<Button variant="secondary" size="lg" @click="onClose">
{{ $t('credits.insufficient.memberCta') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
const { onClose } = defineProps<{
onClose: () => void
}>()
</script>

View File

@@ -0,0 +1,178 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { SubscriptionInfo } from '@/composables/billing/types'
import { i18n } from '@/i18n'
import type { BillingContextMockState } from '@/storybook/mocks/useBillingContext'
import { setBillingContextMock } from '@/storybook/mocks/useBillingContext'
import type { WorkspaceUIMockState } from '@/storybook/mocks/useWorkspaceUI'
import { setWorkspaceUIMock } from '@/storybook/mocks/useWorkspaceUI'
import BillingStatusBanner from './BillingStatusBanner.vue'
/**
* The single billing banner slot for team workspaces (FE-1246), rendered in
* priority order: paused > payment declined > out of credits > ending. At most
* one state shows at a time. Each story drives the real `deriveBillingBanner`
* through the stubbed billing context, so these are the states the backend can
* actually emit — not hand-set banner kinds.
*
* The amber triangle-alert marks action-needed states; the muted circle-alert is
* reserved for the informational "plan ends" notice (DES-380 severity rule).
*
* RUN WITH `DISTRIBUTION=cloud pnpm storybook`. The banner is cloud-only, and
* `isCloud` is compile-time, so under a plain `pnpm storybook` every story below
* renders empty.
*/
const meta: Meta<typeof BillingStatusBanner> = {
title: 'Platform/Workspace/BillingStatusBanner',
component: BillingStatusBanner,
parameters: { layout: 'fullscreen' }
}
export default meta
type Story = StoryObj<typeof BillingStatusBanner>
const RENEWAL_DATE = '2026-08-01T00:00:00Z'
const PLAN_END_DATE = '2026-08-01T00:00:00Z'
const teamSubscription: SubscriptionInfo = {
isActive: true,
tier: null,
duration: 'MONTHLY',
planSlug: 'team-monthly',
renewalDate: RENEWAL_DATE,
endDate: null,
isCancelled: false,
hasFunds: true
}
const funded = teamSubscription
const exhausted: SubscriptionInfo = { ...teamSubscription, hasFunds: false }
const cancelled: SubscriptionInfo = {
...teamSubscription,
isCancelled: true,
endDate: PLAN_END_DATE
}
const owner: Partial<WorkspaceUIMockState> = {}
const member: Partial<WorkspaceUIMockState> = {
canManageSubscription: false,
canManageSubscriptionLifecycle: false,
canTopUp: false
}
function story(
billing: Partial<BillingContextMockState>,
workspace: Partial<WorkspaceUIMockState>
): Story {
return {
beforeEach() {
// Dates in the copy go through vue-i18n's `d()`, so pin the locale rather
// than inherit the developer's.
i18n.global.locale.value = 'en'
setBillingContextMock({ isTeamPlan: true, ...billing })
setWorkspaceUIMock(workspace)
}
}
}
/**
* Stripe suspended the subscription. The backend folds billing_status into
* is_active, so a paused workspace always reports `is_active: false` — the
* pairing these stories pin.
*/
export const PausedOwner: Story = story(
{
subscription: funded,
isActiveSubscription: false,
billingStatus: 'paused',
subscriptionStatus: 'active'
},
owner
)
/** Members get an admin-directed notice and no action. */
export const PausedMember: Story = story(
{
subscription: funded,
isActiveSubscription: false,
billingStatus: 'paused',
subscriptionStatus: 'active'
},
member
)
/**
* A failed charge with Stripe still retrying. Owner-only, and `is_active: false`
* for the same reason paused is — payment_failed also denies spend.
*/
export const PaymentDeclined: Story = story(
{
subscription: funded,
isActiveSubscription: false,
billingStatus: 'payment_failed',
subscriptionStatus: 'active',
renewalDate: RENEWAL_DATE
},
owner
)
/** Payment declined before a renewal date is known. */
export const PaymentDeclinedNoDate: Story = story(
{
subscription: funded,
isActiveSubscription: false,
billingStatus: 'payment_failed',
subscriptionStatus: 'active'
},
owner
)
/** Shared team credits exhausted. Session-dismissible. */
export const OutOfCreditsOwner: Story = story(
{
subscription: exhausted,
isActiveSubscription: true,
billingStatus: 'paid',
subscriptionStatus: 'active',
renewalDate: RENEWAL_DATE
},
owner
)
/** Members can't top up, so they get contact-admin copy and no Add credits. */
export const OutOfCreditsMember: Story = story(
{
subscription: exhausted,
isActiveSubscription: true,
billingStatus: 'paid',
subscriptionStatus: 'active',
renewalDate: RENEWAL_DATE
},
member
)
/** Cancelled but still active until the period end. Informational. */
export const EndingOwner: Story = story(
{
subscription: cancelled,
isActiveSubscription: true,
billingStatus: 'paid',
subscriptionStatus: 'canceled'
},
owner
)
/**
* Reactivate is lifecycle-gated to the original owner, so a non-original owner
* sees the notice read-only.
*/
export const EndingNonOriginalOwner: Story = story(
{
subscription: cancelled,
isActiveSubscription: true,
billingStatus: 'paid',
subscriptionStatus: 'canceled'
},
{ canManageSubscriptionLifecycle: false }
)

View File

@@ -0,0 +1,317 @@
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed } from 'vue'
import { createI18n } from 'vue-i18n'
import type {
BillingStatus,
WorkspaceType
} from '@/platform/workspace/api/workspaceApi'
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
interface Subscription {
hasFunds: boolean
isCancelled: boolean
endDate: string | null
}
const state = vi.hoisted(() => ({
billingControlEnabled: true,
isActiveSubscription: true,
isTeamPlan: true,
billingStatus: 'paid' as string | null,
subscription: {
hasFunds: true,
isCancelled: false,
endDate: null
} as Subscription | null,
renewalDate: null as string | null,
workspaceType: 'team' as string,
canManageSubscription: true,
canManageSubscriptionLifecycle: true,
canTopUp: true,
showTopUpCreditsDialog: vi.fn(),
manageSubscription: vi.fn(),
handleResubscribe: vi.fn()
}))
vi.mock('@/platform/distribution/types', () => ({ isCloud: true }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get billingControlEnabled() {
return state.billingControlEnabled
}
}
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: computed(() => state.isActiveSubscription),
isTeamPlan: computed(() => state.isTeamPlan),
billingStatus: computed(() => state.billingStatus as BillingStatus | null),
subscription: computed(() => state.subscription),
renewalDate: computed(() => state.renewalDate),
manageSubscription: state.manageSubscription
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: computed(() => ({
canManageSubscription: state.canManageSubscription,
canManageSubscriptionLifecycle: state.canManageSubscriptionLifecycle,
canTopUp: state.canTopUp
})),
workspaceType: computed(() => state.workspaceType as WorkspaceType)
})
}))
vi.mock('@/platform/workspace/composables/useResubscribe', () => ({
useResubscribe: () => ({
isResubscribing: computed(() => false),
handleResubscribe: state.handleResubscribe
})
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showTopUpCreditsDialog: state.showTopUpCreditsDialog
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
workspacePanel: {
billingStatus: {
warning: {
title: 'Payment declined',
body: "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
bodyNoDate:
"Your last payment didn't go through. Update payment to avoid a pause."
},
paused: {
title: 'Subscription paused',
body: "This workspace's subscription is paused. Update payment to resume.",
memberBody:
"This workspace's subscription is paused. Your workspace admins need to update the payment method."
},
outOfCredits: {
title: 'Out of credits',
body: 'Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.',
bodyNoDate:
'Your team has used all its credits. Add more credits to continue generating.',
memberBody:
'Your team has used all its credits. Your workspace admins need to add more credits to continue generating.',
addCredits: 'Add credits',
dismiss: 'Dismiss'
},
ending: {
title: 'Your team plan ends on {date}',
body: 'Members keep full access until then. Reactivate to keep your shared credits and seats.',
reactivate: 'Reactivate plan'
},
updatePayment: 'Update payment'
}
}
}
}
})
const globalOptions = {
plugins: [i18n],
stubs: {
Button: {
template:
'<button v-bind="$attrs" @click="$emit(\'click\')"><slot/></button>',
props: ['variant', 'size', 'loading'],
emits: ['click']
}
}
}
function renderBanner() {
return render(BillingStatusBanner, { global: globalOptions })
}
function exhausted() {
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
}
// The spend gate folds billing_status into is_active, so the backend never emits
// paused alongside an active subscription.
function pausedState() {
state.billingStatus = 'paused'
state.isActiveSubscription = false
}
function paymentFailedState() {
state.billingStatus = 'payment_failed'
state.isActiveSubscription = false
}
describe('BillingStatusBanner', () => {
beforeEach(() => {
state.billingControlEnabled = true
state.isActiveSubscription = true
state.isTeamPlan = true
state.billingStatus = 'paid'
state.subscription = { hasFunds: true, isCancelled: false, endDate: null }
state.renewalDate = null
state.workspaceType = 'team'
state.canManageSubscription = true
state.canManageSubscriptionLifecycle = true
state.canTopUp = true
vi.clearAllMocks()
})
it('renders nothing for a healthy funded team', () => {
renderBanner()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('renders nothing when billing control is rolled back, even out of credits', () => {
state.billingControlEnabled = false
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
renderBanner()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('shows out-of-credits with an Add credits action for owners', async () => {
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent('Out of credits')
await userEvent.click(screen.getByRole('button', { name: 'Add credits' }))
expect(state.showTopUpCreditsDialog).toHaveBeenCalledTimes(1)
})
it('shows out-of-credits contact-admin copy without an Add credits action for members', () => {
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
state.canManageSubscription = false
state.canTopUp = false
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent(
'Your workspace admins need to add more credits'
)
expect(
screen.queryByRole('button', { name: 'Add credits' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument()
})
it('dismisses the out-of-credits banner for the session', async () => {
exhausted()
renderBanner()
await userEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('shares one dismiss across instances rather than tracking it per mount', async () => {
exhausted()
render(
{
components: { BillingStatusBanner },
template: '<div><BillingStatusBanner /><BillingStatusBanner /></div>'
},
{ global: globalOptions }
)
expect(screen.getAllByRole('status')).toHaveLength(2)
await userEvent.click(screen.getAllByRole('button', { name: 'Dismiss' })[0])
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('shows the paused banner with Update payment for owners', async () => {
pausedState()
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent('Subscription paused')
expect(screen.getByRole('status')).toHaveTextContent(
'Update payment to resume'
)
await userEvent.click(
screen.getByRole('button', { name: 'Update payment' })
)
expect(state.manageSubscription).toHaveBeenCalledTimes(1)
})
it('shows the paused member notice without an action', () => {
pausedState()
state.canManageSubscription = false
state.canTopUp = false
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent(
'Your workspace admins need to update the payment method'
)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('shows the payment-declined banner with Update payment for owners', () => {
paymentFailedState()
state.renewalDate = '2026-08-01T00:00:00Z'
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent('Payment declined')
expect(screen.getByRole('status')).toHaveTextContent(/will pause on \S+/)
expect(screen.getByRole('status')).not.toHaveTextContent('{date}')
expect(
screen.getByRole('button', { name: 'Update payment' })
).toBeInTheDocument()
})
it('falls back to the no-date payment-declined copy when there is no renewal date', () => {
paymentFailedState()
state.renewalDate = null
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent(
'Update payment to avoid a pause'
)
expect(screen.getByRole('status')).not.toHaveTextContent('will pause on')
expect(screen.getByRole('status')).not.toHaveTextContent('{date}')
})
it('shows the ending banner with a Reactivate action', async () => {
state.subscription = {
hasFunds: true,
isCancelled: true,
endDate: '2026-08-01T00:00:00Z'
}
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent(
'Your team plan ends on'
)
await userEvent.click(
screen.getByRole('button', { name: 'Reactivate plan' })
)
expect(state.handleResubscribe).toHaveBeenCalledTimes(1)
})
it('shows the ending banner read-only to a non-original owner (Reactivate is lifecycle-gated)', () => {
state.subscription = {
hasFunds: true,
isCancelled: true,
endDate: '2026-08-01T00:00:00Z'
}
state.canManageSubscriptionLifecycle = false
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent(
'Your team plan ends on'
)
expect(
screen.queryByRole('button', { name: 'Reactivate plan' })
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,165 @@
<template>
<div v-if="banner" class="@container">
<div
role="status"
class="flex flex-col gap-3 rounded-2xl border border-interface-stroke/60 bg-base-background p-4 @2xl:flex-row @2xl:items-center @2xl:gap-2"
>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<i
:class="
cn(
'size-4 shrink-0',
banner.muted
? 'icon-[lucide--circle-alert] text-muted-foreground'
: 'icon-[lucide--triangle-alert] text-warning-background'
)
"
/>
<span class="text-sm text-base-foreground">{{ banner.title }}</span>
</div>
<p class="m-0 pl-6 text-sm text-muted-foreground">{{ banner.body }}</p>
</div>
<div
v-if="banner.dismissible || banner.action"
class="flex shrink-0 flex-wrap items-center gap-2 pl-6 @2xl:pl-0"
>
<Button
v-if="banner.dismissible"
variant="textonly"
size="lg"
@click="dismiss"
>
{{ $t('workspacePanel.billingStatus.outOfCredits.dismiss') }}
</Button>
<Button
v-if="banner.action === 'addCredits'"
variant="secondary"
size="lg"
@click="handleAddCredits"
>
{{ $t('workspacePanel.billingStatus.outOfCredits.addCredits') }}
</Button>
<Button
v-else-if="banner.action === 'reactivate'"
variant="secondary"
size="lg"
:loading="isResubscribing"
@click="handleResubscribe"
>
{{ $t('workspacePanel.billingStatus.ending.reactivate') }}
</Button>
<Button
v-else-if="banner.action === 'updatePayment'"
variant="inverted"
size="lg"
@click="handleUpdatePayment"
>
{{ $t('workspacePanel.billingStatus.updatePayment') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useBillingBanner } from '@/platform/workspace/composables/useBillingBanner'
import { useResubscribe } from '@/platform/workspace/composables/useResubscribe'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useDialogService } from '@/services/dialogService'
type BannerAction = 'addCredits' | 'reactivate' | 'updatePayment'
const { t, d } = useI18n()
const { renewalDate, subscription, manageSubscription } = useBillingContext()
const { permissions } = useWorkspaceUI()
const { kind, dismiss } = useBillingBanner()
const { isResubscribing, handleResubscribe } = useResubscribe()
const dialogService = useDialogService()
const canManage = computed(() => permissions.value.canManageSubscription)
const canManageLifecycle = computed(
() => permissions.value.canManageSubscriptionLifecycle
)
const canTopUp = computed(() => permissions.value.canTopUp)
const cycleResetDate = computed(() => {
const raw = renewalDate.value
return raw ? d(new Date(raw), { month: 'short', day: 'numeric' }) : ''
})
const planEndDate = computed(() => {
const raw = subscription.value?.endDate
return raw
? d(new Date(raw), { year: 'numeric', month: 'long', day: 'numeric' })
: ''
})
interface BannerView {
muted: boolean
title: string
body: string
action: BannerAction | null
dismissible: boolean
}
const banner = computed<BannerView | null>(() => {
const bs = 'workspacePanel.billingStatus'
switch (kind.value) {
case 'paused':
return {
muted: false,
title: t(`${bs}.paused.title`),
body: canManage.value
? t(`${bs}.paused.body`)
: t(`${bs}.paused.memberBody`),
action: canManage.value ? 'updatePayment' : null,
dismissible: false
}
case 'paymentFailed':
return {
muted: false,
title: t(`${bs}.warning.title`),
body: cycleResetDate.value
? t(`${bs}.warning.body`, { date: cycleResetDate.value })
: t(`${bs}.warning.bodyNoDate`),
action: 'updatePayment',
dismissible: false
}
case 'outOfCredits':
return {
muted: false,
title: t(`${bs}.outOfCredits.title`),
body: canTopUp.value
? cycleResetDate.value
? t(`${bs}.outOfCredits.body`, { date: cycleResetDate.value })
: t(`${bs}.outOfCredits.bodyNoDate`)
: t(`${bs}.outOfCredits.memberBody`),
action: canTopUp.value ? 'addCredits' : null,
dismissible: true
}
case 'ending':
return {
muted: true,
title: t(`${bs}.ending.title`, { date: planEndDate.value }),
body: t(`${bs}.ending.body`),
action: canManageLifecycle.value ? 'reactivate' : null,
dismissible: false
}
default:
return null
}
})
function handleAddCredits() {
void dialogService.showTopUpCreditsDialog()
}
function handleUpdatePayment() {
void manageSubscription()
}
</script>

View File

@@ -0,0 +1,303 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { defineComponent, nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi'
import type { PartnerNodePolicyStatus } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
import PartnerNodeAllowlistPanel from './PartnerNodeAllowlistPanel.vue'
const mockSavePolicy = vi.fn()
const mockLoadPolicy = vi.fn()
const mockToastAdd = vi.fn()
const state = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
governedWorkspaceId: ref<string | null>('workspace-one'),
partnerNodes: ref([
{ id: 'FluxFill', name: 'Flux Fill', provider: 'BFL' },
{ id: 'FluxExpand', name: 'Flux Expand', provider: 'BFL' },
{ id: 'VeoVideo', name: 'Veo Video', provider: 'Google' }
]),
policy: ref<PartnerNodePolicy | null>({
enforcementEnabled: true,
nodes: {
FluxFill: false,
FluxExpand: true,
VeoVideo: true,
RetiredNode: false
}
}),
status: ref<PartnerNodePolicyStatus>('configured')
}
})
vi.mock('pinia', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
storeToRefs: (store: Record<string, unknown>) => store
}
})
vi.mock('@/platform/workspace/stores/partnerNodeGovernanceStore', () => ({
usePartnerNodeGovernanceStore: () => ({
...state,
savePolicy: mockSavePolicy,
loadPolicy: mockLoadPolicy
})
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: mockToastAdd })
}))
const ToggleSwitchStub = defineComponent({
name: 'ToggleSwitch',
props: {
modelValue: Boolean,
disabled: Boolean,
ariaLabel: String
},
emits: ['update:modelValue'],
template: `
<button
role="switch"
:aria-checked="modelValue"
:aria-label="ariaLabel"
:disabled="disabled"
@click="$emit('update:modelValue', !modelValue)"
/>
`
})
const SearchInputStub = defineComponent({
name: 'SearchInput',
props: {
modelValue: { type: String, required: true },
placeholder: String,
disabled: Boolean
},
emits: ['update:modelValue'],
template: `
<input
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
@input="$emit('update:modelValue', $event.target.value)"
/>
`
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderPanel() {
return render(PartnerNodeAllowlistPanel, {
global: {
plugins: [i18n],
stubs: {
ToggleSwitch: ToggleSwitchStub,
SearchInput: SearchInputStub
}
}
})
}
describe('PartnerNodeAllowlistPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
state.governedWorkspaceId.value = 'workspace-one'
state.partnerNodes.value = [
{ id: 'FluxFill', name: 'Flux Fill', provider: 'BFL' },
{ id: 'FluxExpand', name: 'Flux Expand', provider: 'BFL' },
{ id: 'VeoVideo', name: 'Veo Video', provider: 'Google' }
]
state.policy.value = {
enforcementEnabled: true,
nodes: {
FluxFill: false,
FluxExpand: true,
VeoVideo: true,
RetiredNode: false
}
}
state.status.value = 'configured'
mockSavePolicy.mockResolvedValue(true)
})
it('groups catalog nodes and reflects the configured allowlist', () => {
renderPanel()
expect(screen.getByText('BFL')).toBeInTheDocument()
expect(screen.getByText('Google')).toBeInTheDocument()
expect(
screen.getByRole('switch', {
name: 'Enforce partner node allowlist'
})
).toBeChecked()
expect(
screen.getByRole('switch', { name: 'Allow Flux Fill' })
).not.toBeChecked()
expect(
screen.getByRole('switch', { name: 'Allow Flux Expand' })
).toBeChecked()
expect(screen.getByText('1 of 2 allowed')).toBeInTheDocument()
})
it('saves one whole policy while preserving enforcement and hidden entries', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('switch', { name: 'Allow Flux Fill' }))
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(mockSavePolicy).toHaveBeenCalledWith({
enforcementEnabled: true,
nodes: {
FluxFill: true,
FluxExpand: true,
VeoVideo: true,
RetiredNode: false
}
})
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'Partner node policy saved',
life: 2000
})
})
it('saves enforcement with the existing allowlist', async () => {
const user = userEvent.setup()
state.policy.value = {
enforcementEnabled: false,
nodes: {
FluxFill: false,
FluxExpand: true,
VeoVideo: true,
RetiredNode: false
}
}
renderPanel()
await user.click(
screen.getByRole('switch', {
name: 'Enforce partner node allowlist'
})
)
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(mockSavePolicy).toHaveBeenCalledWith({
enforcementEnabled: true,
nodes: {
FluxFill: false,
FluxExpand: true,
VeoVideo: true,
RetiredNode: false
}
})
})
it('treats an unconfigured workspace as allow-all draft', async () => {
state.policy.value = null
state.status.value = 'unconfigured'
renderPanel()
await nextTick()
expect(
screen.getByRole('switch', {
name: 'Enforce partner node allowlist'
})
).not.toBeChecked()
const nodeToggles = screen.getAllByRole('switch', { name: /^Allow / })
expect(nodeToggles).toHaveLength(3)
for (const toggle of nodeToggles) {
expect(toggle).toBeChecked()
}
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
})
it('filters by provider and node identity', async () => {
const user = userEvent.setup()
renderPanel()
await user.type(
screen.getByPlaceholderText('Search partner nodes...'),
'veo'
)
expect(screen.getByText('Veo Video')).toBeInTheDocument()
expect(screen.queryByText('Flux Fill')).not.toBeInTheDocument()
expect(screen.getByText('Google')).toBeInTheDocument()
})
it('offers retry when the policy is unavailable', async () => {
const user = userEvent.setup()
state.status.value = 'unavailable'
renderPanel()
await user.click(screen.getByRole('button', { name: 'Try again' }))
expect(mockLoadPolicy).toHaveBeenCalledOnce()
expect(screen.getByRole('alert')).toHaveTextContent(
'Partner node policy could not be loaded.'
)
})
it('keeps draft changes and surfaces a save failure', async () => {
const user = userEvent.setup()
mockSavePolicy.mockRejectedValue(new Error('Conflict'))
renderPanel()
const toggle = screen.getByRole('switch', { name: 'Allow Flux Fill' })
await user.click(toggle)
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(toggle).toBeChecked()
expect(screen.getByRole('alert')).toHaveTextContent(
'Partner node policy could not be saved.'
)
})
it('releases the new workspace when a previous save fails', async () => {
const user = userEvent.setup()
let rejectSave: (reason?: unknown) => void = () => undefined
mockSavePolicy.mockImplementation(
() =>
new Promise<boolean>((_resolve, reject) => {
rejectSave = reject
})
)
renderPanel()
await user.click(screen.getByRole('switch', { name: 'Allow Flux Fill' }))
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(
screen.getByRole('switch', { name: 'Allow Flux Fill' })
).toBeDisabled()
state.governedWorkspaceId.value = 'workspace-two'
await nextTick()
expect(
screen.getByRole('switch', { name: 'Allow Flux Fill' })
).not.toBeDisabled()
rejectSave(new Error('Conflict'))
await Promise.resolve()
await Promise.resolve()
expect(
screen.queryByText('Allowlist could not be saved.')
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,300 @@
<template>
<div class="grow overflow-auto pt-6">
<div
class="flex size-full min-h-0 flex-col gap-5 rounded-2xl border border-interface-stroke p-6"
>
<div class="flex items-start gap-6">
<div class="min-w-0 flex-1">
<h2 class="m-0 text-base font-semibold text-base-foreground">
{{ $t('workspacePanel.allowlist.title') }}
</h2>
<p class="mt-1 mb-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.allowlist.description') }}
</p>
</div>
<SearchInput
v-model="searchQuery"
:placeholder="$t('workspacePanel.allowlist.searchPlaceholder')"
size="lg"
class="w-64 shrink-0"
:disabled="!isReady"
/>
</div>
<div
v-if="isReady"
class="flex min-h-14 items-center gap-4 rounded-xl border border-interface-stroke/60 px-4 py-3"
>
<div class="min-w-0 flex-1">
<div class="text-sm text-base-foreground">
{{ $t('workspacePanel.allowlist.enforcement.title') }}
</div>
<div class="mt-0.5 text-xs text-muted-foreground">
{{ $t('workspacePanel.allowlist.enforcement.description') }}
</div>
</div>
<ToggleSwitch
:model-value="draftEnforcementEnabled"
:disabled="isSaving"
:aria-label="$t('workspacePanel.allowlist.enforcement.toggle')"
@update:model-value="setEnforcementEnabled"
/>
</div>
<div
v-if="status === 'loading'"
role="status"
class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground"
>
<i class="icon-[lucide--loader-circle] size-4 animate-spin" />
{{ $t('workspacePanel.allowlist.loading') }}
</div>
<div
v-else-if="status === 'error' || status === 'unavailable'"
role="alert"
class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground"
>
<span>{{ $t('workspacePanel.allowlist.loadError') }}</span>
<Button variant="muted-textonly" @click="loadPolicy">
{{ $t('workspacePanel.allowlist.retry') }}
</Button>
</div>
<div
v-else-if="isReady && partnerNodes.length === 0"
class="flex flex-1 items-center justify-center text-sm text-muted-foreground"
>
{{ $t('workspacePanel.allowlist.empty') }}
</div>
<div v-else-if="isReady" class="min-h-0 flex-1 overflow-y-auto">
<div
v-if="groups.length === 0"
class="flex min-h-32 items-center justify-center text-sm text-muted-foreground"
>
{{ $t('workspacePanel.allowlist.noMatches') }}
</div>
<template v-else>
<section
v-for="group in groups"
:key="group.provider"
class="mb-5 last:mb-0"
>
<div
class="mb-2 flex items-center justify-between text-xs font-medium text-muted-foreground"
>
<span>{{ group.provider }}</span>
<span>
{{
$t('workspacePanel.allowlist.allowedCount', {
enabled: group.enabledCount,
total: group.nodes.length
})
}}
</span>
</div>
<div
class="overflow-hidden rounded-xl border border-interface-stroke/60"
>
<div
v-for="node in group.nodes"
:key="node.id"
class="flex min-h-14 items-center gap-4 border-b border-interface-stroke/40 px-4 last:border-b-0"
>
<div class="min-w-0 flex-1">
<div class="truncate text-sm text-base-foreground">
{{ node.name }}
</div>
<div
v-if="node.name !== node.id"
class="truncate text-xs text-muted-foreground"
>
{{ node.id }}
</div>
</div>
<ToggleSwitch
:model-value="draftNodes[node.id]"
:disabled="isSaving"
:aria-label="
$t('workspacePanel.allowlist.nodeToggle', {
name: node.name
})
"
@update:model-value="
(enabled: boolean) => setNodeEnabled(node.id, enabled)
"
/>
</div>
</div>
</section>
</template>
</div>
<div v-if="isReady" class="flex min-h-10 items-center justify-end gap-3">
<span v-if="saveError" role="alert" class="text-destructive text-sm">
{{ $t('workspacePanel.allowlist.saveError') }}
</span>
<Button
variant="primary"
size="lg"
:disabled="!hasChanges"
:loading="isSaving"
@click="save"
>
{{ $t('g.save') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import type { PartnerNodeCatalogItem } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
interface PartnerNodeGroup {
provider: string
nodes: PartnerNodeCatalogItem[]
enabledCount: number
}
const { t } = useI18n()
const governanceStore = usePartnerNodeGovernanceStore()
const { governedWorkspaceId, partnerNodes, policy, status } =
storeToRefs(governanceStore)
const toastStore = useToastStore()
const searchQuery = ref('')
const draftNodes = ref<Record<string, boolean>>({})
const draftEnforcementEnabled = ref(false)
const isSaving = ref(false)
const saveError = ref(false)
let saveGeneration = 0
const isReady = computed(
() => status.value === 'configured' || status.value === 'unconfigured'
)
function originalNodeValue(nodeId: string): boolean {
return policy.value ? policy.value.nodes[nodeId] === true : true
}
function resetDraft(): void {
draftEnforcementEnabled.value = policy.value?.enforcementEnabled ?? false
draftNodes.value = Object.fromEntries(
partnerNodes.value.map((node) => [node.id, originalNodeValue(node.id)])
)
saveError.value = false
}
watch([governedWorkspaceId, partnerNodes, policy], resetDraft, {
immediate: true
})
watch(
governedWorkspaceId,
() => {
saveGeneration += 1
isSaving.value = false
},
{ flush: 'sync' }
)
const hasChanges = computed(
() =>
draftEnforcementEnabled.value !==
(policy.value?.enforcementEnabled ?? false) ||
partnerNodes.value.some(
(node) => draftNodes.value[node.id] !== originalNodeValue(node.id)
)
)
const groups = computed<PartnerNodeGroup[]>(() => {
const query = searchQuery.value.trim().toLocaleLowerCase()
const byProvider = new Map<string, PartnerNodeCatalogItem[]>()
for (const node of partnerNodes.value) {
if (
query &&
![node.name, node.id, node.provider].some((value) =>
value.toLocaleLowerCase().includes(query)
)
) {
continue
}
const nodes = byProvider.get(node.provider) ?? []
nodes.push(node)
byProvider.set(node.provider, nodes)
}
return [...byProvider.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([provider, nodes]) => {
const sortedNodes = nodes.sort((left, right) =>
left.name.localeCompare(right.name)
)
return {
provider,
nodes: sortedNodes,
enabledCount: sortedNodes.filter(
(node) => draftNodes.value[node.id] === true
).length
}
})
})
function setNodeEnabled(nodeId: string, enabled: boolean): void {
draftNodes.value[nodeId] = enabled
saveError.value = false
}
function setEnforcementEnabled(enabled: boolean): void {
draftEnforcementEnabled.value = enabled
saveError.value = false
}
async function save(): Promise<void> {
if (!hasChanges.value || isSaving.value) return
const generation = saveGeneration
isSaving.value = true
saveError.value = false
try {
const applied = await governanceStore.savePolicy({
enforcementEnabled: draftEnforcementEnabled.value,
nodes: {
...(policy.value?.nodes ?? {}),
...draftNodes.value
}
})
if (!applied || generation !== saveGeneration) return
toastStore.add({
severity: 'success',
summary: t('workspacePanel.allowlist.saved'),
life: 2000
})
} catch {
if (generation !== saveGeneration) return
saveError.value = true
} finally {
if (generation === saveGeneration) {
isSaving.value = false
}
}
}
function loadPolicy(): void {
void governanceStore.loadPolicy()
}
</script>

View File

@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/vue'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -9,12 +10,19 @@ import WorkspacePanelContent from './WorkspacePanelContent.vue'
const mockFetchMembers = vi.fn()
const mockFetchPendingInvites = vi.fn()
const { mockMembers, mockWorkspaceType } = vi.hoisted(() => {
const {
mockGovernedWorkspaceId,
mockMembers,
mockWorkspaceRole,
mockWorkspaceType
} = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
mockGovernedWorkspaceId: ref<string | null>('workspace-one'),
mockMembers: ref<WorkspaceMember[]>([]),
mockWorkspaceRole: ref<'owner' | 'member'>('owner'),
mockWorkspaceType: ref<'personal' | 'team'>('team')
}
})
@@ -41,16 +49,20 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
})
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
useWorkspaceUI: () => ({
workspaceType: mockWorkspaceType,
workspaceRole: ref('owner')
workspaceRole: mockWorkspaceRole
})
}
})
vi.mock('@/platform/workspace/stores/partnerNodeGovernanceStore', () => ({
usePartnerNodeGovernanceStore: () => ({
governedWorkspaceId: mockGovernedWorkspaceId
})
}))
vi.mock(
'@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue',
() => ({
@@ -65,6 +77,21 @@ vi.mock(
})
)
vi.mock(
'@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue',
() => ({
default: {
name: 'BillingStatusBanner',
template: '<div data-testid="billing-banner" />'
}
})
)
vi.mock(
'@/platform/workspace/components/dialogs/settings/PartnerNodeAllowlistPanel.vue',
() => ({ default: { template: '<div />' } })
)
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -84,8 +111,9 @@ function createMember(id: string): WorkspaceMember {
}
}
function renderComponent() {
function renderComponent(defaultTab?: string) {
return render(WorkspacePanelContent, {
props: { defaultTab },
global: {
plugins: [i18n],
stubs: { WorkspaceProfilePic: true }
@@ -93,10 +121,33 @@ function renderComponent() {
})
}
describe('WorkspacePanelContent billing banner', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMembers.value = []
mockGovernedWorkspaceId.value = 'workspace-one'
mockWorkspaceRole.value = 'owner'
mockWorkspaceType.value = 'team'
})
it('hosts a single banner slot above the tab content, so it shows on every tab', () => {
renderComponent()
const banner = screen.getByTestId('billing-banner')
const planPanel = screen.getByText('workspacePanel.tabs.planCredits')
expect(
planPanel.compareDocumentPosition(banner) &
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy()
})
})
describe('WorkspacePanelContent members tab label', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMembers.value = []
mockGovernedWorkspaceId.value = 'workspace-one'
mockWorkspaceRole.value = 'owner'
mockWorkspaceType.value = 'team'
})
@@ -127,3 +178,56 @@ describe('WorkspacePanelContent members tab label', () => {
expect(mockFetchPendingInvites).toHaveBeenCalled()
})
})
describe('WorkspacePanelContent allowlist tab', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGovernedWorkspaceId.value = 'workspace-one'
mockWorkspaceRole.value = 'owner'
mockWorkspaceType.value = 'team'
})
it('shows the allowlist for an eligible workspace owner', () => {
renderComponent()
expect(
screen.getByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
).toBeInTheDocument()
})
it('hides the allowlist from workspace members', () => {
mockWorkspaceRole.value = 'member'
renderComponent()
expect(
screen.queryByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
).toBeNull()
})
it('hides the allowlist when governance is ineligible', () => {
mockGovernedWorkspaceId.value = null
renderComponent()
expect(
screen.queryByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
).toBeNull()
})
it('falls back to a valid tab when allowlist access is lost', async () => {
renderComponent('allowlist')
expect(
screen.getByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
).toHaveAttribute('aria-selected', 'true')
mockWorkspaceRole.value = 'member'
await nextTick()
expect(
screen.queryByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
).toBeNull()
expect(
screen.getByRole('tab', { name: 'workspacePanel.tabs.planCredits' })
).toHaveAttribute('aria-selected', 'true')
})
})

View File

@@ -39,28 +39,48 @@
: $t('workspacePanel.members.header')
}}
</TabsTrigger>
<TabsTrigger
v-if="showAllowlistTab"
value="allowlist"
:class="
cn(
tabTriggerBase,
activeTab === 'allowlist' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{ $t('workspacePanel.tabs.allowlist') }}
</TabsTrigger>
</TabsList>
<BillingStatusBanner class="mt-4" />
<TabsContent value="plan" class="mt-4">
<SubscriptionPanelContentWorkspace />
</TabsContent>
<TabsContent value="members" class="mt-4">
<MembersPanelContent :key="workspaceRole" />
</TabsContent>
<TabsContent v-if="showAllowlistTab" value="allowlist" class="mt-4">
<PartnerNodeAllowlistPanel />
</TabsContent>
</TabsRoot>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
import PartnerNodeAllowlistPanel from '@/platform/workspace/components/dialogs/settings/PartnerNodeAllowlistPanel.vue'
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
@@ -80,9 +100,19 @@ const { workspaceName, members } = storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { workspaceType, workspaceRole } = useWorkspaceUI()
const { governedWorkspaceId } = storeToRefs(usePartnerNodeGovernanceStore())
const isPersonalWorkspace = computed(() => workspaceType.value === 'personal')
const showAllowlistTab = computed(
() => !!governedWorkspaceId.value && workspaceRole.value === 'owner'
)
const activeTab = ref(defaultTab)
watch(showAllowlistTab, (isVisible) => {
if (!isVisible && activeTab.value === 'allowlist') {
activeTab.value = defaultTab === 'allowlist' ? 'plan' : defaultTab
}
})
// Per design, the tab counts members only when there is more than the owner
const showMembersTabCount = computed(
() => !isPersonalWorkspace.value && members.value.length > 1

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import type { BillingBannerInputs } from './useBillingBanner'
import { deriveBillingBanner } from './useBillingBanner'
const funded: BillingBannerInputs = {
billingControlEnabled: true,
isTeamPlan: true,
isLoaded: true,
isActiveSubscription: true,
billingStatus: 'paid',
hasFunds: true,
isCancelled: false,
endDate: null,
canManage: true,
outOfCreditsDismissed: false
}
// The backend folds billing_status into is_active, so every spend-denying status
// arrives paired with is_active=false. Pinning that pairing is what keeps these
// cases honest — spreading `funded` instead would assert an input the backend
// cannot emit, and pass no matter where the check sits.
const paused: Partial<BillingBannerInputs> = {
billingStatus: 'paused',
isActiveSubscription: false
}
const paymentFailed: Partial<BillingBannerInputs> = {
billingStatus: 'payment_failed',
isActiveSubscription: false
}
function derive(overrides: Partial<BillingBannerInputs>) {
return deriveBillingBanner({ ...funded, ...overrides })
}
describe('deriveBillingBanner', () => {
it('shows no banner for a healthy funded team', () => {
expect(derive({})).toBeNull()
})
it('shows no banner outside a team plan', () => {
expect(derive({ isTeamPlan: false, hasFunds: false })).toBeNull()
})
it('shows no banner when billing control is rolled back, even out of credits', () => {
expect(derive({ billingControlEnabled: false, hasFunds: false })).toBeNull()
})
it('shows no banner until the subscription snapshot has loaded', () => {
expect(derive({ isLoaded: false, hasFunds: false })).toBeNull()
})
it('surfaces out of credits when the balance is exhausted', () => {
expect(derive({ hasFunds: false })).toBe('outOfCredits')
})
it('shows out of credits to members too', () => {
expect(derive({ hasFunds: false, canManage: false })).toBe('outOfCredits')
})
it('hides out of credits once dismissed', () => {
expect(derive({ hasFunds: false, outOfCreditsDismissed: true })).toBeNull()
})
it('shows payment failed to owners even though the backend reports the plan inactive', () => {
expect(derive(paymentFailed)).toBe('paymentFailed')
})
it('prioritizes payment failure over out of credits for owners', () => {
expect(derive({ ...paymentFailed, hasFunds: false })).toBe('paymentFailed')
})
it('hides payment failed from members, who get the run-lock modal instead', () => {
expect(derive({ ...paymentFailed, canManage: false })).toBeNull()
})
it('prioritizes paused above everything, for owners and members', () => {
expect(derive({ ...paused, hasFunds: false })).toBe('paused')
expect(derive({ ...paused, canManage: false })).toBe('paused')
})
it('shows paused even though the backend reports the workspace inactive', () => {
expect(derive(paused)).toBe('paused')
})
it('surfaces the ending banner for a cancelled-but-active owner', () => {
expect(
derive({
isCancelled: true,
endDate: '2026-08-01T00:00:00Z'
})
).toBe('ending')
})
it('does not show the ending banner until the end date is populated', () => {
expect(
derive({
isCancelled: true,
endDate: null
})
).toBeNull()
})
it('hides the ending banner from members', () => {
expect(
derive({
isCancelled: true,
endDate: '2026-08-01T00:00:00Z',
canManage: false
})
).toBeNull()
})
it('shows no banner for an inactive subscription (that is a run-lock modal)', () => {
expect(
derive({ isActiveSubscription: false, billingStatus: 'inactive' })
).toBeNull()
})
})

View File

@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
const mocks = vi.hoisted(() => ({
billing: null as {
isActiveSubscription: { value: boolean }
isTeamPlan: { value: boolean }
billingStatus: { value: string | null }
subscription: { value: { hasFunds: boolean } | null }
} | null,
billingControlEnabled: null as { value: boolean } | null
}))
vi.mock('@/platform/distribution/types', () => ({ isCloud: true }))
vi.mock('@/composables/useFeatureFlags', async () => {
const { ref } = await import('vue')
const billingControlEnabled = ref(true)
mocks.billingControlEnabled = billingControlEnabled
return {
useFeatureFlags: () => ({
flags: {
get billingControlEnabled() {
return billingControlEnabled.value
}
}
})
}
})
vi.mock('@/composables/billing/useBillingContext', async () => {
const { ref } = await import('vue')
const billing = {
isActiveSubscription: ref(true),
isTeamPlan: ref(true),
billingStatus: ref<string | null>('paid'),
subscription: ref<{ hasFunds: boolean } | null>({ hasFunds: true })
}
mocks.billing = billing
return { useBillingContext: () => billing }
})
vi.mock('@/platform/workspace/composables/useWorkspaceUI', async () => {
const { computed } = await import('vue')
return {
useWorkspaceUI: () => ({
permissions: computed(() => ({
canManageSubscription: true,
canManageSubscriptionLifecycle: true,
canTopUp: true
}))
})
}
})
import { useBillingBanner } from './useBillingBanner'
describe('useBillingBanner', () => {
beforeEach(() => {
const b = mocks.billing!
b.isActiveSubscription.value = true
b.isTeamPlan.value = true
b.billingStatus.value = 'paid'
b.subscription.value = { hasFunds: true }
mocks.billingControlEnabled!.value = true
})
it('suppresses the banner entirely when billing control is rolled back', async () => {
const b = mocks.billing!
const { kind } = useBillingBanner()
b.subscription.value = { hasFunds: false }
await nextTick()
expect(kind.value).toBe('outOfCredits')
mocks.billingControlEnabled!.value = false
await nextTick()
expect(kind.value).toBeNull()
})
it('re-shows the out-of-credits banner after a top-up and a later exhaustion', async () => {
const b = mocks.billing!
const { kind, dismiss } = useBillingBanner()
b.subscription.value = { hasFunds: false }
await nextTick()
expect(kind.value).toBe('outOfCredits')
dismiss()
await nextTick()
expect(kind.value).toBeNull()
b.subscription.value = { hasFunds: true }
await nextTick()
b.subscription.value = { hasFunds: false }
await nextTick()
expect(kind.value).toBe('outOfCredits')
})
})

View File

@@ -0,0 +1,106 @@
import { createSharedComposable } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { BillingStatus } from '@/platform/workspace/api/workspaceApi'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
export type BillingBannerKind =
| 'paused'
| 'paymentFailed'
| 'outOfCredits'
| 'ending'
export interface BillingBannerInputs {
billingControlEnabled: boolean
isTeamPlan: boolean
isLoaded: boolean
isActiveSubscription: boolean
billingStatus: BillingStatus | null
hasFunds: boolean | null
isCancelled: boolean
endDate: string | null
canManage: boolean
outOfCreditsDismissed: boolean
}
// The single billing banner slot, in priority order: paused > paymentFailed >
// outOfCredits > ending. billingControlEnabled is the FE-1246 kill switch: the
// whole banner is behind it so a PostHog rollback hides it for everyone. Then
// gated on the team PLAN rather than the workspace type, because personal
// workspaces are due to gain team plans (BE-1526) — a workspace-type gate would
// then hide the banner from real team subscribers.
export function deriveBillingBanner(
inputs: BillingBannerInputs
): BillingBannerKind | null {
if (!inputs.billingControlEnabled || !inputs.isTeamPlan || !inputs.isLoaded) {
return null
}
// Both sit above the isActiveSubscription gate because the backend folds
// billing_status into is_active: paused and payment_failed each report
// is_active=false, so either check would be dead code below it.
if (inputs.billingStatus === 'paused') return 'paused'
if (inputs.billingStatus === 'payment_failed' && inputs.canManage) {
return 'paymentFailed'
}
// Inactive workspaces surface a run-lock modal, not this banner. Members hit
// this on payment_failed, which is per design — only billing managers see it.
if (!inputs.isActiveSubscription) return null
if (inputs.hasFunds === false && !inputs.outOfCreditsDismissed) {
return 'outOfCredits'
}
if (inputs.isCancelled && inputs.endDate && inputs.canManage) {
return 'ending'
}
return null
}
function useBillingBannerInternal() {
const { isActiveSubscription, billingStatus, subscription, isTeamPlan } =
useBillingContext()
const { permissions } = useWorkspaceUI()
const { flags } = useFeatureFlags()
const dismissed = ref(false)
const kind = computed<BillingBannerKind | null>(() => {
if (!isCloud) return null
return deriveBillingBanner({
billingControlEnabled: flags.billingControlEnabled,
isTeamPlan: isTeamPlan.value,
isLoaded: subscription.value !== null,
isActiveSubscription: isActiveSubscription.value,
billingStatus: billingStatus.value,
hasFunds: subscription.value?.hasFunds ?? null,
isCancelled: subscription.value?.isCancelled ?? false,
endDate: subscription.value?.endDate ?? null,
canManage: permissions.value.canManageSubscription,
outOfCreditsDismissed: dismissed.value
})
})
// Dismiss silences only the out-of-credits banner, and only for the current
// exhaustion episode: reset once the workspace is funded again so a later
// exhaustion re-shows. Shared state, so it survives the settings panel
// unmounting when the dialog closes.
const hasExhaustedFunds = computed(
() => subscription.value?.hasFunds === false
)
watch(hasExhaustedFunds, (exhausted) => {
if (!exhausted) dismissed.value = false
})
function dismiss() {
dismissed.value = true
}
return { kind, dismiss }
}
export const useBillingBanner = createSharedComposable(useBillingBannerInternal)

View File

@@ -0,0 +1,430 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type * as PartnerNodePolicyApi from '@/platform/workspace/api/partnerNodePolicyApi'
import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi'
import { PartnerNodePolicyApiError } from '@/platform/workspace/api/partnerNodePolicyApi'
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
const mockGetPartnerNodePolicy = vi.hoisted(() => vi.fn())
const mockUpdatePartnerNodePolicy = vi.hoisted(() => vi.fn())
const mockFlags = vi.hoisted(() => ({
teamWorkspacesEnabled: true,
partnerNodeGovernanceEnabled: true
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: mockFlags })
}))
vi.mock(
'@/platform/workspace/api/partnerNodePolicyApi',
async (importOriginal) => {
const actual = await importOriginal<typeof PartnerNodePolicyApi>()
return {
...actual,
getPartnerNodePolicy: mockGetPartnerNodePolicy,
updatePartnerNodePolicy: mockUpdatePartnerNodePolicy
}
}
)
function nodeDef(
name: string,
overrides: Partial<ComfyNodeDef> = {}
): ComfyNodeDef {
return {
name,
display_name: `Display ${name}`,
category: 'partner/image/Provider',
python_module: 'comfy_api_nodes.provider',
description: '',
input: {},
output: [],
output_is_list: [],
output_name: [],
output_node: false,
api_node: true,
...overrides
}
}
function activateWorkspace(id: string, type: 'personal' | 'team' = 'team') {
const store = useTeamWorkspaceStore()
store.workspaces = [
{
id,
name: id,
type,
role: 'owner',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
isSubscribed: false,
subscriptionPlan: null,
subscriptionTier: null,
members: [],
pendingInvites: []
}
]
store.activeWorkspaceId = id
}
async function createLoadedStore() {
const store = usePartnerNodeGovernanceStore()
await vi.waitFor(() => expect(store.status).not.toBe('loading'))
return store
}
describe('partnerNodeGovernanceStore', () => {
let store: ReturnType<typeof usePartnerNodeGovernanceStore> | undefined
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
mockFlags.teamWorkspacesEnabled = true
mockFlags.partnerNodeGovernanceEnabled = true
mockGetPartnerNodePolicy.mockResolvedValue(null)
mockUpdatePartnerNodePolicy.mockImplementation(
async (policy: PartnerNodePolicy) => policy
)
activateWorkspace('workspace-one')
useNodeDefStore().updateNodeDefs([
nodeDef('AllowedNode'),
nodeDef('DisabledNode'),
nodeDef('UnreviewedNode'),
nodeDef('CoreNode', {
api_node: false,
category: 'sampling',
python_module: 'nodes'
})
])
})
afterEach(() => {
delete LiteGraph.registered_node_types.LegacyGovernedNode
store?.$dispose()
store = undefined
})
it('composes partner-node catalog metadata from object info', async () => {
useNodeDefStore().updateNodeDefs([
nodeDef('PartnerNode', {
display_name: 'Partner Node',
category: 'partner/video/Acme'
}),
nodeDef('CoreNode', { api_node: false })
])
store = await createLoadedStore()
expect(store.partnerNodes).toEqual([
{ id: 'PartnerNode', name: 'Partner Node', provider: 'Acme' }
])
})
it('treats 404 as unconfigured and allows every node', async () => {
store = await createLoadedStore()
expect(store.status).toBe('unconfigured')
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
})
it('does not block nodes while enforcement is off', async () => {
mockGetPartnerNodePolicy.mockResolvedValue({
enforcementEnabled: false,
nodes: { AllowedNode: true, DisabledNode: false }
} satisfies PartnerNodePolicy)
store = await createLoadedStore()
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
})
it('allows only explicit true rules while enforcement is on', async () => {
mockGetPartnerNodePolicy.mockResolvedValue({
enforcementEnabled: true,
nodes: { AllowedNode: true, DisabledNode: false }
} satisfies PartnerNodePolicy)
store = await createLoadedStore()
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
expect(store.isNodeDisabled('DisabledNode')).toBe(true)
expect(store.isNodeDisabled('UnreviewedNode')).toBe(true)
expect(store.isNodeDisabled('CoreNode')).toBe(false)
})
it('filters disabled nodes out of discovery', async () => {
mockGetPartnerNodePolicy.mockResolvedValue({
enforcementEnabled: true,
nodes: { AllowedNode: true, DisabledNode: false }
} satisfies PartnerNodePolicy)
store = await createLoadedStore()
const nodeDefStore = useNodeDefStore()
expect(nodeDefStore.visibleNodeDefs.map((node) => node.name)).toEqual([
'AllowedNode',
'CoreNode'
])
expect(nodeDefStore.nodeSearchService.searchNode('DisabledNode')).toEqual(
[]
)
})
it('keeps the litegraph legacy menu in sync with policy changes', async () => {
class LegacyGovernedNode extends LGraphNode {}
LiteGraph.registered_node_types.LegacyGovernedNode = LegacyGovernedNode
useNodeDefStore().addNodeDef(nodeDef('LegacyGovernedNode'))
mockGetPartnerNodePolicy.mockResolvedValue({
enforcementEnabled: true,
nodes: { LegacyGovernedNode: false }
} satisfies PartnerNodePolicy)
store = await createLoadedStore()
expect(LegacyGovernedNode.skip_list).toBe(true)
mockGetPartnerNodePolicy.mockResolvedValue({
enforcementEnabled: false,
nodes: { LegacyGovernedNode: false }
} satisfies PartnerNodePolicy)
await store.loadPolicy()
await nextTick()
expect(LegacyGovernedNode.skip_list).toBe(false)
})
it('fails closed for a 503 from an enforcing workspace', async () => {
mockGetPartnerNodePolicy.mockRejectedValue(
new PartnerNodePolicyApiError(503, 'Service Unavailable')
)
store = await createLoadedStore()
expect(store.status).toBe('unavailable')
expect(store.isNodeDisabled('AllowedNode')).toBe(true)
expect(store.isNodeDisabled('CoreNode')).toBe(false)
})
it('fails open during initial loading and a generic failure', async () => {
let rejectLoad!: (error: Error) => void
mockGetPartnerNodePolicy.mockReturnValue(
new Promise((_, reject) => {
rejectLoad = reject
})
)
store = usePartnerNodeGovernanceStore()
expect(store.status).toBe('loading')
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
rejectLoad(new Error('Network error'))
await vi.waitFor(() => expect(store?.status).toBe('error'))
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
})
it('stays fail-closed while retrying an unavailable policy', async () => {
mockGetPartnerNodePolicy.mockRejectedValueOnce(
new PartnerNodePolicyApiError(503, 'Service Unavailable')
)
store = await createLoadedStore()
let rejectRetry!: (error: Error) => void
mockGetPartnerNodePolicy.mockReturnValueOnce(
new Promise((_, reject) => {
rejectRetry = reject
})
)
const retry = store.loadPolicy()
expect(store.status).toBe('unavailable')
expect(store.isNodeDisabled('AllowedNode')).toBe(true)
rejectRetry(new Error('Network error'))
await retry
expect(store.status).toBe('unavailable')
expect(store.isNodeDisabled('AllowedNode')).toBe(true)
})
it('preserves the last enforcing policy after a generic refresh error', async () => {
mockGetPartnerNodePolicy.mockResolvedValue({
enforcementEnabled: true,
nodes: { AllowedNode: true, DisabledNode: false }
} satisfies PartnerNodePolicy)
store = await createLoadedStore()
mockGetPartnerNodePolicy.mockRejectedValue(new Error('Network error'))
await store.loadPolicy()
expect(store.status).toBe('error')
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
expect(store.isNodeDisabled('DisabledNode')).toBe(true)
})
it('stays inactive when partner-node governance is disabled', async () => {
mockFlags.partnerNodeGovernanceEnabled = false
store = usePartnerNodeGovernanceStore()
await nextTick()
expect(mockGetPartnerNodePolicy).not.toHaveBeenCalled()
expect(store.status).toBe('inactive')
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
})
it('stays inactive in a personal workspace', async () => {
activateWorkspace('personal-workspace', 'personal')
store = usePartnerNodeGovernanceStore()
await nextTick()
expect(mockGetPartnerNodePolicy).not.toHaveBeenCalled()
expect(store.status).toBe('inactive')
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
})
it('ignores a stale response after switching workspaces', async () => {
let resolveFirst!: (policy: PartnerNodePolicy) => void
mockGetPartnerNodePolicy
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirst = resolve
})
)
.mockResolvedValueOnce({
enforcementEnabled: true,
nodes: { DisabledNode: true }
} satisfies PartnerNodePolicy)
store = usePartnerNodeGovernanceStore()
await vi.waitFor(() =>
expect(mockGetPartnerNodePolicy).toHaveBeenCalledTimes(1)
)
activateWorkspace('workspace-two')
await vi.waitFor(() => expect(store?.status).toBe('configured'))
resolveFirst({
enforcementEnabled: true,
nodes: { DisabledNode: false }
})
await nextTick()
expect(store.governedWorkspaceId).toBe('workspace-two')
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
})
it('saves and installs the validated whole policy', async () => {
store = await createLoadedStore()
const nextPolicy = {
enforcementEnabled: false,
nodes: { AllowedNode: true, DisabledNode: true }
} satisfies PartnerNodePolicy
await expect(store.savePolicy(nextPolicy)).resolves.toBe(true)
expect(mockUpdatePartnerNodePolicy).toHaveBeenCalledWith(nextPolicy)
expect(store.policy).toEqual(nextPolicy)
expect(store.status).toBe('configured')
})
it('does not install a save response after switching workspaces', async () => {
store = await createLoadedStore()
let resolveSave!: (policy: PartnerNodePolicy) => void
mockUpdatePartnerNodePolicy.mockReturnValueOnce(
new Promise((resolve) => {
resolveSave = resolve
})
)
const save = store.savePolicy({
enforcementEnabled: false,
nodes: { AllowedNode: true }
})
activateWorkspace('workspace-two')
await vi.waitFor(() =>
expect(store?.governedWorkspaceId).toBe('workspace-two')
)
resolveSave({
enforcementEnabled: false,
nodes: { AllowedNode: true }
})
await expect(save).resolves.toBe(false)
await vi.waitFor(() => expect(store?.status).toBe('unconfigured'))
expect(store.policy).toBeNull()
})
it('does not install a save response after switching away and back', async () => {
store = await createLoadedStore()
let resolveSave!: (policy: PartnerNodePolicy) => void
mockUpdatePartnerNodePolicy.mockReturnValueOnce(
new Promise((resolve) => {
resolveSave = resolve
})
)
const save = store.savePolicy({
enforcementEnabled: true,
nodes: { AllowedNode: true }
})
activateWorkspace('workspace-two')
await vi.waitFor(() =>
expect(store?.governedWorkspaceId).toBe('workspace-two')
)
activateWorkspace('workspace-one')
await vi.waitFor(() =>
expect(store?.governedWorkspaceId).toBe('workspace-one')
)
resolveSave({
enforcementEnabled: true,
nodes: { AllowedNode: true }
})
await expect(save).resolves.toBe(false)
await vi.waitFor(() => expect(store?.status).toBe('unconfigured'))
expect(store.policy).toBeNull()
})
it('ignores a stale save rejection after switching workspaces', async () => {
store = await createLoadedStore()
let rejectSave!: (error: Error) => void
mockUpdatePartnerNodePolicy.mockReturnValueOnce(
new Promise((_, reject) => {
rejectSave = reject
})
)
const save = store.savePolicy({
enforcementEnabled: true,
nodes: { AllowedNode: true }
})
activateWorkspace('workspace-two')
await vi.waitFor(() =>
expect(store?.governedWorkspaceId).toBe('workspace-two')
)
rejectSave(new Error('Conflict'))
await expect(save).resolves.toBe(false)
expect(store.error).toBeNull()
})
it('refuses to save before governance is ready', async () => {
mockFlags.partnerNodeGovernanceEnabled = false
store = usePartnerNodeGovernanceStore()
await nextTick()
await expect(
store.savePolicy({ enforcementEnabled: false, nodes: {} })
).rejects.toThrow('Partner node governance is not ready')
expect(mockUpdatePartnerNodePolicy).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,213 @@
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, watch, watchEffect } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
getPartnerNodePolicy,
PartnerNodePolicyApiError,
updatePartnerNodePolicy
} from '@/platform/workspace/api/partnerNodePolicyApi'
import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
export interface PartnerNodeCatalogItem {
id: string
name: string
provider: string
}
export type PartnerNodePolicyStatus =
| 'inactive'
| 'loading'
| 'unconfigured'
| 'configured'
| 'unavailable'
| 'error'
const DISCOVERY_FILTER_ID = 'workspace.partner-node-governance'
export const usePartnerNodeGovernanceStore = defineStore(
'partnerNodeGovernance',
() => {
const { flags } = useFeatureFlags()
const workspaceStore = useTeamWorkspaceStore()
const nodeDefStore = useNodeDefStore()
const policy = shallowRef<PartnerNodePolicy | null>(null)
const policyWorkspaceId = ref<string | null>(null)
const status = ref<PartnerNodePolicyStatus>('inactive')
const error = shallowRef<Error | null>(null)
let loadVersion = 0
let workspaceVersion = 0
const governedWorkspaceId = computed(() => {
const workspace = workspaceStore.activeWorkspace
return flags.teamWorkspacesEnabled &&
flags.partnerNodeGovernanceEnabled &&
workspace?.type === 'team'
? workspace.id
: null
})
const partnerNodes = computed<PartnerNodeCatalogItem[]>(() =>
Object.values(nodeDefStore.nodeDefsByName)
.filter((nodeDef) => nodeDef.api_node)
.map((nodeDef) => ({
id: nodeDef.name,
name: nodeDef.display_name || nodeDef.name,
provider:
nodeDef.category.split('/')[2] ||
nodeDef.category ||
nodeDef.python_module
}))
)
function isNodeDisabled(nodeType: string): boolean {
if (!nodeDefStore.nodeDefsByName[nodeType]?.api_node) return false
const workspaceId = governedWorkspaceId.value
if (!workspaceId || policyWorkspaceId.value !== workspaceId) return false
if (status.value === 'unavailable') return true
return (
policy.value?.enforcementEnabled === true &&
policy.value.nodes[nodeType] !== true
)
}
nodeDefStore.registerNodeDefFilter({
id: DISCOVERY_FILTER_ID,
name: 'Workspace partner-node governance',
predicate: (nodeDef) => !isNodeDisabled(nodeDef.name)
})
const legacyHiddenNodeTypes = new Set<string>()
watchEffect(() => {
const showDevOnly = nodeDefStore.showDevOnly
for (const nodeDef of Object.values(nodeDefStore.nodeDefsByName)) {
if (!nodeDef.api_node) continue
const nodeType = LiteGraph.registered_node_types[nodeDef.name]
if (!nodeType) continue
if (isNodeDisabled(nodeDef.name)) {
if (!nodeType.skip_list) {
nodeType.skip_list = true
legacyHiddenNodeTypes.add(nodeDef.name)
}
continue
}
if (legacyHiddenNodeTypes.delete(nodeDef.name)) {
nodeType.skip_list = nodeDef.dev_only && !showDevOnly
}
}
})
async function loadPolicy(): Promise<void> {
const workspaceId = governedWorkspaceId.value
const version = ++loadVersion
if (!workspaceId) {
policy.value = null
policyWorkspaceId.value = null
status.value = 'inactive'
error.value = null
return
}
const workspaceChanged = policyWorkspaceId.value !== workspaceId
if (workspaceChanged) {
policy.value = null
policyWorkspaceId.value = workspaceId
status.value = 'loading'
} else if (status.value !== 'unavailable') {
status.value = 'loading'
}
error.value = null
try {
const nextPolicy = await getPartnerNodePolicy()
if (
version !== loadVersion ||
governedWorkspaceId.value !== workspaceId
) {
return
}
policy.value = nextPolicy
status.value = nextPolicy ? 'configured' : 'unconfigured'
} catch (loadError) {
if (
version !== loadVersion ||
governedWorkspaceId.value !== workspaceId
) {
return
}
error.value =
loadError instanceof Error
? loadError
: new Error('Failed to load partner node policy')
if (
loadError instanceof PartnerNodePolicyApiError &&
loadError.status === 503
) {
policy.value = null
status.value = 'unavailable'
return
}
if (status.value !== 'unavailable') status.value = 'error'
}
}
async function savePolicy(nextPolicy: PartnerNodePolicy): Promise<boolean> {
const workspaceId = governedWorkspaceId.value
const version = workspaceVersion
if (!workspaceId || policyWorkspaceId.value !== workspaceId) {
throw new Error('Partner node governance is not ready')
}
let savedPolicy: PartnerNodePolicy
try {
savedPolicy = await updatePartnerNodePolicy(nextPolicy)
} catch (saveError) {
if (
workspaceVersion !== version ||
governedWorkspaceId.value !== workspaceId
) {
return false
}
throw saveError
}
if (
workspaceVersion !== version ||
governedWorkspaceId.value !== workspaceId
) {
return false
}
policy.value = savedPolicy
policyWorkspaceId.value = workspaceId
status.value = 'configured'
error.value = null
return true
}
watch(
governedWorkspaceId,
() => {
workspaceVersion++
void loadPolicy()
},
{ immediate: true, flush: 'sync' }
)
return {
policy,
status,
error,
governedWorkspaceId,
partnerNodes,
isNodeDisabled,
loadPolicy,
savePolicy
}
}
)

View File

@@ -1,126 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
const useImageMock = vi.hoisted(() => ({
state: null as Ref<HTMLImageElement | undefined> | null,
isReady: null as Ref<boolean> | null
}))
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
const { ref } = await import('vue')
useImageMock.state = ref<HTMLImageElement | undefined>(undefined)
useImageMock.isReady = ref(false)
return {
...(actual as Record<string, unknown>),
useImage: () => ({
state: useImageMock.state,
isReady: useImageMock.isReady
})
}
})
const executionStatusMock = vi.hoisted(() => ({
message: null as Ref<string | null> | null
}))
vi.mock('@/renderer/extensions/linearMode/useExecutionStatus', async () => {
const { ref } = await import('vue')
executionStatusMock.message = ref<string | null>(null)
return {
useExecutionStatus: () => ({
executionStatusMessage: executionStatusMock.message
})
}
})
import ImagePreview from './ImagePreview.vue'
const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false })
function renderImagePreview(props: Record<string, unknown> = {}) {
return render(ImagePreview, {
props: { src: 'https://example.com/image.png', ...props },
global: {
plugins: [i18n],
stubs: {
ZoomPane: {
template: '<div data-testid="zoom-pane"><slot /></div>'
}
}
}
})
}
function setLoadedImage(width: number, height: number) {
const fakeImage = { naturalWidth: width, naturalHeight: height } as
| HTMLImageElement
| undefined
useImageMock.state!.value = fakeImage
useImageMock.isReady!.value = true
}
describe('ImagePreview (linearMode)', () => {
beforeEach(() => {
if (useImageMock.state) useImageMock.state.value = undefined
if (useImageMock.isReady) useImageMock.isReady.value = false
if (executionStatusMock.message) executionStatusMock.message.value = null
})
it('renders src inside ZoomPane in desktop mode', () => {
renderImagePreview()
expect(screen.getByTestId('zoom-pane')).toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'https://example.com/image.png'
)
})
it('renders bare img when mobile is true', () => {
renderImagePreview({ mobile: true })
expect(screen.queryByTestId('zoom-pane')).not.toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'https://example.com/image.png'
)
})
it('shows dimensions once the image is ready', async () => {
renderImagePreview()
setLoadedImage(800, 600)
await nextTick()
expect(screen.getByText('800 x 600')).toBeInTheDocument()
})
it('appends label when provided alongside dimensions', async () => {
renderImagePreview({ label: 'demo' })
setLoadedImage(64, 32)
await nextTick()
expect(screen.getByText(/64 x 32/)).toBeInTheDocument()
expect(screen.getByText(/demo/)).toBeInTheDocument()
})
it('does not show dimensions when showSize=false', async () => {
renderImagePreview({ showSize: false })
setLoadedImage(800, 600)
await nextTick()
expect(screen.queryByText('800 x 600')).not.toBeInTheDocument()
})
it('does not show dimensions before the image is ready', () => {
renderImagePreview()
expect(screen.queryByText(/x/)).not.toBeInTheDocument()
})
it('shows execution status message instead of dimensions when present', async () => {
renderImagePreview()
setLoadedImage(800, 600)
executionStatusMock.message!.value = 'Generating…'
await nextTick()
expect(screen.getByText('Generating…')).toBeInTheDocument()
expect(screen.queryByText('800 x 600')).not.toBeInTheDocument()
})
})

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { useImage } from '@vueuse/core'
import { computed } from 'vue'
import { ref, useTemplateRef } from 'vue'
import ZoomPane from '@/components/ui/ZoomPane.vue'
import { useExecutionStatus } from '@/renderer/extensions/linearMode/useExecutionStatus'
@@ -17,20 +16,15 @@ const { src, showSize = true } = defineProps<{
showSize?: boolean
}>()
const { state: imageState, isReady } = useImage(
computed(() => ({ src, alt: '' }))
)
const imageRef = useTemplateRef('imageRef')
const width = ref<number | null>(null)
const height = ref<number | null>(null)
const width = computed(() =>
showSize && isReady.value && imageState.value
? imageState.value.naturalWidth || null
: null
)
const height = computed(() =>
showSize && isReady.value && imageState.value
? imageState.value.naturalHeight || null
: null
)
function onImageLoad() {
if (!imageRef.value || !showSize) return
width.value = imageRef.value.naturalWidth
height.value = imageRef.value.naturalHeight
}
</script>
<template>
<ZoomPane
@@ -38,9 +32,21 @@ const height = computed(() =>
v-slot="slotProps"
:class="cn('w-full flex-1', $attrs.class as string)"
>
<img :src v-bind="slotProps" class="size-full object-contain" />
<img
ref="imageRef"
:src
v-bind="slotProps"
class="size-full object-contain"
@load="onImageLoad"
/>
</ZoomPane>
<img v-else class="grow object-contain contain-size" :src />
<img
v-else
ref="imageRef"
class="grow object-contain contain-size"
:src
@load="onImageLoad"
/>
<span
v-if="executionStatusMessage"
class="animate-pulse self-center text-muted md:z-10"

View File

@@ -12,7 +12,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { toNodeId } from '@/types/nodeId'
const billingMock = vi.hoisted(() => ({
isActiveSubscription: true
canRunWorkflows: true
}))
const overlayMock = vi.hoisted(() => ({
@@ -22,7 +22,7 @@ const overlayMock = vi.hoisted(() => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: billingMock.isActiveSubscription
canRunWorkflows: billingMock.canRunWorkflows
})
}))
@@ -77,14 +77,14 @@ const nodeErrors: Record<string, NodeError> = {
function renderControls({
hasError = false,
isActiveSubscription = true,
canRunWorkflows = true,
mobile = false
}: {
hasError?: boolean
isActiveSubscription?: boolean
canRunWorkflows?: boolean
mobile?: boolean
} = {}) {
billingMock.isActiveSubscription = isActiveSubscription
billingMock.canRunWorkflows = canRunWorkflows
const pinia = createTestingPinia({
createSpy: vi.fn,
@@ -120,7 +120,7 @@ function renderControls({
describe('LinearControls', () => {
beforeEach(() => {
vi.clearAllMocks()
billingMock.isActiveSubscription = true
billingMock.canRunWorkflows = true
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
overlayMock.overlayTitle = 'Required input missing'
})
@@ -187,7 +187,7 @@ describe('LinearControls', () => {
({ mobile }) => {
renderControls({
hasError: true,
isActiveSubscription: false,
canRunWorkflows: false,
mobile
})

View File

@@ -11,6 +11,7 @@ import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -28,7 +29,7 @@ const { t } = useI18n()
const commandStore = useCommandStore()
const { batchCount } = storeToRefs(useQueueSettingsStore())
const settingStore = useSettingStore()
const { isActiveSubscription } = useBillingContext()
const { canRunWorkflows } = useBillingContext()
const workflowStore = useWorkflowStore()
const { isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
@@ -54,7 +55,7 @@ const linearRunButtonTestId = 'linear-run-button'
const showRunErrorWarning = computed(
() =>
hasAnyError.value &&
toValue(isActiveSubscription) &&
toValue(canRunWorkflows) &&
toValue(overlayMessage).trim().length > 0
)
@@ -152,10 +153,7 @@ function handleDragDrop() {
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="mt-4 w-full"
/>
<SubscribeToRunButton v-if="!canRunWorkflows" class="mt-4 w-full" />
<div v-else class="mt-4 flex">
<PartnerNodesList mobile />
<Popover side="top" @open-auto-focus.prevent>
@@ -210,10 +208,7 @@ function handleDragDrop() {
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
class="h-7 min-w-40"
/>
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="mt-4 w-full"
/>
<SubscribeToRunButton v-if="!canRunWorkflows" class="mt-4 w-full" />
<Button
v-else
variant="primary"
@@ -229,6 +224,7 @@ function handleDragDrop() {
<i aria-hidden="true" class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
<FreeTierQuota />
</section>
</div>
</div>

View File

@@ -4,33 +4,17 @@ import {
CollapsibleRoot,
CollapsibleTrigger
} from 'reka-ui'
import { computed, toValue } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
import { useCreditsBadgesInGraph } from '@/composables/node/usePriceBadge'
import PartnerNodeItem from '@/renderer/extensions/linearMode/PartnerNodeItem.vue'
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import { app } from '@/scripts/app'
import { mapAllNodes } from '@/utils/graphTraversalUtil'
defineProps<{ mobile?: boolean }>()
const { isCreditsBadge } = usePriceBadge()
const creditsBadges = useCreditsBadgesInGraph()
const { t } = useI18n()
const creditsBadges = computed(() =>
mapAllNodes(app.graph, (node) => {
if (node.isSubgraphNode()) return
const priceBadge = node.badges.find(isCreditsBadge)
if (!priceBadge) return
trackNodePrice(node)
return [node.title, toValue(priceBadge).text, node.id] as const
})
)
</script>
<template>
<Popover v-if="mobile && creditsBadges.length" side="top">

View File

@@ -4,24 +4,9 @@ import { createTestingPinia } from '@pinia/testing'
import { render, screen, fireEvent } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
const useImageMock = vi.hoisted(() => ({
error: null as Ref<unknown> | null
}))
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
const { ref } = await import('vue')
useImageMock.error = ref<unknown>(null)
return {
...(actual as Record<string, unknown>),
useImage: () => ({ error: useImageMock.error })
}
})
import { downloadFile } from '@/base/common/downloadUtil'
import ImagePreview from '@/renderer/extensions/vueNodes/components/ImagePreview.vue'
@@ -100,7 +85,6 @@ describe('ImagePreview', () => {
}
beforeEach(() => {
if (useImageMock.error) useImageMock.error.value = null
vi.clearAllMocks()
})
@@ -179,7 +163,7 @@ describe('ImagePreview', () => {
screen.getByRole('button', { name: 'Download image' })
).toBeInTheDocument()
useImageMock.error!.value = new Error('failed to load')
await fireEvent.error(screen.getByTestId('main-image'))
await nextTick()
expect(

View File

@@ -99,6 +99,7 @@
draggable="false"
class="pointer-events-none absolute inset-0 block size-full object-contain"
@load="handleImageLoad"
@error="handleImageError"
/>
<!-- Floating Action Buttons (appear on hover and focus) -->
@@ -194,7 +195,7 @@
</template>
<script setup lang="ts">
import { useElementSize, useImage, useTimeoutFn } from '@vueuse/core'
import { useElementSize, useTimeoutFn } from '@vueuse/core'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -242,6 +243,7 @@ const currentIndex = ref(0)
const viewMode = ref<ViewMode>(defaultViewMode(imageUrls))
const galleryPanelEl = ref<HTMLDivElement>()
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const showLoader = ref(false)
const imageAspectRatio = ref(1)
@@ -269,20 +271,6 @@ const gridCols = computed(() => {
return Math.max(Math.round(Math.sqrt(imageUrls.length * bias)), 1)
})
// Use useImage for error detection only. Load handling stays on the rendered
// <img> @load handler so syncLegacyNodeImgs receives the actual DOM element.
const { error: imageError } = useImage(
computed(() => ({ src: currentImageUrl.value, alt: imageAltText.value }))
)
watch(imageError, (err) => {
if (err) {
stopDelayedLoader()
showLoader.value = false
actualDimensions.value = null
}
})
watch(
() => imageUrls,
(newUrls, oldUrls) => {
@@ -299,11 +287,11 @@ watch(
currentIndex.value = 0
}
// Reset loading and dimensions when URLs change. `imageError` is reset
// automatically by `useImage` when the source changes.
// Reset loading and error states when URLs change
actualDimensions.value = null
viewMode.value = defaultViewMode(newUrls)
imageError.value = false
if (newUrls.length > 0) startDelayedLoader()
},
{ immediate: true }
@@ -314,6 +302,7 @@ function handleImageLoad(event: Event) {
const img = event.target
stopDelayedLoader()
showLoader.value = false
imageError.value = false
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}
@@ -331,6 +320,13 @@ function updateAspectRatio(event: Event, index: number) {
}
}
function handleImageError() {
stopDelayedLoader()
showLoader.value = false
imageError.value = true
actualDimensions.value = null
}
function handleEditMask() {
if (!nodeId) return
const node = resolveNode(nodeId)
@@ -355,6 +351,7 @@ function setCurrentIndex(index: number) {
if (index >= 0 && index < imageUrls.length) {
const urlChanged = imageUrls[index] !== currentImageUrl.value
currentIndex.value = index
imageError.value = false
if (urlChanged) startDelayedLoader()
}
}

View File

@@ -1,32 +1,9 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { fireEvent, render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
const useImageMock = vi.hoisted(() => ({
state: null as Ref<HTMLImageElement | undefined> | null,
isReady: null as Ref<boolean> | null,
error: null as Ref<unknown> | null
}))
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
const { ref } = await import('vue')
useImageMock.state = ref<HTMLImageElement | undefined>(undefined)
useImageMock.isReady = ref(false)
useImageMock.error = ref<unknown>(null)
return {
...(actual as Record<string, unknown>),
useImage: () => ({
state: useImageMock.state,
isReady: useImageMock.isReady,
error: useImageMock.error
})
}
})
import LivePreview from '@/renderer/extensions/vueNodes/components/LivePreview.vue'
const i18n = createI18n({
@@ -44,19 +21,6 @@ const i18n = createI18n({
}
})
function makeFakeLoadedImage(width: number, height: number): HTMLImageElement {
const img = new Image()
Object.defineProperty(img, 'naturalWidth', {
configurable: true,
value: width
})
Object.defineProperty(img, 'naturalHeight', {
configurable: true,
value: height
})
return img
}
describe('LivePreview', () => {
const defaultProps = {
imageUrl: '/api/view?filename=test_sample.png&type=temp'
@@ -79,12 +43,6 @@ describe('LivePreview', () => {
})
}
beforeEach(() => {
useImageMock.state!.value = undefined
useImageMock.isReady!.value = false
useImageMock.error!.value = null
})
it('renders preview when imageUrl provided', () => {
renderLivePreview()
@@ -116,62 +74,54 @@ describe('LivePreview', () => {
it('handles image load event', async () => {
const { container } = renderLivePreview()
const img = screen.getByRole('img')
useImageMock.state!.value = makeFakeLoadedImage(512, 512)
useImageMock.isReady!.value = true
Object.defineProperty(img, 'naturalWidth', {
writable: false,
value: 512
})
Object.defineProperty(img, 'naturalHeight', {
writable: false,
value: 512
})
await fireEvent.load(img)
await nextTick()
expect(container.textContent).toContain('512 x 512')
})
it('keeps last good dimensions when imageUrl changes (no flicker)', async () => {
const { container, rerender } = renderLivePreview()
useImageMock.state!.value = makeFakeLoadedImage(800, 600)
useImageMock.isReady!.value = true
await nextTick()
expect(container.textContent).toContain('800 x 600')
// Simulate the source changing during live preview streaming. useImage
// would normally reset isReady to false until the next image is ready.
useImageMock.isReady!.value = false
await rerender({
imageUrl: '/api/view?filename=test_sample_2.png&type=temp'
})
await nextTick()
// Dimensions should still display, not flicker back to "Calculating".
expect(container.textContent).toContain('800 x 600')
expect(container.textContent).not.toContain('Calculating dimensions')
})
it('handles image error state', async () => {
renderLivePreview()
useImageMock.error!.value = new Event('error')
const img = screen.getByRole('img')
await fireEvent.error(img)
await nextTick()
expect(screen.queryByRole('img')).not.toBeInTheDocument()
screen.getByText('Image failed to load')
})
it('resets error state when imageUrl changes', async () => {
it('resets state when imageUrl changes', async () => {
const { container, rerender } = renderLivePreview()
const img = screen.getByRole('img')
useImageMock.error!.value = new Event('error')
await fireEvent.error(img)
await nextTick()
expect(container.textContent).toContain('Error loading image')
// useImage resets error automatically when src changes.
useImageMock.error!.value = null
await rerender({ imageUrl: '/new-image.png' })
await nextTick()
expect(container.textContent).toContain('Calculating dimensions')
expect(container.textContent).not.toContain('Error loading image')
})
it('shows error state when image fails to load', async () => {
const { container } = renderLivePreview()
useImageMock.error!.value = new Event('error')
const img = screen.getByRole('img')
await fireEvent.error(img)
await nextTick()
expect(screen.queryByRole('img')).not.toBeInTheDocument()

View File

@@ -12,6 +12,8 @@
:src="imageUrl"
:alt="$t('g.liveSamplingPreview')"
class="pointer-events-none min-h-55 w-full flex-1 object-contain contain-size"
@load="handleImageLoad"
@error="handleImageError"
/>
<div class="text-node-component-header-text mt-1 text-center text-xs">
{{
@@ -24,9 +26,7 @@
</template>
<script setup lang="ts">
import { useImage } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ref, watch } from 'vue'
interface LivePreviewProps {
imageUrl: string
@@ -34,36 +34,29 @@ interface LivePreviewProps {
const props = defineProps<LivePreviewProps>()
const { t } = useI18n()
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const {
state: imageState,
isReady,
error
} = useImage(
computed(() => ({ src: props.imageUrl, alt: t('g.liveSamplingPreview') }))
)
// Cache last successfully loaded dimensions so the placeholder text does not
// flicker back to "Calculating dimensions" each time `imageUrl` changes during
// live preview streaming. Update only when a new image is ready, never on
// URL change alone.
const cachedWidth = ref<number | null>(null)
const cachedHeight = ref<number | null>(null)
watch([isReady, imageState], ([ready, img]) => {
if (!ready || !img) return
if (img.naturalWidth && img.naturalHeight) {
cachedWidth.value = img.naturalWidth
cachedHeight.value = img.naturalHeight
watch(
() => props.imageUrl,
() => {
// Reset error state when URL changes, but keep previous dimensions
// to avoid flickering "Calculating dimensions" text during live preview
imageError.value = false
}
})
const imageError = computed(() => !!error.value)
const actualDimensions = computed(() =>
cachedWidth.value && cachedHeight.value
? `${cachedWidth.value} x ${cachedHeight.value}`
: null
)
const handleImageLoad = (event: Event) => {
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
imageError.value = false
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}
}
const handleImageError = () => {
imageError.value = true
actualDimensions.value = null
}
</script>

View File

@@ -32,6 +32,7 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { registerQueuePromptGuard } from '@/services/queuePromptGuardService'
const {
mockApiKeyAuthStore,
@@ -189,6 +190,93 @@ describe('ComfyApp', () => {
})
describe('queuePrompt', () => {
it('blocks before adding a disallowed prompt to the client queue', async () => {
const unregisterGuard = registerQueuePromptGuard(
'app.test.before-queue',
() => false
)
const dispatchEvent = vi
.spyOn(api, 'dispatchCustomEvent')
.mockImplementation(() => true)
const queuePrompt = vi.spyOn(api, 'queuePrompt')
try {
await expect(app.queuePrompt(0)).resolves.toBe(false)
} finally {
unregisterGuard()
}
expect(mockAuthStore.getAuthToken).not.toHaveBeenCalled()
expect(dispatchEvent).not.toHaveBeenCalled()
expect(queuePrompt).not.toHaveBeenCalled()
})
it('checks again after deferred authentication resolves', async () => {
const graph = new LGraph()
Reflect.set(app, 'rootGraphInternal', graph)
let resolveAuth: (token: string | undefined) => void = () => {}
mockAuthStore.getAuthToken.mockReturnValue(
new Promise((resolve) => {
resolveAuth = resolve
})
)
let guardCalls = 0
const unregisterGuard = registerQueuePromptGuard(
'app.test.after-auth',
() => ++guardCalls === 1
)
const queuePrompt = vi.spyOn(api, 'queuePrompt')
try {
const result = app.queuePrompt(0)
await vi.waitFor(() => {
expect(mockAuthStore.getAuthToken).toHaveBeenCalledOnce()
})
resolveAuth(undefined)
await expect(result).resolves.toBe(false)
} finally {
unregisterGuard()
}
expect(guardCalls).toBe(2)
expect(queuePrompt).not.toHaveBeenCalled()
})
it('dispatches queued progress when a later batch item is blocked', async () => {
const graph = new LGraph()
Reflect.set(app, 'rootGraphInternal', graph)
vi.spyOn(app, 'graphToPrompt').mockResolvedValue({
output: {},
workflow: createWorkflowGraphData()
})
vi.spyOn(api, 'queuePrompt').mockResolvedValue({
prompt_id: 'job-1',
node_errors: {},
error: ''
})
const dispatchEvent = vi
.spyOn(api, 'dispatchCustomEvent')
.mockImplementation(() => true)
let guardCalls = 0
const unregisterGuard = registerQueuePromptGuard(
'app.test.partial-batch',
() => ++guardCalls < 3
)
try {
await expect(app.queuePrompt(0, 2)).resolves.toBe(false)
} finally {
unregisterGuard()
}
expect(api.queuePrompt).toHaveBeenCalledOnce()
expect(dispatchEvent).toHaveBeenCalledWith('promptQueued', {
number: 0,
batchCount: 1,
requestId: 1
})
})
it('shows the error overlay for successful prompt responses with node errors', async () => {
const graph = new LGraph()
const workflow = new ComfyWorkflow({

View File

@@ -24,6 +24,7 @@ import { snapPoint } from '@/lib/litegraph/src/measure'
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -67,6 +68,7 @@ import { resolveAccountPrecondition } from '@/platform/errorCatalog/accountPreco
import { useDialogService } from '@/services/dialogService'
import { useExtensionService } from '@/services/extensionService'
import { useLitegraphService } from '@/services/litegraphService'
import { runQueuePromptGuards } from '@/services/queuePromptGuardService'
import { useSubgraphService } from '@/services/subgraphService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -1610,11 +1612,17 @@ export class ComfyApp {
})
}
private async isQueuePromptAllowed(): Promise<boolean> {
return await runQueuePromptGuards(this)
}
async queuePrompt(
number: number,
batchCount: number = 1,
queueNodeIds?: NodeExecutionId[]
): Promise<boolean> {
if (!(await this.isQueuePromptAllowed())) return false
const requestId = this.nextQueueRequestId++
this.queueItems.push({ number, batchCount, queueNodeIds, requestId })
api.dispatchCustomEvent('promptQueueing', {
@@ -1647,6 +1655,18 @@ export class ComfyApp {
const isPartialExecution = !!queueNodeIds?.length
for (let i = 0; i < batchCount; i++) {
if (!(await this.isQueuePromptAllowed())) {
this.queueItems.length = 0
if (queuedCount > 0) {
api.dispatchCustomEvent('promptQueued', {
number,
batchCount: queuedCount,
requestId
})
}
return false
}
// Allow widgets to run callbacks before a prompt has been queued
// e.g. random seed before every gen
forEachNode(this.rootGraph, (node) => {
@@ -1813,6 +1833,7 @@ export class ComfyApp {
isPartialExecution
})
}
useFreeTierQuota().trackRun()
this.canvas.draw(true, true)
await this.ui.queue.update()
}

View File

@@ -0,0 +1,123 @@
/**
* showTopUpCreditsDialog must route team members (who cannot top up) to the
* read-only contact-admin notice instead of the purchase dialog, while
* owners/personal/legacy users keep the purchase flow.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const showDialog = vi.hoisted(() => vi.fn())
const closeDialog = vi.hoisted(() => vi.fn())
const state = vi.hoisted(() => ({
isActiveSubscription: true,
isFreeTier: false,
type: 'workspace' as 'workspace' | 'legacy',
canTopUp: true
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ showDialog, closeDialog })
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackEvent: vi.fn() })
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: { value: state.isActiveSubscription },
isFreeTier: { value: state.isFreeTier },
type: { value: state.type }
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: { value: { canTopUp: state.canTopUp } }
})
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: vi.fn() })
}))
const showSubscriptionDialog = vi.hoisted(() => vi.fn())
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({ show: showSubscriptionDialog })
})
)
import { useDialogService } from '@/services/dialogService'
describe('showTopUpCreditsDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
state.isActiveSubscription = true
state.isFreeTier = false
state.type = 'workspace'
state.canTopUp = true
})
it('shows the purchase dialog to users who can top up', async () => {
await useDialogService().showTopUpCreditsDialog({
isInsufficientCredits: true
})
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('top-up-credits')
})
it('shows the contact-admin notice to team members instead of the purchase dialog', async () => {
state.canTopUp = false
await useDialogService().showTopUpCreditsDialog({
isInsufficientCredits: true
})
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('insufficient-credits-member')
// The member notice draws its own header + close button, so it must open
// headless or Reka wraps it in duplicate chrome.
expect(args.dialogComponentProps.headless).toBe(true)
expect(args.dialogComponentProps.renderer).toBe('reka')
args.props.onClose()
expect(closeDialog).toHaveBeenCalledWith({
key: 'insufficient-credits-member'
})
})
it('ignores workspace permissions on legacy billing', async () => {
state.type = 'legacy'
state.canTopUp = false
await useDialogService().showTopUpCreditsDialog()
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('top-up-credits')
})
it('routes a member of an inactive team to the subscription-required flow, not the credits notice', async () => {
state.isActiveSubscription = false
state.canTopUp = false
await useDialogService().showTopUpCreditsDialog({
isInsufficientCredits: true
})
expect(showSubscriptionDialog).toHaveBeenCalledWith({
reason: 'out_of_credits'
})
expect(showDialog).not.toHaveBeenCalled()
})
})

View File

@@ -5,7 +5,9 @@ import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationD
import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue'
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
import TopUpCreditsDialogContentLegacy from '@/components/dialog/content/TopUpCreditsDialogContentLegacy.vue'
import InsufficientCreditsMemberDialog from '@/platform/workspace/components/InsufficientCreditsMemberDialog.vue'
import TopUpCreditsDialogContentWorkspace from '@/platform/workspace/components/TopUpCreditsDialogContentWorkspace.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { t } from '@/i18n'
import { useTelemetry } from '@/platform/telemetry'
import { isCloud } from '@/platform/distribution/types'
@@ -339,6 +341,28 @@ export const useDialogService = () => {
return
}
// Members can't top up a team workspace, so they get a read-only
// "ask your workspace admins" notice instead of the purchase dialog.
if (
type.value === 'workspace' &&
!useWorkspaceUI().permissions.value.canTopUp
) {
return dialogStore.showDialog({
key: 'insufficient-credits-member',
component: InsufficientCreditsMemberDialog,
props: {
onClose: () =>
dialogStore.closeDialog({ key: 'insufficient-credits-member' })
},
dialogComponentProps: {
renderer: 'reka',
headless: true,
contentClass:
'w-[min(360px,95vw)] max-w-[min(360px,95vw)] sm:max-w-[min(360px,95vw)] border-0 bg-transparent shadow-none'
}
})
}
const component =
type.value === 'workspace'
? TopUpCreditsDialogContentWorkspace

View File

@@ -0,0 +1,30 @@
import type { LGraph } from '@/lib/litegraph/src/litegraph'
export interface QueuePromptGuardContext {
readonly rootGraph: LGraph
}
export type QueuePromptGuard = (
context: QueuePromptGuardContext
) => boolean | Promise<boolean>
const guards = new Map<string, QueuePromptGuard>()
export function registerQueuePromptGuard(
id: string,
guard: QueuePromptGuard
): () => void {
guards.set(id, guard)
return () => {
if (guards.get(id) === guard) guards.delete(id)
}
}
export async function runQueuePromptGuards(
context: QueuePromptGuardContext
): Promise<boolean> {
const results = await Promise.all(
[...guards.values()].map((guard) => guard(context))
)
return results.every((result) => result !== false)
}

View File

@@ -1,6 +1,7 @@
import axios from 'axios'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -10,7 +11,7 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import type { NodeDefFilter } from '@/stores/nodeDefStore'
describe('useNodeDefStore', () => {
@@ -21,6 +22,10 @@ describe('useNodeDefStore', () => {
store = useNodeDefStore()
})
afterEach(() => {
vi.restoreAllMocks()
})
const createMockNodeDef = (
overrides: Partial<ComfyNodeDef> = {}
): ComfyNodeDef => ({
@@ -286,6 +291,30 @@ describe('useNodeDefStore', () => {
})
})
describe('node frequency visibility', () => {
it('excludes node definitions hidden by registered filters', async () => {
vi.spyOn(axios, 'get').mockResolvedValue({
data: { HiddenNode: 2, VisibleNode: 1 }
})
store.updateNodeDefs([
createMockNodeDef({ name: 'HiddenNode' }),
createMockNodeDef({ name: 'VisibleNode' })
])
store.registerNodeDefFilter({
id: 'test.hide-node',
name: 'Hide node',
predicate: (nodeDef) => nodeDef.name !== 'HiddenNode'
})
const frequencyStore = useNodeFrequencyStore()
await frequencyStore.loadNodeFrequencies()
expect(frequencyStore.topNodeDefs.map((nodeDef) => nodeDef.name)).toEqual(
['VisibleNode']
)
})
})
describe('allNodeDefsByName', () => {
it('should include all node defs by name', () => {
const node1 = createMockNodeDef({ name: 'Node1' })

View File

@@ -562,9 +562,15 @@ export const useNodeFrequencyStore = defineStore('nodeFrequency', () => {
const nodeDefStore = useNodeDefStore()
const topNodeDefs = computed<ComfyNodeDefImpl[]>(() => {
const visibleNodeNames = new Set(
nodeDefStore.visibleNodeDefs.map((nodeDef) => nodeDef.name)
)
return nodeNamesByFrequency.value
.map((nodeName: string) => nodeDefStore.nodeDefsByName[nodeName])
.filter((nodeDef: ComfyNodeDefImpl) => nodeDef !== undefined)
.filter(
(nodeDef): nodeDef is ComfyNodeDefImpl =>
nodeDef !== undefined && visibleNodeNames.has(nodeDef.name)
)
.slice(0, topNodeDefLimit.value)
})

View File

@@ -2,14 +2,42 @@ import { computed, ref } from 'vue'
import type { BillingContext } from '@/composables/billing/types'
type Subscription = BillingContext['subscription']['value']
/** The billing state a story wants the stub to report. */
export interface BillingContextMockState {
subscription: Subscription
isActiveSubscription: boolean
isTeamPlan: boolean
billingStatus: BillingContext['billingStatus']['value']
subscriptionStatus: BillingContext['subscriptionStatus']['value']
renewalDate: string | null
}
const defaultState: BillingContextMockState = {
subscription: null,
isActiveSubscription: false,
isTeamPlan: false,
billingStatus: null,
subscriptionStatus: null,
renewalDate: null
}
const state = ref<BillingContextMockState>({ ...defaultState })
/** Drives the stub from a story's `beforeEach`. */
export function setBillingContextMock(next: Partial<BillingContextMockState>) {
state.value = { ...defaultState, ...next }
}
/**
* Storybook mock for `useBillingContext`.
*
* The real facade lazily instantiates the legacy billing adapter, which pulls
* in Firebase auth (`setPersistence`) and crashes in the Storybook environment
* (no Firebase). This static stub lets presentational billing components — e.g.
* UnifiedPricingTable — render against their `TIER_PRICING` / DES-197 fallbacks
* without any network or auth.
* (no Firebase). This stub lets billing components — e.g. UnifiedPricingTable,
* BillingStatusBanner — render without any network or auth. It defaults to the
* unsubscribed state; call `setBillingContextMock` to drive a specific one.
*
* Typed against `BillingContext` so the stub stays in lockstep with the real
* composable's return shape: drifted or removed keys fail to compile.
@@ -18,7 +46,7 @@ export function useBillingContext(): BillingContext {
return {
type: computed(() => 'legacy' as const),
isInitialized: ref(true),
subscription: computed(() => null),
subscription: computed(() => state.value.subscription),
balance: computed(() => null),
plans: computed(() => []),
currentPlanSlug: computed(() => null),
@@ -26,13 +54,15 @@ export function useBillingContext(): BillingContext {
currentTeamCreditStop: computed(() => null),
isLoading: ref(false),
error: ref<string | null>(null),
isActiveSubscription: computed(() => false),
isActiveSubscription: computed(() => state.value.isActiveSubscription),
canRunWorkflows: computed(() => state.value.isActiveSubscription),
isFreeTier: computed(() => false),
isLegacyTeamPlan: computed(() => false),
billingStatus: computed(() => null),
subscriptionStatus: computed(() => null),
isTeamPlan: computed(() => state.value.isTeamPlan),
billingStatus: computed(() => state.value.billingStatus),
subscriptionStatus: computed(() => state.value.subscriptionStatus),
tier: computed(() => null),
renewalDate: computed(() => null),
renewalDate: computed(() => state.value.renewalDate),
getMaxSeats: (tierKey: string) => ({ creator: 5, pro: 20 })[tierKey] ?? 1,
initialize: async () => {},
fetchStatus: async () => {},

View File

@@ -26,14 +26,14 @@ export enum ServerFeatureFlag {
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled'
BILLING_CONTROL_ENABLED = 'billing_control_enabled'
}
export function useFeatureFlags() {
return {
flags: {
teamWorkspacesEnabled: true,
consolidatedBillingEnabled: true
billingControlEnabled: true
}
}
}

View File

@@ -0,0 +1,45 @@
import { computed, ref } from 'vue'
import type { WorkspaceType } from '@/platform/workspace/api/workspaceApi'
/** The workspace role/permission state a story wants the stub to report. */
export interface WorkspaceUIMockState {
workspaceType: WorkspaceType
canManageSubscription: boolean
canManageSubscriptionLifecycle: boolean
canTopUp: boolean
}
const defaultState: WorkspaceUIMockState = {
workspaceType: 'team',
canManageSubscription: true,
canManageSubscriptionLifecycle: true,
canTopUp: true
}
const state = ref<WorkspaceUIMockState>({ ...defaultState })
/** Drives the stub from a story's `beforeEach`. */
export function setWorkspaceUIMock(next: Partial<WorkspaceUIMockState>) {
state.value = { ...defaultState, ...next }
}
/**
* Storybook mock for `useWorkspaceUI`.
*
* The real composable derives permissions from `useCurrentUser` (Firebase auth)
* and the team workspace store, neither of which is available in Storybook. This
* stub exposes only the role surface the billing banner reads; add keys here as
* other stories need them.
*/
export function useWorkspaceUI() {
return {
workspaceType: computed(() => state.value.workspaceType),
permissions: computed(() => ({
canManageSubscription: state.value.canManageSubscription,
canManageSubscriptionLifecycle:
state.value.canManageSubscriptionLifecycle,
canTopUp: state.value.canTopUp
}))
}
}

View File

@@ -1,95 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { components } from '@/types/comfyRegistryTypes'
const useImageMock = vi.hoisted(() => ({
error: null as Ref<unknown> | null
}))
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
const { ref } = await import('vue')
useImageMock.error = ref<unknown>(null)
return {
...(actual as Record<string, unknown>),
useImage: () => ({ error: useImageMock.error })
}
})
import PackBanner from './PackBanner.vue'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
defaultBanner: 'Default banner'
}
}
}
})
function makePack(
overrides: Partial<components['schemas']['Node']> = {}
): components['schemas']['Node'] {
return {
id: 'pack-id',
name: 'TestPack',
...overrides
} as components['schemas']['Node']
}
function renderPackBanner(nodePack: components['schemas']['Node']) {
return render(PackBanner, {
props: { nodePack },
global: { plugins: [i18n] }
})
}
describe('PackBanner', () => {
beforeEach(() => {
if (useImageMock.error) useImageMock.error.value = null
})
it('renders the default banner when both banner_url and icon are missing', () => {
renderPackBanner(makePack())
const img = screen.getByRole('img')
expect(img).toHaveAttribute('src', DEFAULT_BANNER)
expect(img).toHaveAttribute('alt', 'Default banner')
})
it('renders the banner_url image when provided', () => {
renderPackBanner(makePack({ banner_url: 'https://example.com/banner.png' }))
const img = screen.getByRole('img')
expect(img).toHaveAttribute('src', 'https://example.com/banner.png')
expect(img).toHaveAttribute('alt', 'TestPack banner')
})
it('falls back to icon when banner_url is missing but icon is set', () => {
renderPackBanner(makePack({ icon: 'https://example.com/icon.svg' }))
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'https://example.com/icon.svg'
)
})
it('falls back to default banner when image fails to load', async () => {
renderPackBanner(makePack({ banner_url: 'https://example.com/broken.png' }))
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'https://example.com/broken.png'
)
useImageMock.error!.value = new Event('error')
await nextTick()
expect(screen.getByRole('img')).toHaveAttribute('src', DEFAULT_BANNER)
})
})

View File

@@ -12,7 +12,7 @@
<div v-else class="relative size-full">
<!-- blur background -->
<div
v-if="imgSrc && !isImageError"
v-if="imgSrc"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
:style="{
backgroundImage: `url(${imgSrc})`,
@@ -21,24 +21,21 @@
></div>
<!-- image -->
<img
v-if="isImageError"
:src="DEFAULT_BANNER"
:alt="bannerAlt"
class="relative z-10 size-full object-cover"
/>
<img
v-else
:src="imgSrc"
:alt="bannerAlt"
class="relative z-10 size-full object-contain"
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="nodePack.name + ' banner'"
:class="
isImageError
? 'relative z-10 size-full object-cover'
: 'relative z-10 size-full object-contain'
"
@error="isImageError = true"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useImage } from '@vueuse/core'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
@@ -48,11 +45,8 @@ const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon || '')
const bannerAlt = computed(() => `${nodePack.name} banner`)
const isImageError = ref(false)
const { error: isImageError } = useImage(
computed(() => ({ src: imgSrc.value, alt: bannerAlt.value }))
)
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
</script>