mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-27 01:57:17 +00:00
Compare commits
24 Commits
codex/cove
...
feat/adr-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e91a60cf9 | ||
|
|
06cbb44a57 | ||
|
|
5237112114 | ||
|
|
289ea0562c | ||
|
|
ce4a53c498 | ||
|
|
9ed93698a0 | ||
|
|
cfbc378df3 | ||
|
|
db085eb7a1 | ||
|
|
48d429bd13 | ||
|
|
6eaad99502 | ||
|
|
2ced8a25d4 | ||
|
|
9209a4b923 | ||
|
|
90cb8021df | ||
|
|
5a01c5b3b4 | ||
|
|
e3049e7c31 | ||
|
|
87e84e7280 | ||
|
|
67009dcda2 | ||
|
|
026b2c4795 | ||
|
|
d60260ac3c | ||
|
|
0c89f5a3a7 | ||
|
|
f19597ce81 | ||
|
|
988dc71955 | ||
|
|
da55529d23 | ||
|
|
52d430d1b6 |
@@ -2,6 +2,7 @@ issue_enrichment:
|
||||
auto_enrich:
|
||||
enabled: true
|
||||
reviews:
|
||||
profile: assertive
|
||||
high_level_summary: false
|
||||
request_changes_workflow: true
|
||||
auto_review:
|
||||
|
||||
21
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
21
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -133,3 +133,24 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No Customer.io references found'
|
||||
|
||||
- name: Scan dist for Cloudflare Turnstile sitekey references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Scanning for Cloudflare Turnstile sitekeys...'
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
-e '0x4AAAAAADnYZPVOpFCL_zeo' \
|
||||
-e '0x4AAAAAADnYY4_Q0qxHZ5a7' \
|
||||
-e '1x00000000000000000000AA' \
|
||||
dist; then
|
||||
echo '❌ ERROR: Cloudflare Turnstile sitekey found in dist assets!'
|
||||
echo 'The per-env Turnstile sitekeys are cloud-only and must be tree-shaken from OSS builds.'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Gate sitekey selection on the __DISTRIBUTION__ build define, not the runtime isCloud const'
|
||||
echo '2. See getTurnstileSiteKey() in src/config/turnstile.ts'
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No Turnstile sitekey references found'
|
||||
|
||||
2
.github/workflows/cla.yml
vendored
2
.github/workflows/cla.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
|
||||
# Allowlist bots so they don't need to sign (optional, comma-separated).
|
||||
# *[bot] is a catch-all for any GitHub App bot account.
|
||||
allowlist: actions-user,ampagent,claude,coderabbitai[bot],comfy-pr-bot,dependabot[bot],github-actions[bot],copilot-swe-agent[bot],devin-ai-integration[bot],*[bot]
|
||||
allowlist: actions-user,ampagent,claude,comfy-pr-bot,github-actions,*[bot],Glary Bot
|
||||
|
||||
# Custom PR comment messages
|
||||
custom-notsigned-prcomment: |
|
||||
|
||||
@@ -83,6 +83,16 @@ const config: StorybookConfig = {
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
|
||||
},
|
||||
{
|
||||
find: '@/composables/useFeatureFlags',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/stores/teamWorkspaceStore',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/teamWorkspaceStore.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/formatUtil',
|
||||
replacement:
|
||||
|
||||
@@ -47,6 +47,11 @@ test.describe('Download page @smoke', () => {
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
await expect(downloadBtn).toHaveAttribute('target', '_blank')
|
||||
await expect(downloadBtn).toHaveAttribute(
|
||||
'href',
|
||||
'https://comfy.org/download/windows/nsis/x64'
|
||||
)
|
||||
await expect(downloadBtn).toHaveAttribute('data-astro-prefetch', 'false')
|
||||
|
||||
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
|
||||
await expect(githubBtn).toBeVisible()
|
||||
@@ -73,7 +78,7 @@ test.describe('Download page @smoke', () => {
|
||||
})
|
||||
|
||||
const windowsBtn = hero.locator(
|
||||
'a[href="https://download.comfy.org/windows/nsis/x64"]'
|
||||
'a[href="https://comfy.org/download/windows/nsis/x64"]'
|
||||
)
|
||||
await expect(windowsBtn).toBeVisible()
|
||||
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)
|
||||
|
||||
@@ -72,6 +72,7 @@ const buttons = computed<ButtonSpec[]>(() => {
|
||||
size="lg"
|
||||
:class="customClass"
|
||||
:aria-label="btn.ariaLabel"
|
||||
:data-astro-prefetch="btn.key === 'windows' ? 'false' : undefined"
|
||||
@click="captureDownloadClick(btn.key)"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { externalLinks } from '@/config/routes'
|
||||
|
||||
export const downloadUrls = {
|
||||
windows: 'https://download.comfy.org/windows/nsis/x64',
|
||||
windows: 'https://comfy.org/download/windows/nsis/x64',
|
||||
macArm: 'https://download.comfy.org/mac/dmg/arm64'
|
||||
} as const
|
||||
|
||||
|
||||
96
browser_tests/fixtures/data/cloudWorkspace.ts
Normal file
96
browser_tests/fixtures/data/cloudWorkspace.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type {
|
||||
BillingStatusResponse,
|
||||
Member,
|
||||
Plan,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
// `/api/features` is the remote-config source: production builds resolve the
|
||||
// workspaces flag from it (the `ff:` localStorage override is dev-only).
|
||||
export const WORKSPACE_FEATURE_FLAG: RemoteConfig = {
|
||||
team_workspaces_enabled: true
|
||||
}
|
||||
|
||||
export const TEAM_WORKSPACE: WorkspaceWithRole = {
|
||||
id: 'ws-team',
|
||||
name: 'Team Comfy',
|
||||
type: 'team',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
joined_at: '2025-01-02T00:00:00Z',
|
||||
role: 'owner',
|
||||
subscription_tier: 'PRO'
|
||||
}
|
||||
|
||||
export const CREATOR: Member = {
|
||||
id: 'u-liz',
|
||||
name: 'Liz',
|
||||
email: 'liz@test.comfy.org',
|
||||
joined_at: '2025-01-01T00:00:00Z',
|
||||
role: 'owner',
|
||||
is_original_owner: true
|
||||
}
|
||||
|
||||
// Identity must match the CloudAuthHelper mock user so this row counts as
|
||||
// "(You)".
|
||||
export const VIEWER: Member = {
|
||||
id: 'u-me',
|
||||
name: 'E2E Test User',
|
||||
email: 'e2e@test.comfy.org',
|
||||
joined_at: '2025-01-02T00:00:00Z',
|
||||
role: 'owner',
|
||||
is_original_owner: false
|
||||
}
|
||||
|
||||
export const MEMBER_JANE: Member = {
|
||||
id: 'u-jane',
|
||||
name: 'Jane',
|
||||
email: 'jane@test.comfy.org',
|
||||
joined_at: '2025-01-03T00:00:00Z',
|
||||
role: 'member',
|
||||
is_original_owner: false
|
||||
}
|
||||
|
||||
export const MEMBER_JOHN: Member = {
|
||||
id: 'u-john',
|
||||
name: 'John',
|
||||
email: 'john@test.comfy.org',
|
||||
joined_at: '2025-01-04T00:00:00Z',
|
||||
role: 'member',
|
||||
is_original_owner: false
|
||||
}
|
||||
|
||||
export const DEFAULT_TEAM_MEMBERS: Member[] = [
|
||||
CREATOR,
|
||||
VIEWER,
|
||||
MEMBER_JANE,
|
||||
MEMBER_JOHN
|
||||
]
|
||||
|
||||
export const TEAM_BILLING_STATUS: BillingStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_status: 'active',
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
plan_slug: 'pro-monthly',
|
||||
billing_status: 'paid',
|
||||
has_funds: true,
|
||||
renewal_date: '2099-02-20T00:00:00Z'
|
||||
}
|
||||
|
||||
// `max_seats > 1` on the current plan is what flips `isOnTeamPlan`, which gates
|
||||
// the whole role-management UI.
|
||||
export const TEAM_PRO_PLAN: Plan = {
|
||||
slug: 'pro-monthly',
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
price_cents: 10000,
|
||||
credits_cents: 21100,
|
||||
max_seats: 30,
|
||||
availability: { available: true },
|
||||
seat_summary: {
|
||||
seat_count: 4,
|
||||
total_cost_cents: 40000,
|
||||
total_credits_cents: 0
|
||||
}
|
||||
}
|
||||
150
browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts
Normal file
150
browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { Member } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import {
|
||||
DEFAULT_TEAM_MEMBERS,
|
||||
TEAM_BILLING_STATUS,
|
||||
TEAM_PRO_PLAN,
|
||||
TEAM_WORKSPACE,
|
||||
WORKSPACE_FEATURE_FLAG
|
||||
} from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
interface RoleChangeRequest {
|
||||
url: string
|
||||
role: string
|
||||
}
|
||||
|
||||
interface MemberMockState {
|
||||
members: Member[]
|
||||
patches: RoleChangeRequest[]
|
||||
}
|
||||
|
||||
const jsonRoute = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
/**
|
||||
* Boots the cloud app against fully mocked workspace + billing endpoints so
|
||||
* member/role specs can drive a raw `page` (the `comfyPage` fixture would try
|
||||
* to reach the OSS devtools backend during setup).
|
||||
*
|
||||
* Returns the mutable mock state: `members` reflects PATCH-applied roles and
|
||||
* `patches` records every role-change request for assertion.
|
||||
*/
|
||||
export class CloudWorkspaceMockHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async setup(
|
||||
members: Member[] = DEFAULT_TEAM_MEMBERS
|
||||
): Promise<MemberMockState> {
|
||||
const state = await this.mockBoot(members)
|
||||
await new CloudAuthHelper(this.page).mockAuth()
|
||||
await this.page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
localStorage.setItem('Comfy.Workspace.LastWorkspaceId', 'ws-team')
|
||||
})
|
||||
return state
|
||||
}
|
||||
|
||||
private async mockBoot(members: Member[]): Promise<MemberMockState> {
|
||||
const state: MemberMockState = {
|
||||
members: members.map((m) => ({ ...m })),
|
||||
patches: []
|
||||
}
|
||||
const { page } = this
|
||||
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(jsonRoute(WORKSPACE_FEATURE_FLAG))
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// A non-empty settings payload with TutorialCompleted marks the user as
|
||||
// returning, so the new-user Templates dialog never auto-opens to block the
|
||||
// Settings button. Errors tab off suppresses the model-folder 401 toast.
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': false
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(jsonRoute({ workspaces: [TEAM_WORKSPACE] }))
|
||||
)
|
||||
|
||||
await page.route('**/api/workspace/members**', (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() === 'PATCH') {
|
||||
const url = request.url()
|
||||
const id = url.match(/\/api\/workspace\/members\/([^/?]+)/)?.[1]
|
||||
const { role } = request.postDataJSON() as { role: Member['role'] }
|
||||
state.patches.push({ url, role })
|
||||
const member = state.members.find((m) => m.id === id)
|
||||
if (member) member.role = role
|
||||
// Echo the updated row like the real BE; the store merges only the role
|
||||
// locally, so the response body shape is not load-bearing.
|
||||
return route.fulfill(jsonRoute(member))
|
||||
}
|
||||
return route.fulfill(
|
||||
jsonRoute({
|
||||
members: state.members,
|
||||
pagination: { offset: 0, limit: 50, total: state.members.length }
|
||||
})
|
||||
)
|
||||
})
|
||||
await page.route('**/api/workspace/invites', (r) =>
|
||||
r.fulfill(jsonRoute({ invites: [] }))
|
||||
)
|
||||
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(jsonRoute(TEAM_BILLING_STATUS))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
amount_micros: 6000,
|
||||
currency: 'usd',
|
||||
effective_balance_micros: 6000,
|
||||
cloud_credit_balance_micros: 5000,
|
||||
prepaid_balance_micros: 1000
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ current_plan_slug: 'pro-monthly', plans: [TEAM_PRO_PLAN] })
|
||||
)
|
||||
)
|
||||
|
||||
return state
|
||||
}
|
||||
}
|
||||
34
browser_tests/fixtures/utils/cloudBillingMocks.ts
Normal file
34
browser_tests/fixtures/utils/cloudBillingMocks.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
/**
|
||||
* Minimal valid billing shapes so the billing facade resolves while a
|
||||
* subscription dialog mounts. Active personal sub with zero balance.
|
||||
*/
|
||||
export async function mockBilling(page: Page) {
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_status: 'active',
|
||||
subscription_tier: 'pro',
|
||||
subscription_duration: 'MONTHLY',
|
||||
billing_status: 'paid'
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(jsonRoute({ plans: [] }))
|
||||
)
|
||||
await page.route('**/customers/cloud-subscription-status', (r) =>
|
||||
r.fulfill(jsonRoute({ is_active: false }))
|
||||
)
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
|
||||
)
|
||||
}
|
||||
64
browser_tests/fixtures/utils/cloudBootMocks.ts
Normal file
64
browser_tests/fixtures/utils/cloudBootMocks.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
interface CloudBootOptions {
|
||||
/** Remote-config payload for `/api/features` (enables the flags under test). */
|
||||
features: RemoteConfig
|
||||
/** Body for `/api/settings` (defaults to `{}`). */
|
||||
settings?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the core endpoints the cloud app hits on boot so a raw `page` reaches the
|
||||
* working app without falling through to the OSS devtools backend. Specs layer
|
||||
* their own feature- or flow-specific routes on top.
|
||||
*/
|
||||
export async function mockCloudBoot(
|
||||
page: Page,
|
||||
{ features, settings = {} }: CloudBootOptions
|
||||
) {
|
||||
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(features)))
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/user', (r) =>
|
||||
r.fulfill(jsonRoute({ status: 'active' }))
|
||||
)
|
||||
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute(settings)))
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Firebase auth and pre-select the e2e user so the cloud app boots
|
||||
* signed-in. The signed-in email (`e2e@test.comfy.org`) is what the
|
||||
* original-owner gate matches against the members self-row.
|
||||
*/
|
||||
export async function bootCloud(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
}
|
||||
12
browser_tests/fixtures/utils/jsonRoute.ts
Normal file
12
browser_tests/fixtures/utils/jsonRoute.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Build a 200 JSON body for `route.fulfill()`. Generic so callers can type the
|
||||
* payload (e.g. `jsonRoute({ ... } satisfies RemoteConfig)`) and catch contract
|
||||
* drift against the real API shape.
|
||||
*/
|
||||
export function jsonRoute<T>(body: T) {
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
68
browser_tests/fixtures/utils/workspaceMocks.ts
Normal file
68
browser_tests/fixtures/utils/workspaceMocks.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
Member,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
export function workspace(
|
||||
type: 'personal' | 'team',
|
||||
role: 'owner' | 'member'
|
||||
): WorkspaceWithRole {
|
||||
return {
|
||||
id: `ws-${type}`,
|
||||
name: type === 'team' ? 'My Team' : 'Personal Workspace',
|
||||
type,
|
||||
role,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
}
|
||||
|
||||
export function member(
|
||||
overrides: Partial<Member> & Pick<Member, 'email' | 'role'>
|
||||
): Member {
|
||||
return {
|
||||
id: `user-${overrides.email}`,
|
||||
name: overrides.email,
|
||||
joined_at: '2026-01-01T00:00:00Z',
|
||||
is_original_owner: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the workspace resolution + members list so the cloud app boots into the
|
||||
* given workspace with the given roster (drives the original-owner gate).
|
||||
*/
|
||||
export async function mockWorkspace(
|
||||
page: Page,
|
||||
ws: WorkspaceWithRole,
|
||||
members: Member[]
|
||||
) {
|
||||
await page.route('**/api/workspaces', async (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
await route.fulfill(jsonRoute({ workspaces: [ws] }))
|
||||
})
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
workspace: { id: ws.id, name: ws.name, type: ws.type },
|
||||
role: ws.role,
|
||||
permissions: []
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/workspace/members**', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
members,
|
||||
pagination: { offset: 0, limit: 50, total: members.length }
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
194
browser_tests/tests/billingFacadeConsumers.spec.ts
Normal file
194
browser_tests/tests/billingFacadeConsumers.spec.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type {
|
||||
BillingBalanceResponse,
|
||||
BillingStatusResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
/**
|
||||
* Billing facade consumers — FE-933 (B3) regression.
|
||||
*
|
||||
* The repointed surfaces (avatar popover tier badge / balance, free-tier
|
||||
* dialog renewal date) must keep rendering from `useBillingContext`. The facade
|
||||
* selects its backend by flag: `team_workspaces_enabled: false` routes through
|
||||
* the legacy `/customers/*` endpoints, while `true` routes a personal workspace
|
||||
* through the workspace `/api/billing/*` endpoints. Both shapes are mocked here.
|
||||
* Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
* against fully mocked endpoints — same pattern as creditsTile.spec.ts.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
const jsonRoute = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
// The workspace `/api/billing/status` shape mirrors the legacy subscription
|
||||
// status; map the fields so a single test fixture drives both backends.
|
||||
const toWorkspaceStatus = (
|
||||
s: CloudSubscriptionStatusResponse
|
||||
): BillingStatusResponse => ({
|
||||
is_active: s.is_active ?? false,
|
||||
subscription_tier: s.subscription_tier ?? undefined,
|
||||
subscription_duration: s.subscription_duration ?? undefined,
|
||||
renewal_date: s.renewal_date ?? undefined,
|
||||
cancel_at: s.end_date ?? undefined,
|
||||
has_funds: s.has_fund ?? true
|
||||
})
|
||||
|
||||
const mockBalance: BillingBalanceResponse = {
|
||||
amount_micros: 6000, // -> 12,660 credits
|
||||
currency: 'usd',
|
||||
effective_balance_micros: 6000
|
||||
}
|
||||
|
||||
async function mockCloudBoot(
|
||||
page: Page,
|
||||
subscriptionStatus: CloudSubscriptionStatusResponse,
|
||||
remoteConfig: RemoteConfig = { team_workspaces_enabled: false }
|
||||
) {
|
||||
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(remoteConfig)))
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// TutorialCompleted suppresses the new-user template browser, whose modal
|
||||
// overlay would otherwise intercept clicks on the topbar.
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(jsonRoute({ 'Comfy.TutorialCompleted': true }))
|
||||
)
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: 'Personal Workspace',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Legacy backend (team_workspaces_enabled: false).
|
||||
await page.route('**/customers/cloud-subscription-status', (r) =>
|
||||
r.fulfill(jsonRoute(subscriptionStatus))
|
||||
)
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(jsonRoute(mockBalance))
|
||||
)
|
||||
|
||||
// Workspace backend (team_workspaces_enabled: true) — a personal workspace
|
||||
// now routes through `/api/billing/*`.
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(jsonRoute(toWorkspaceStatus(subscriptionStatus)))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(jsonRoute(mockBalance))
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(jsonRoute({ plans: [] }))
|
||||
)
|
||||
}
|
||||
|
||||
async function bootApp(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Billing facade consumers (FE-933)', { tag: '@cloud' }, () => {
|
||||
test('avatar popover renders tier badge and balance from the facade', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-02-20T10:00:00Z',
|
||||
end_date: null
|
||||
})
|
||||
await bootApp(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
const popover = page.locator('.current-user-popover')
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
await expect(popover.getByText('Pro', { exact: true })).toBeVisible()
|
||||
await expect(popover.getByText('12,660')).toBeVisible()
|
||||
await expect(popover.getByTestId('add-credits-button')).toBeVisible()
|
||||
})
|
||||
|
||||
test('free-tier dialog shows the renewal date from the facade', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
// Boots with team workspaces enabled (production shape); the facade routes a
|
||||
// personal workspace through the workspace `/api/billing/*` endpoints. With
|
||||
// subscription gating on, an inactive FREE user gets the "Subscribe to run"
|
||||
// button, which opens the free-tier dialog on click. (refreshRemoteConfig
|
||||
// overwrites window.__CONFIG__ from /api/features, so the flags must come
|
||||
// from the features mock, not an init script.)
|
||||
await mockCloudBoot(
|
||||
page,
|
||||
{
|
||||
is_active: false,
|
||||
subscription_tier: 'FREE',
|
||||
subscription_duration: 'MONTHLY',
|
||||
// 10:00Z keeps the en-US calendar date stable across CI timezones.
|
||||
renewal_date: '2099-02-20T10:00:00Z',
|
||||
end_date: null
|
||||
},
|
||||
{ team_workspaces_enabled: true, subscription_required: true }
|
||||
)
|
||||
await bootApp(page)
|
||||
|
||||
await page.getByTestId('subscribe-to-run-button').click()
|
||||
|
||||
// T5: the dialog must source the date from facade renewalDate — when this
|
||||
// line read the legacy store it silently vanished for team users.
|
||||
await expect(
|
||||
page.getByText('Your credits refresh on Feb 20, 2099.')
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -4,8 +4,7 @@ import type { Page } from '@playwright/test'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
|
||||
/**
|
||||
* getSurveyCompletedStatus fails safe: a transient 401 on `/` must not bounce a
|
||||
@@ -16,51 +15,12 @@ import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
function jsonRoute(body: unknown) {
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
|
||||
async function mockCloudBoot(page: Page) {
|
||||
// `/api/features` is the remote-config source: production builds resolve
|
||||
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
|
||||
// dev-only). Enable the survey so the gate is actually live.
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ onboarding_survey_enabled: true } satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// Cloud user status (getUserCloudStatus) — an active account so the gate
|
||||
// proceeds to the survey check instead of bouncing back to login.
|
||||
await page.route('**/api/user', (r) =>
|
||||
r.fulfill(jsonRoute({ status: 'active' }))
|
||||
)
|
||||
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
}
|
||||
// `/api/features` is the remote-config source: production builds resolve
|
||||
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
|
||||
// dev-only). Enable the survey so the gate is actually live.
|
||||
const BOOT_FEATURES = {
|
||||
onboarding_survey_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
|
||||
// Genuine "not completed": the cloud backend returns 404 for a survey key that
|
||||
// was never stored. This is the response that must still route to the survey.
|
||||
@@ -89,22 +49,13 @@ async function mockSurveyTransient401(page: Page) {
|
||||
)
|
||||
}
|
||||
|
||||
async function bootCloud(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
// Pre-select the mock user to skip the user-select screen.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
|
||||
test('a transient 401 on the survey check does not bounce a working user to the survey', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page)
|
||||
await mockCloudBoot(page, { features: BOOT_FEATURES })
|
||||
await mockSurveyTransient401(page)
|
||||
await bootCloud(page)
|
||||
|
||||
@@ -122,9 +73,9 @@ test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
|
||||
test('a not-completed (404) user landing on / is routed to the survey', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page)
|
||||
await mockCloudBoot(page, { features: BOOT_FEATURES })
|
||||
await mockSurveyNotCompleted(page)
|
||||
await bootCloud(page)
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
|
||||
import type {
|
||||
BillingStatusResponse,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -51,6 +54,20 @@ const mockSubscriptionStatus: CloudSubscriptionStatusResponse = {
|
||||
end_date: FUTURE_DATE
|
||||
}
|
||||
|
||||
// With team workspaces enabled, the facade routes a personal workspace through
|
||||
// `/api/billing/*`. The cancelled-but-active state maps to `is_active: true`
|
||||
// with `subscription_status: 'canceled'`; a paid tier keeps "Add credits"
|
||||
// visible (free tier would swap it for "Upgrade to add credits").
|
||||
const mockBillingStatus: BillingStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_status: 'canceled',
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
has_funds: true,
|
||||
cancel_at: FUTURE_DATE,
|
||||
renewal_date: FUTURE_DATE
|
||||
}
|
||||
|
||||
// ~6.3M credits — a 7-digit balance is what pushes the second action button out
|
||||
// of the popover before the fix.
|
||||
const mockBalance: CustomerBalanceResponse = {
|
||||
@@ -105,6 +122,32 @@ const test = comfyPageFixture.extend({
|
||||
})
|
||||
)
|
||||
|
||||
// Flag-on (team workspaces enabled) routes a personal workspace through the
|
||||
// workspace billing endpoints, so the popover sources its data from here.
|
||||
await page.route('**/api/billing/status', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockBillingStatus)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/api/billing/balance', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockBalance)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/api/billing/plans', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ plans: [] })
|
||||
})
|
||||
)
|
||||
|
||||
await use(page)
|
||||
}
|
||||
})
|
||||
|
||||
264
browser_tests/tests/dialogs/creditsTile.spec.ts
Normal file
264
browser_tests/tests/dialogs/creditsTile.spec.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
|
||||
// devtools backend during setup.
|
||||
|
||||
/**
|
||||
* Credits tile (Settings ▸ Workspace ▸ Plan & Credits) — DES-247 / FE-964.
|
||||
*
|
||||
* The credits tile only lives inside the authenticated cloud app, which the
|
||||
* shared `comfyPage` fixture can't boot (it expects the OSS devtools backend).
|
||||
* Instead this drives a raw page: mock Firebase auth + every boot endpoint so
|
||||
* the cloud app initializes against fully stubbed data. With team workspaces
|
||||
* enabled the facade routes a personal workspace through the workspace
|
||||
* `/api/billing/*` endpoints (mocked with an active Pro subscription); the
|
||||
* legacy `/customers/*` shapes are mocked too for the flag-off path. The tile
|
||||
* should then render its total / progress bar / monthly+additional breakdown /
|
||||
* add-credits.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
const jsonRoute = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
// Legacy `/customers/balance` and workspace `/api/billing/balance` share the
|
||||
// same response shape, so one body fulfills both endpoints.
|
||||
const balanceRoute = (balance: {
|
||||
amount: number
|
||||
monthly: number
|
||||
prepaid: number
|
||||
}) =>
|
||||
jsonRoute({
|
||||
amount_micros: balance.amount,
|
||||
currency: 'usd',
|
||||
effective_balance_micros: balance.amount,
|
||||
cloud_credit_balance_micros: balance.monthly,
|
||||
prepaid_balance_micros: balance.prepaid
|
||||
})
|
||||
|
||||
// 6000 -> 12,660 total; 5000 -> 10,550 monthly remaining; 1000 -> 2,110 extra.
|
||||
const DEFAULT_BALANCE = { amount: 6000, monthly: 5000, prepaid: 1000 }
|
||||
|
||||
const mockBillingStatus: BillingStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-02-20T12:00:00Z',
|
||||
has_funds: true
|
||||
}
|
||||
|
||||
async function mockCloudBoot(page: Page) {
|
||||
// Frontend-origin boot endpoints (proxied to the backend in production).
|
||||
// `/api/features` is the remote-config source: production builds resolve
|
||||
// `teamWorkspacesEnabled` from it (the `ff:` localStorage override is
|
||||
// dev-only), and the flag gates the Workspace settings panel.
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ team_workspaces_enabled: true } satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
// Include the mock user so the multi-user select screen auto-selects it
|
||||
// (paired with the `Comfy.userId` localStorage seed below).
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// Non-empty settings with a completed tutorial keep the cloud app from
|
||||
// booting as a new user, whose Workflow Templates dialog would otherwise
|
||||
// auto-open and intercept the Settings click behind its modal backdrop.
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(jsonRoute({ 'Comfy.TutorialCompleted': true }))
|
||||
)
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: 'Personal Workspace',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Legacy billing (flag-off path, api.comfy.org/customers/*).
|
||||
await page.route('**/customers/cloud-subscription-status', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-02-20T12:00:00Z',
|
||||
end_date: null
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(balanceRoute(DEFAULT_BALANCE))
|
||||
)
|
||||
|
||||
// Workspace billing (flag-on path) — a personal workspace now routes through
|
||||
// `/api/billing/*`.
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(jsonRoute(mockBillingStatus))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(balanceRoute(DEFAULT_BALANCE))
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(jsonRoute({ plans: [] }))
|
||||
)
|
||||
}
|
||||
|
||||
async function mockBalance(
|
||||
page: Page,
|
||||
balance: { amount: number; monthly: number; prepaid: number }
|
||||
) {
|
||||
await page.unroute('**/customers/balance')
|
||||
await page.unroute('**/api/billing/balance')
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(balanceRoute(balance))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(balanceRoute(balance))
|
||||
)
|
||||
}
|
||||
|
||||
/** Boots the mocked cloud app and opens Settings ▸ Workspace ▸ Plan & Credits. */
|
||||
async function openPlanAndCredits(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
|
||||
// Pre-select the mock user to skip the user-select screen.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
// Open Settings ▸ Workspace.
|
||||
await page
|
||||
.getByRole('button', { name: /^Settings/ })
|
||||
.first()
|
||||
.click()
|
||||
const dialog = page.getByTestId('settings-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
|
||||
|
||||
return dialog.getByRole('main')
|
||||
}
|
||||
|
||||
test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
|
||||
test('renders the unified tile with breakdown and add-credits', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page)
|
||||
|
||||
const content = await openPlanAndCredits(page)
|
||||
|
||||
// Total + remaining suffix (Pro monthly allowance = 21,100; remaining
|
||||
// 10,550 -> used 10,550).
|
||||
await expect(content.getByText('Total credits')).toBeVisible()
|
||||
await expect(content.getByText('12,660')).toBeVisible()
|
||||
|
||||
// Monthly usage bar header + used / left-of-total labels.
|
||||
await expect(content.getByText('Monthly', { exact: true })).toBeVisible()
|
||||
await expect(content.getByText(/Refills Feb/)).toBeVisible()
|
||||
await expect(content.getByText('10,550 used')).toBeVisible()
|
||||
await expect(content.getByText('10,550 left of 21,100')).toBeVisible()
|
||||
|
||||
// Additional credits row + subtitle.
|
||||
await expect(content.getByText('Additional credits')).toBeVisible()
|
||||
await expect(content.getByText('2,110')).toBeVisible()
|
||||
await expect(content.getByText('Used after monthly runs out')).toBeVisible()
|
||||
|
||||
// Permission-gated add-credits action (personal owner can top up).
|
||||
await expect(
|
||||
content.getByRole('button', { name: 'Add credits' })
|
||||
).toBeVisible()
|
||||
|
||||
// Narrow container (DES-247 responsive variants): drop the used/remaining
|
||||
// labels and the breakdown subtitle, compact the monthly summary numbers.
|
||||
await page.setViewportSize({ width: 360, height: 800 })
|
||||
await expect(content.getByText('10,550 used')).toBeHidden()
|
||||
await expect(content.getByText('remaining', { exact: true })).toBeHidden()
|
||||
await expect(content.getByText('Used after monthly runs out')).toBeHidden()
|
||||
await expect(content.getByText('10,550 left of 21,100')).toBeHidden()
|
||||
await expect(content.getByText('11K left of 21K')).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders the depleted-credit empty states', async ({ page }) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page)
|
||||
// Monthly allowance fully spent; additional credits keep generation going.
|
||||
await mockBalance(page, { amount: 1000, monthly: 0, prepaid: 1000 })
|
||||
|
||||
const content = await openPlanAndCredits(page)
|
||||
|
||||
// 0-monthly state: depletion notice + IN USE badge on additional credits.
|
||||
await expect(
|
||||
content.getByText('Monthly credits are used up. Refills Feb 20')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
content.getByText("You're now spending additional credits.")
|
||||
).toBeVisible()
|
||||
await expect(content.getByText('In use')).toBeVisible()
|
||||
await expect(content.getByText('0 left of 21,100')).toBeVisible()
|
||||
|
||||
// Drain the remaining additional credits and refresh the tile: the
|
||||
// out-of-credits notice takes over and the badge drops.
|
||||
await mockBalance(page, { amount: 0, monthly: 0, prepaid: 0 })
|
||||
await content.getByRole('button', { name: 'Refresh credits' }).click()
|
||||
|
||||
await expect(
|
||||
content.getByText("You're out of credits. Credits refill Feb 20")
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
content.getByText('Add more credits to continue generating.')
|
||||
).toBeVisible()
|
||||
await expect(content.getByText('In use')).toBeHidden()
|
||||
await expect(
|
||||
content.getByRole('button', { name: 'Add credits' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
264
browser_tests/tests/dialogs/memberRoleChange.spec.ts
Normal file
264
browser_tests/tests/dialogs/memberRoleChange.spec.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { Member } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
CREATOR,
|
||||
MEMBER_JANE,
|
||||
MEMBER_JOHN,
|
||||
VIEWER
|
||||
} from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
|
||||
|
||||
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
|
||||
// devtools backend during setup.
|
||||
|
||||
/**
|
||||
* Member role change (Settings ▸ Workspace ▸ Members) — Figma 2993-15512.
|
||||
*
|
||||
* The viewer is a promoted owner (not the workspace creator), so the spec can
|
||||
* distinguish the creator guard from the self guard: the creator row and the
|
||||
* viewer's own row hide the row menu, every other row exposes
|
||||
* "Change role ›" (Owner / Member) plus "Remove member". Promoting a member
|
||||
* sends PATCH /api/workspace/members/:id {role}, flips the Role column,
|
||||
* re-sorts the row under the creator, and the promoted owner stays demotable.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
async function openMembersTab(page: Page): Promise<Locator> {
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: /^Settings/ })
|
||||
.first()
|
||||
.click()
|
||||
const dialog = page.getByTestId('settings-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
|
||||
|
||||
const content = dialog.getByRole('main')
|
||||
await content.getByRole('tab', { name: /Members/ }).click()
|
||||
await expect(content.getByText('4 of 30 members')).toBeVisible()
|
||||
return content
|
||||
}
|
||||
|
||||
function memberRow(content: Locator, email: string): Locator {
|
||||
return content
|
||||
.locator('div.grid')
|
||||
.filter({ has: content.page().getByText(email, { exact: true }) })
|
||||
}
|
||||
|
||||
function menuButton(row: Locator): Locator {
|
||||
return row.getByRole('button', { name: 'More Options' })
|
||||
}
|
||||
|
||||
// Reka submenus open on real pointer travel or keyboard; Playwright's
|
||||
// synthetic hover doesn't trigger the pointermove handler, so drive the
|
||||
// subtrigger with ArrowRight instead.
|
||||
async function openChangeRoleSubmenu(page: Page) {
|
||||
const trigger = page.getByRole('menuitem', { name: 'Change role' })
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.press('ArrowRight')
|
||||
await expect(
|
||||
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
|
||||
test.describe.configure({ timeout: 60_000 })
|
||||
|
||||
test('row menus respect creator and self guards', async ({ page }) => {
|
||||
await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
// US8/US9 — no row actions on the creator row (Liz) nor on the viewer's
|
||||
// own row; the two plain members each expose a menu.
|
||||
await expect(
|
||||
menuButton(memberRow(content, MEMBER_JOHN.email))
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
menuButton(memberRow(content, MEMBER_JANE.email))
|
||||
).toBeVisible()
|
||||
await expect(menuButton(memberRow(content, CREATOR.email))).toHaveCount(0)
|
||||
await expect(menuButton(memberRow(content, VIEWER.email))).toHaveCount(0)
|
||||
|
||||
// US1/US12 — the row menu exposes Change role and the FE-768 remove flow.
|
||||
await menuButton(memberRow(content, MEMBER_JANE.email)).click()
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Change role' })
|
||||
).toBeVisible()
|
||||
await page.getByRole('menuitem', { name: 'Remove member' }).click()
|
||||
await expect(page.getByText('Remove this member?')).toBeVisible()
|
||||
})
|
||||
|
||||
test('selecting the current role is a no-op', async ({ page }) => {
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
|
||||
// The current role is a checked radio item so assistive tech can announce
|
||||
// which role is active.
|
||||
await expect(
|
||||
page.getByRole('menuitemradio', { name: 'Member', exact: true })
|
||||
).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(
|
||||
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Member', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: /an owner\?/ })).toHaveCount(
|
||||
0
|
||||
)
|
||||
expect(state.patches).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('promote dialog shows the Figma copy and cancelling keeps the role', async ({
|
||||
page
|
||||
}) => {
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText("They'll be able to:")).toBeVisible()
|
||||
await expect(page.getByText('Add additional credits')).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('Manage members, payment methods, and workspace settings')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Promote and demote other owners (except the workspace creator).'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
).toHaveCount(0)
|
||||
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
|
||||
expect(state.patches).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('promoting a member re-sorts the row under the creator and stays demotable', async ({
|
||||
page
|
||||
}) => {
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const emails = content.getByText(/@test\.comfy\.org/)
|
||||
await expect(emails).toHaveText([
|
||||
CREATOR.email,
|
||||
VIEWER.email,
|
||||
MEMBER_JOHN.email,
|
||||
MEMBER_JANE.email
|
||||
])
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Make owner' }).click()
|
||||
|
||||
await expect(page.getByText('Role updated')).toBeVisible()
|
||||
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
|
||||
await expect(emails).toHaveText([
|
||||
CREATOR.email,
|
||||
VIEWER.email,
|
||||
MEMBER_JANE.email,
|
||||
MEMBER_JOHN.email
|
||||
])
|
||||
expect(state.patches).toEqual([
|
||||
{
|
||||
url: expect.stringContaining('/api/workspace/members/u-jane'),
|
||||
role: 'owner'
|
||||
}
|
||||
])
|
||||
|
||||
// The promoted owner keeps its row menu (still demotable).
|
||||
await expect(menuButton(janeRow)).toBeVisible()
|
||||
})
|
||||
|
||||
test('demoting an owner returns them to member', async ({ page }) => {
|
||||
const ownerJane: Member = { ...MEMBER_JANE, role: 'owner' }
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup([
|
||||
CREATOR,
|
||||
VIEWER,
|
||||
ownerJane,
|
||||
MEMBER_JOHN
|
||||
])
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
|
||||
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Member', exact: true })
|
||||
.click()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Demote Jane to member?' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText("They'll lose admin access.")).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Demote to member' }).click()
|
||||
|
||||
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
|
||||
expect(state.patches).toEqual([
|
||||
{
|
||||
url: expect.stringContaining('/api/workspace/members/u-jane'),
|
||||
role: 'member'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('failed role change keeps the dialog open with an error toast', async ({
|
||||
page
|
||||
}) => {
|
||||
await new CloudWorkspaceMockHelper(page).setup()
|
||||
// Override the member route so PATCH fails after boot succeeds.
|
||||
await page.route('**/api/workspace/members/**', (route) =>
|
||||
route.request().method() === 'PATCH'
|
||||
? route.fulfill({ status: 500, body: '{}' })
|
||||
: route.fallback()
|
||||
)
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Make owner' }).click()
|
||||
|
||||
// US10 — error toast, dialog stays open, role unchanged.
|
||||
await expect(page.getByText('Failed to update role')).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
|
||||
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
128
browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts
Normal file
128
browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type {
|
||||
Member,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockBilling } from '@e2e/fixtures/utils/cloudBillingMocks'
|
||||
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
import {
|
||||
member,
|
||||
mockWorkspace,
|
||||
workspace
|
||||
} from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
/**
|
||||
* The `?pricing=` deep link opens the pricing table on app load, gated to the
|
||||
* original owner (canManageSubscriptionLifecycle). Drives a raw `page` so the
|
||||
* cloud app boots against fully mocked endpoints, like the survey-gate spec.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
// CloudAuthHelper.mockAuth() signs in as this email; the original-owner gate
|
||||
// matches it against the members self-row.
|
||||
const SELF_EMAIL = 'e2e@test.comfy.org'
|
||||
|
||||
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
|
||||
// Disable the experimental Asset API: with it on (cloud default) the unmocked
|
||||
// asset endpoints 403 and workflow restore throws uncaught, aborting the
|
||||
// GraphCanvas onMounted chain before the deep-link loader.
|
||||
const BOOT_SETTINGS = { 'Comfy.Assets.UseAssetAPI': false }
|
||||
|
||||
// The deep-link loader runs at the tail of GraphCanvas onMounted, so the boot
|
||||
// chain must not throw before it: a missing settings subpath, prompt exec_info,
|
||||
// or queue status each abort that chain.
|
||||
async function mockGraphBootExtras(page: Page) {
|
||||
// Boot only reads these; fall back on any write so an unexpected POST/PUT
|
||||
// surfaces instead of being masked by a blanket 200.
|
||||
await page.route('**/api/settings/**', (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
return route.fulfill(jsonRoute({}))
|
||||
})
|
||||
await page.route('**/api/prompt', (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
return route.fulfill(jsonRoute({ exec_info: { queue_remaining: 0 } }))
|
||||
})
|
||||
await page.route('**/api/queue', (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
return route.fulfill(jsonRoute({ queue_running: [], queue_pending: [] }))
|
||||
})
|
||||
}
|
||||
|
||||
async function setupCloudApp(
|
||||
page: Page,
|
||||
ws: WorkspaceWithRole,
|
||||
members: Member[]
|
||||
) {
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
})
|
||||
await mockGraphBootExtras(page)
|
||||
await mockBilling(page)
|
||||
await mockWorkspace(page, ws, members)
|
||||
await bootCloud(page)
|
||||
}
|
||||
|
||||
const pricingHeading = (page: Page) =>
|
||||
page.getByRole('heading', { name: 'Choose a Plan' })
|
||||
|
||||
test.describe('Pricing table deep link', { tag: '@cloud' }, () => {
|
||||
test('opens the pricing table for a personal owner', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('personal', 'owner'), [])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=1`)
|
||||
|
||||
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
|
||||
await expect(page).not.toHaveURL(/[?&]pricing=/)
|
||||
})
|
||||
|
||||
test('opens on the Team tab for ?pricing=team', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('personal', 'owner'), [])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=team`)
|
||||
|
||||
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'For Teams' })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('opens for a team original owner', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('team', 'owner'), [
|
||||
member({ email: SELF_EMAIL, role: 'owner', is_original_owner: true })
|
||||
])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=1`)
|
||||
|
||||
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
|
||||
})
|
||||
|
||||
test('is a silent no-op for a team member', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('team', 'member'), [
|
||||
member({
|
||||
email: 'creator@test.comfy.org',
|
||||
role: 'owner',
|
||||
is_original_owner: true
|
||||
}),
|
||||
member({ email: SELF_EMAIL, role: 'member' })
|
||||
])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=1`)
|
||||
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
await expect(page).not.toHaveURL(/[?&]pricing=/)
|
||||
await expect(pricingHeading(page)).toBeHidden()
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('tooltips', { tag: '@vue-nodes' }, async () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.settings.setSetting('LiteGraph.Node.TooltipDelay', 0)
|
||||
})
|
||||
|
||||
test('widget value tooltips', async ({ comfyPage }) => {
|
||||
const tooltip = comfyPage.page.locator('.p-tooltip-text')
|
||||
await comfyPage.vueNodes.getWidgetByName('load check', 'ckpt_name').hover()
|
||||
await expect(tooltip, 'displays for combos').toContainText('v1-5-pruned')
|
||||
|
||||
await comfyPage.vueNodes.getWidgetByName('ksampler', 'seed').hover()
|
||||
await expect(tooltip, 'displays for numbers').toContainText('15668')
|
||||
|
||||
await comfyPage.vueNodes.getNodeLocator('6').getByLabel('text').hover()
|
||||
await expect(tooltip).toBeVisible()
|
||||
await expect(tooltip, "doesn't display for prompts").not.toContainText(
|
||||
'purple galaxy bottle'
|
||||
)
|
||||
})
|
||||
})
|
||||
209
docs/adr/0011-in-app-agent-graph-state-integration.md
Normal file
209
docs/adr/0011-in-app-agent-graph-state-integration.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# 11. In-App Agent Graph-State Integration
|
||||
|
||||
Date: 2026-06-25
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
The In-App Agent (V0, target ~2026-07-13) adds a server-side ComfyUI agent the user chats with
|
||||
from a side panel. A hard product requirement is that the agent can **read the user's live
|
||||
workflow** and **write workflow changes back onto the canvas**. This ADR records the frontend
|
||||
graph-state and synchronization design for that capability. It is intentionally written as
|
||||
"TDD-as-code": a design record, ahead of the implementation PRs.
|
||||
|
||||
This decision builds directly on:
|
||||
|
||||
- [ADR-0001 (Merge LiteGraph)](0001-merge-litegraph-into-frontend.md) and
|
||||
[ADR-0003 (Centralized Layout Management with CRDT)](0003-crdt-based-layout-system.md), which
|
||||
established a Yjs CRDT store as the single source of truth for spatial state and a command/
|
||||
observer model for mutations.
|
||||
- The long-term architectural direction RFC ([issue #4661]), which makes a CRDT-mediated state
|
||||
layer the foundation for multiplayer — and frames the agent as **just another client** of a
|
||||
per-graph room.
|
||||
|
||||
### Forces at play
|
||||
|
||||
1. **A second writer.** Until now the only writer to a workflow is the local user. The agent
|
||||
introduces a second, remote writer to the _same_ graph. We need conflict handling, not
|
||||
last-write-wins.
|
||||
2. **The backend is server-authoritative.** The cloud backend (`Comfy-Org/cloud` PR #4432)
|
||||
introduces a mutable server-side **`workflow_draft`** (full save-format JSON + integer
|
||||
`version`), commits edits with **compare-and-swap on `version`**, and pushes results to the
|
||||
browser as a **full-document replace** over an existing **Redis-PubSub → WebSocket** bridge
|
||||
(`channel:ws:{workspaceId}:u:{userId}`). Inbound chat turns go through ingest `/api/agent/*`.
|
||||
3. **The frontend is moving toward decentralized CRDT.** Per ADR-0003 / #4661 the end-state is
|
||||
per-graph rooms with mutation relay and CRDT merge — _not_ server-authoritative full-document
|
||||
replace. The V0 backend model and the FE end-state are different shapes.
|
||||
4. **Timeline.** V0 ships in ~3 weeks. The CRDT migration of the _data-model_ class of state
|
||||
(node existence, widget values) is still in progress; only the _layout_ class is fully on the
|
||||
Yjs store today. A true per-mutation CRDT sync for the agent is not ready for V0.
|
||||
5. **No throwaway work.** Whatever we ship for V0 must be a strict subset of the #4661 end-state.
|
||||
|
||||
### What V0 is NOT
|
||||
|
||||
- The agent does **not** draw, animate, or incrementally lay out nodes on the canvas.
|
||||
- The agent does **not** submit/run the workflow — the user clicks the existing Run button.
|
||||
- The agent is **not** aware of viewport state (zoom/pan/cursor) — that is FE-only ephemeral
|
||||
state and is never synced.
|
||||
|
||||
## Decision
|
||||
|
||||
For V0 the frontend treats the **server `workflow_draft` as the authority**, integrates agent
|
||||
writes as **full-document replaces guarded by `version`**, and frames the whole interaction as a
|
||||
**room-per-graph** model so it is forward-compatible with the CRDT end-state.
|
||||
|
||||
### Graph-state model
|
||||
|
||||
| State class | Source of truth (V0) | Synced to agent? |
|
||||
| ---------------------------------------------------------- | ------------------------------------------------------ | ---------------- |
|
||||
| Save-format **data model** (nodes, links, widgets, groups) | server `workflow_draft.content` | read + write |
|
||||
| **Layout** (positions/sizes/reroutes) | within `content`; mirrors Yjs `layoutStore` (ADR-0003) | within content |
|
||||
| **Selection** (selected node ids) | browser, sent per turn | per-turn input |
|
||||
| **Viewport** (zoom/pan/cursor) | browser only | never |
|
||||
|
||||
- Each `workflow_draft` is a **room**. V0 has up to two writers: the human (via autosave-to-draft)
|
||||
and the agent. The browser keeps a draft's tab **alive in memory** while connected so agent
|
||||
pushes apply even when the tab is unfocused (lazy apply on refocus) — the CRDT room behavior,
|
||||
minus true merge.
|
||||
- The browser **autosaves canvas edits into the draft** so the server copy reflects unsaved work
|
||||
before a turn. Agent awareness then reduces to "read the draft + read the selection ids".
|
||||
|
||||
### Synchronization & conflict handling
|
||||
|
||||
The agent→browser push is a `draft_patch { workflow_id, content, version, base_version }`.
|
||||
|
||||
```
|
||||
agent commits draft (CAS version N → N+1)
|
||||
→ draft_patch { content, version: N+1, base_version: N } over Redis → WS
|
||||
browser:
|
||||
tab.version == base_version → apply full replace, adopt new version (happy path)
|
||||
tab.version != base_version → MERGE DIALOG: [Accept agent's] [Keep mine] [Open in new tab]
|
||||
```
|
||||
|
||||
- **Apply** = load the full save-format graph into the target tab (a destructive variant of the
|
||||
existing `loadGraphData` path) and adopt `version` as the tab's new base.
|
||||
- **Conflict** (user edited the graph during the agent's turn) surfaces a dialog rather than
|
||||
silently clobbering. We explicitly reject **graph-locking** as the primary mechanism: a
|
||||
lost/duplicated backend message could leave the graph _permanently_ locked. A presentational
|
||||
"agent editing…" hint MAY be driven by the optional backend edit-turn lease, but it is never on
|
||||
the correctness path.
|
||||
- The agent can also target a **new tab** (`target: "new_tab"`) for unrelated requests — a
|
||||
non-destructive load, no conflict possible.
|
||||
|
||||
### Awareness & run
|
||||
|
||||
- A chat turn carries `{ content, selection?: NodeId[], attachments?, target }`. `selection` is
|
||||
the set of node ids from the canvas (the panel's `@`-tag chips). The agent reads the draft data
|
||||
model server-side; the browser does not scrape the live graph.
|
||||
- The agent never runs the workflow in V0; after a write it tells the user the graph is loaded and
|
||||
to click Run. Submit is gated off for the in-app client.
|
||||
|
||||
### Migration path to the CRDT end-state (#4661)
|
||||
|
||||
This is the load-bearing reason the V0 shape is acceptable. The transition is a payload swap, not
|
||||
a rewrite:
|
||||
|
||||
1. **V0:** full-document `draft_patch`; convergence via `version` CAS + merge dialog.
|
||||
2. **When the data-model class finishes migrating to the Yjs store:** `draft_patch` gains a
|
||||
**mutation-list** variant applied via `layoutStore.applyOperation(op)` tagged
|
||||
`LayoutSource.External` with a dedicated agent actor id (the store already tracks
|
||||
source/actor). Full replace remains the fallback for large rewrites / new tabs.
|
||||
3. **Multiplayer:** server relays mutations; both client and server write + reconcile via CRDT
|
||||
merge, retiring the merge dialog for fine-grained edits. The room model, actor/source tracking,
|
||||
and the Redis channel are unchanged.
|
||||
|
||||
### Prototype implementation (this PR)
|
||||
|
||||
This PR ships TDD-as-code for **both** layers so the V0 shape and the end-state are exercised, not
|
||||
just described. Nothing is wired into the chat UI yet.
|
||||
|
||||
**Layer A — V0 server-draft path** (`src/platform/agent/common/`)
|
||||
|
||||
- `agentProtocol.ts` — typed FE⇄agent wire contract + `parseAgentEvent()` decoder for untrusted
|
||||
WS payloads.
|
||||
- `draftReconciler.ts` — pure `version`-CAS decision (`apply` / `conflict` / `stale`).
|
||||
- `useAgentDraftSync.ts` — composable tracking per-workflow base `version` and the three merge
|
||||
outcomes via injected canvas ports.
|
||||
|
||||
**Layer B — CRDT peer path (the #4661 migration target)** (`src/platform/agent/crdt/`)
|
||||
|
||||
- `agentRoom.ts` — a `Y.Doc`-backed room whose top-level types (`nodes`/`links`/`reroutes`) mirror
|
||||
the layout store; real `encodeStateVector`/`diffSince`/`applyRemoteUpdate` + presence.
|
||||
- `roomSync.ts` — transport-agnostic two-phase Yjs sync (state-vector handshake + live updates),
|
||||
modelled on `y-protocols/sync`; runs over the existing Redis→WS bridge.
|
||||
- `agentRoomManager.ts` — room lifecycle: a tab switch is a `join`/`leave`; rooms stay alive in
|
||||
memory while referenced or pinned (agent editing a backgrounded workflow).
|
||||
- `roomDocBinding.ts` — generic, layer-safe bidirectional `Y.Doc` binding with origin-tag echo
|
||||
guards.
|
||||
|
||||
**Wiring (renderer layer)** — `src/renderer/core/layout/agent/bindRoomToLayoutStore.ts` binds a
|
||||
room to the **real** `layoutStore` singleton via its existing (`"future feature"`)
|
||||
`getYDoc()`/`applyUpdate` surface. A test drives an agent room edit into the live store and asserts
|
||||
the version bump — concrete proof of the "few lines of code" claim. Layout binds today; data-model
|
||||
mutations bind the same way once that class finishes migrating to the store.
|
||||
|
||||
**Local chat state** — `src/platform/agent/session/agentSessionStore.ts` is an **Immer** reducer
|
||||
for streaming deltas / tool-call lifecycle. Chat is single-client, so it is deliberately _not_ a
|
||||
CRDT: Yjs owns graph state, Immer owns local UI state, and the two never mix.
|
||||
|
||||
A layering constraint surfaced during this work: `platform/` may not import `renderer/`
|
||||
(base → platform → workbench → renderer). Hence the pure CRDT core lives in `platform/` and only
|
||||
the thin singleton wiring lives in `renderer/`.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- **Build true CRDT (Yjs) agent sync in V0** — rejected: data-model CRDT migration incomplete;
|
||||
misses the timeline; high risk.
|
||||
- **Always open a new tab for agent output** — rejected: simplest, but fails "update what I'm
|
||||
looking at" and causes tab sprawl.
|
||||
- **Graph-locking / "agent mode" that blocks user edits** — rejected as primary mechanism:
|
||||
permanent-lock dead-end risk on message loss.
|
||||
- **Browser scrapes the live graph per turn** — rejected: invites client/server drift;
|
||||
autosave-to-draft keeps the canonical server copy current instead.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Hits the V0 timeline by reusing the backend draft + the existing Redis→WS transport; no new
|
||||
realtime infrastructure on the frontend.
|
||||
- A clean, documented bridge to the #4661 CRDT end-state: room-per-graph, agent-as-client, and the
|
||||
ADR-0003 source/actor model all carry forward unchanged.
|
||||
- Conflicts never silently destroy user work; the dialog appears only in the genuine
|
||||
concurrent-edit case.
|
||||
- Awareness is minimal and robust: read the draft + the selection ids.
|
||||
|
||||
### Negative
|
||||
|
||||
- Full-document replace is coarse-grained: a concurrent user edit during an agent turn collides
|
||||
and must go through the merge dialog rather than merging automatically.
|
||||
- Introduces a frontend-owned **base-`version` lifecycle** (obtain on draft open, bump on apply
|
||||
and on autosave). If this drifts, the merge dialog can mis-fire — this is the main correctness
|
||||
risk to get right.
|
||||
- The agent→browser event schema becomes a **cross-repo contract** (Go ⇄ TS); it must be
|
||||
versioned and drift-guarded.
|
||||
- A temporary semantic gap with V0 product copy ("Agent generation does not impact the graph")
|
||||
that must be reconciled now that write-to-graph is in scope.
|
||||
|
||||
## Notes
|
||||
|
||||
### Open questions
|
||||
|
||||
1. **Tab closed mid-edit.** If the user closes the draft's tab while the agent is editing, do
|
||||
pending changes invalidate (and report back to the agent) or persist server-side and reopen on
|
||||
a new tab? Affects the room lifecycle.
|
||||
2. **Base-`version` lifecycle.** Exact points where the tab obtains/bumps its base `version` so
|
||||
the merge dialog cannot mis-fire or desync.
|
||||
3. **Event schema home & versioning.** Where the shared `draft_patch` / `agent_message_delta` /
|
||||
`agent_tool_call` / `agent_message_done` contract lives and how drift is caught.
|
||||
|
||||
### References
|
||||
|
||||
- [ADR-0001](0001-merge-litegraph-into-frontend.md), [ADR-0003](0003-crdt-based-layout-system.md)
|
||||
- RFC: Long-Term Architectural Direction for ComfyUI_frontend (issue #4661)
|
||||
- Backend slice: `Comfy-Org/cloud` PR #4432 (`workflow_draft`, ingest `/api/agent/*`, Redis PubSub)
|
||||
- Existing FE entry points: `src/renderer/core/layout/store/layoutStore.ts`,
|
||||
`src/renderer/core/layout/operations/layoutMutations.ts`, `src/scripts/app.ts` (`loadGraphData`)
|
||||
@@ -20,6 +20,7 @@ An Architecture Decision Record captures an important architectural decision mad
|
||||
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
|
||||
| [0009](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Subgraph Promoted Widgets Use Linked Inputs | Proposed | 2026-05-05 |
|
||||
| [0010](0010-remove-nx-orchestration.md) | Remove Nx Orchestration | Accepted | 2026-05-19 |
|
||||
| [0011](0011-in-app-agent-graph-state-integration.md) | In-App Agent Graph-State Integration | Proposed | 2026-06-25 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ const config: KnipConfig = {
|
||||
// Marketing media tooling — adopted by pages in a follow-up PR
|
||||
'apps/website/src/components/common/SiteVideo.vue',
|
||||
'apps/website/src/utils/marketingImage.ts',
|
||||
// In-app agent wire contract (ADR-0011) — public types pending UI integration
|
||||
'src/platform/agent/common/agentProtocol.ts',
|
||||
// Agent review check config, not part of the build
|
||||
'.agents/checks/eslint.strict.config.js',
|
||||
// Devtools extensions, included dynamically
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"firebase": "catalog:",
|
||||
"fuse.js": "^7.0.0",
|
||||
"glob": "catalog:",
|
||||
"immer": "catalog:",
|
||||
"jsonata": "catalog:",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
--color-gold-500: #fdab34;
|
||||
--color-gold-600: #fd9903;
|
||||
|
||||
--color-credit: #fabc25;
|
||||
|
||||
--color-coral-500: #f75951;
|
||||
--color-coral-600: #e04e48;
|
||||
--color-coral-700: #b33a3a;
|
||||
@@ -236,6 +238,8 @@
|
||||
--secondary-background: var(--color-smoke-200);
|
||||
--secondary-background-hover: var(--color-smoke-400);
|
||||
--secondary-background-selected: var(--color-smoke-600);
|
||||
--tertiary-background: var(--color-smoke-400);
|
||||
--tertiary-background-hover: var(--color-smoke-500);
|
||||
--base-background: var(--color-white);
|
||||
--primary-background: var(--color-azure-400);
|
||||
--primary-background-hover: var(--color-cobalt-800);
|
||||
@@ -384,6 +388,8 @@
|
||||
--secondary-background: var(--color-charcoal-600);
|
||||
--secondary-background-hover: var(--color-charcoal-400);
|
||||
--secondary-background-selected: var(--color-charcoal-200);
|
||||
--tertiary-background: var(--color-charcoal-400);
|
||||
--tertiary-background-hover: var(--color-charcoal-300);
|
||||
--base-background: var(--color-charcoal-800);
|
||||
--primary-background: var(--color-azure-600);
|
||||
--primary-background-hover: var(--color-azure-400);
|
||||
@@ -554,6 +560,8 @@
|
||||
--color-secondary-background: var(--secondary-background);
|
||||
--color-secondary-background-hover: var(--secondary-background-hover);
|
||||
--color-secondary-background-selected: var(--secondary-background-selected);
|
||||
--color-tertiary-background: var(--tertiary-background);
|
||||
--color-tertiary-background-hover: var(--tertiary-background-hover);
|
||||
--color-primary-background: var(--primary-background);
|
||||
--color-primary-background-hover: var(--primary-background-hover);
|
||||
--color-destructive-background: var(--destructive-background);
|
||||
|
||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
@@ -258,6 +258,9 @@ catalogs:
|
||||
husky:
|
||||
specifier: ^9.1.7
|
||||
version: 9.1.7
|
||||
immer:
|
||||
specifier: ^11.1.8
|
||||
version: 11.1.8
|
||||
jiti:
|
||||
specifier: 2.6.1
|
||||
version: 2.6.1
|
||||
@@ -579,6 +582,9 @@ importers:
|
||||
glob:
|
||||
specifier: 'catalog:'
|
||||
version: 13.0.6
|
||||
immer:
|
||||
specifier: 'catalog:'
|
||||
version: 11.1.8
|
||||
jsonata:
|
||||
specifier: 'catalog:'
|
||||
version: 2.1.0
|
||||
@@ -654,7 +660,7 @@ importers:
|
||||
version: 4.5.0(eslint@10.4.0(jiti@2.6.1))(jsonc-eslint-parser@2.4.0)(vue-eslint-parser@10.4.0(eslint@10.4.0(jiti@2.6.1)))(yaml-eslint-parser@1.3.0)
|
||||
'@lobehub/i18n-cli':
|
||||
specifier: 'catalog:'
|
||||
version: 1.26.1(@types/react@19.1.9)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.21.0)(zod@3.25.76)
|
||||
version: 1.26.1(@types/react@19.1.9)(immer@11.1.8)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.21.0)(zod@3.25.76)
|
||||
'@pinia/testing':
|
||||
specifier: 'catalog:'
|
||||
version: 1.0.3(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))
|
||||
@@ -6042,6 +6048,9 @@ packages:
|
||||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
immer@11.1.8:
|
||||
resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -10521,7 +10530,7 @@ snapshots:
|
||||
- react-devtools-core
|
||||
- utf-8-validate
|
||||
|
||||
'@lobehub/i18n-cli@1.26.1(@types/react@19.1.9)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.21.0)(zod@3.25.76)':
|
||||
'@lobehub/i18n-cli@1.26.1(@types/react@19.1.9)(immer@11.1.8)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.21.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@lobehub/cli-ui': 1.13.0(@types/react@19.1.9)
|
||||
'@yutengjing/eld': 0.0.2
|
||||
@@ -10552,7 +10561,7 @@ snapshots:
|
||||
unified: 11.0.5
|
||||
unist-util-visit: 5.1.0
|
||||
update-notifier: 7.3.1
|
||||
zustand: 5.0.11(@types/react@19.1.9)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
|
||||
zustand: 5.0.11(@types/react@19.1.9)(immer@11.1.8)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- bufferutil
|
||||
@@ -14361,6 +14370,8 @@ snapshots:
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
immer@11.1.8: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
@@ -17949,9 +17960,10 @@ snapshots:
|
||||
|
||||
zod@4.3.6: {}
|
||||
|
||||
zustand@5.0.11(@types/react@19.1.9)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
|
||||
zustand@5.0.11(@types/react@19.1.9)(immer@11.1.8)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
immer: 11.1.8
|
||||
react: 19.2.4
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ catalog:
|
||||
gsap: ^3.14.2
|
||||
happy-dom: ^20.8.9
|
||||
husky: ^9.1.7
|
||||
immer: ^11.1.8
|
||||
jiti: 2.6.1
|
||||
jsdom: ^27.4.0
|
||||
jsonata: ^2.1.0
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineOptions({
|
||||
@@ -50,11 +52,27 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem
|
||||
v-else
|
||||
:class="itemClass"
|
||||
v-tooltip="
|
||||
item.tooltip ? { value: String(item.tooltip), showDelay: 0 } : undefined
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
itemClass,
|
||||
String(item.class ?? ''),
|
||||
Boolean(item.tooltip) && toValue(item.disabled) && 'pointer-events-auto'
|
||||
)
|
||||
"
|
||||
v-bind="
|
||||
'checked' in item
|
||||
? { role: 'menuitemradio', 'aria-checked': Boolean(item.checked) }
|
||||
: {}
|
||||
"
|
||||
:disabled="toValue(item.disabled) ?? !item.command"
|
||||
@select="item.command?.({ originalEvent: $event, item })"
|
||||
>
|
||||
<i class="size-5 shrink-0" :class="item.icon" />
|
||||
<!-- Items declaring an icon key (even empty) keep the slot so labels align
|
||||
within icon-bearing menus; icon-less menus render labels flush-left. -->
|
||||
<i v-if="'icon' in item" class="size-5 shrink-0" :class="item.icon" />
|
||||
<div class="mr-auto truncate" v-text="item.label" />
|
||||
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import {
|
||||
DropdownMenuArrow,
|
||||
@@ -7,13 +8,16 @@ import {
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, toValue } from 'vue'
|
||||
import { computed, ref, toValue } from 'vue'
|
||||
|
||||
import DropdownItem from '@/components/common/DropdownItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { ButtonVariants } from '../ui/button/button.variants'
|
||||
|
||||
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
|
||||
const MODAL_BASE_Z_INDEX = 1700
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
@@ -41,10 +45,20 @@ const contentClass = computed(() =>
|
||||
contentProp
|
||||
)
|
||||
)
|
||||
|
||||
// Body-portaled content keeps its static z-1700 unless a dialog that joined
|
||||
// @primeuix's auto-incrementing 'modal' counter is open above it; then lift
|
||||
// past that dialog so the menu isn't hidden behind it.
|
||||
const open = ref(false)
|
||||
const contentStyle = computed(() => {
|
||||
if (!open.value) return undefined
|
||||
const topZIndex = ZIndex.getCurrent('modal')
|
||||
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuRoot v-model:open="open">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
|
||||
@@ -60,6 +74,7 @@ const contentClass = computed(() =>
|
||||
:collision-padding="10"
|
||||
v-bind="$attrs"
|
||||
:class="contentClass"
|
||||
:style="contentStyle"
|
||||
>
|
||||
<slot :item-class>
|
||||
<DropdownItem
|
||||
|
||||
56
src/components/common/DropdownMenu.zindex.test.ts
Normal file
56
src/components/common/DropdownMenu.zindex.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import DropdownMenu from './DropdownMenu.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderMenu() {
|
||||
return render(DropdownMenu, {
|
||||
props: { entries: [{ label: 'Item A' }] },
|
||||
global: { plugins: [i18n], directives: { tooltip: {} } }
|
||||
})
|
||||
}
|
||||
|
||||
let openModal: HTMLElement | undefined
|
||||
|
||||
afterEach(() => {
|
||||
if (openModal) {
|
||||
ZIndex.clear(openModal)
|
||||
openModal = undefined
|
||||
}
|
||||
})
|
||||
|
||||
describe('DropdownMenu z-index', () => {
|
||||
it('opens above a dialog registered with the modal z-index counter', async () => {
|
||||
openModal = document.createElement('div')
|
||||
ZIndex.set('modal', openModal, 1700)
|
||||
const dialogZ = Number(openModal.style.zIndex)
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderMenu()
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
const menu = await screen.findByRole('menu')
|
||||
expect(Number(menu.style.zIndex)).toBeGreaterThan(dialogZ)
|
||||
})
|
||||
|
||||
it('leaves the static z-index untouched when no dialog is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderMenu()
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
const menu = await screen.findByRole('menu')
|
||||
expect(menu.style.zIndex).toBe('')
|
||||
expect(menu.className).toContain('z-1700')
|
||||
})
|
||||
})
|
||||
@@ -37,7 +37,7 @@
|
||||
<Message v-if="userIsInChina" severity="warn" class="mb-4">
|
||||
{{ t('auth.signup.regionRestrictionChina') }}
|
||||
</Message>
|
||||
<SignUpForm v-else @submit="signUpWithEmail" />
|
||||
<SignUpForm v-else ref="signUpForm" @submit="signUpWithEmail" />
|
||||
</template>
|
||||
|
||||
<!-- Divider -->
|
||||
@@ -206,9 +206,21 @@ const signInWithEmail = async (values: SignInData) => {
|
||||
}
|
||||
}
|
||||
|
||||
const signUpWithEmail = async (values: SignUpData) => {
|
||||
if (await authActions.signUpWithEmail(values.email, values.password)) {
|
||||
const signUpForm = ref<InstanceType<typeof SignUpForm> | null>(null)
|
||||
|
||||
const signUpWithEmail = async (values: SignUpData, turnstileToken?: string) => {
|
||||
if (
|
||||
await authActions.signUpWithEmail(
|
||||
values.email,
|
||||
values.password,
|
||||
turnstileToken
|
||||
)
|
||||
) {
|
||||
onSuccess()
|
||||
} else {
|
||||
// Signup failed while the form is still mounted: re-arm the single-use
|
||||
// Turnstile token so the next attempt sends a fresh one.
|
||||
signUpForm.value?.resetTurnstile()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
97
src/components/dialog/content/setting/CreditsPanel.vue
Normal file
97
src/components/dialog/content/setting/CreditsPanel.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="credits-container flex h-full flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('credits.credits') }}
|
||||
</h2>
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<CreditsTile />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="m-0">{{ $t('credits.activity') }}</h3>
|
||||
<Button variant="muted-textonly" @click="handleCreditsHistoryClick">
|
||||
<i class="pi pi-arrow-up-right" />
|
||||
{{ $t('credits.invoiceHistory') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<UsageLogsTable ref="usageLogsTableRef" />
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button variant="muted-textonly" @click="handleFaqClick">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('credits.faqs') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleOpenPartnerNodesInfo">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('subscription.partnerNodesCredits') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleMessageSupport">
|
||||
<i class="pi pi-comments" />
|
||||
{{ $t('credits.messageSupport') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Divider from 'primevue/divider'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const authStore = useAuthStore()
|
||||
const authActions = useAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
|
||||
|
||||
watch(
|
||||
() => authStore.lastBalanceUpdateTime,
|
||||
(newTime, oldTime) => {
|
||||
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
|
||||
void usageLogsTableRef.value.refresh()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleCreditsHistoryClick = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
const handleMessageSupport = async () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'credits_panel'
|
||||
})
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
const handleFaqClick = () => {
|
||||
window.open(
|
||||
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -1,195 +0,0 @@
|
||||
<template>
|
||||
<div class="credits-container h-full">
|
||||
<!-- Legacy Design -->
|
||||
<div class="flex h-full flex-col">
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('credits.credits') }}
|
||||
</h2>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-sm font-medium text-muted">
|
||||
{{ $t('credits.yourCreditBalance') }}
|
||||
</h3>
|
||||
<div class="flex items-center justify-between">
|
||||
<UserCredit text-class="text-3xl font-bold" />
|
||||
<Skeleton v-if="loading" width="2rem" height="2rem" />
|
||||
<Button
|
||||
v-else-if="isActiveSubscription"
|
||||
:loading="loading"
|
||||
@click="handlePurchaseCreditsClick"
|
||||
>
|
||||
{{ $t('credits.purchaseCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-row items-center">
|
||||
<Skeleton
|
||||
v-if="balanceLoading"
|
||||
width="12rem"
|
||||
height="1rem"
|
||||
class="text-xs"
|
||||
/>
|
||||
<div v-else-if="formattedLastUpdateTime" class="text-xs text-muted">
|
||||
{{ $t('credits.lastUpdated') }}: {{ formattedLastUpdateTime }}
|
||||
</div>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('g.refresh')"
|
||||
@click="() => authActions.fetchBalance()"
|
||||
>
|
||||
<i class="pi pi-refresh" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3>{{ $t('credits.activity') }}</h3>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
:loading="loading"
|
||||
@click="handleCreditsHistoryClick"
|
||||
>
|
||||
<i class="pi pi-arrow-up-right" />
|
||||
{{ $t('credits.invoiceHistory') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<template v-if="creditHistory.length > 0">
|
||||
<div class="grow">
|
||||
<DataTable :value="creditHistory" :show-headers="false">
|
||||
<Column field="title" :header="$t('g.name')">
|
||||
<template #body="{ data }">
|
||||
<div class="text-sm font-medium">{{ data.title }}</div>
|
||||
<div class="text-xs text-muted">{{ data.timestamp }}</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="amount" :header="$t('g.amount')">
|
||||
<template #body="{ data }">
|
||||
<div
|
||||
:class="[
|
||||
'text-center text-base font-medium',
|
||||
data.isPositive ? 'text-sky-500' : 'text-red-400'
|
||||
]"
|
||||
>
|
||||
{{ data.isPositive ? '+' : '-' }}${{
|
||||
formatMetronomeCurrency(data.amount, 'usd')
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Divider />
|
||||
|
||||
<UsageLogsTable ref="usageLogsTableRef" />
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button variant="muted-textonly" @click="handleFaqClick">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('credits.faqs') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleOpenPartnerNodesInfo">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('subscription.partnerNodesCredits') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleMessageSupport">
|
||||
<i class="pi pi-comments" />
|
||||
{{ $t('credits.messageSupport') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Divider from 'primevue/divider'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { formatMetronomeCurrency } from '@/utils/formatUtil'
|
||||
|
||||
interface CreditHistoryItemData {
|
||||
title: string
|
||||
timestamp: string
|
||||
amount: number
|
||||
isPositive: boolean
|
||||
}
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const dialogService = useDialogService()
|
||||
const authStore = useAuthStore()
|
||||
const authActions = useAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
|
||||
|
||||
const formattedLastUpdateTime = computed(() =>
|
||||
authStore.lastBalanceUpdateTime
|
||||
? authStore.lastBalanceUpdateTime.toLocaleString()
|
||||
: ''
|
||||
)
|
||||
|
||||
watch(
|
||||
() => authStore.lastBalanceUpdateTime,
|
||||
(newTime, oldTime) => {
|
||||
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
|
||||
usageLogsTableRef.value.refresh()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handlePurchaseCreditsClick = () => {
|
||||
// Track purchase credits entry from Settings > Credits panel
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
const handleCreditsHistoryClick = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
const handleMessageSupport = async () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'credits_panel'
|
||||
})
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
const handleFaqClick = () => {
|
||||
window.open(
|
||||
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
const creditHistory = ref<CreditHistoryItemData[]>([])
|
||||
</script>
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Form, FormField } from '@primevue/forms'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
@@ -36,34 +37,116 @@ vi.mock('@/stores/authStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockTurnstileEnabled = ref(false)
|
||||
const mockTurnstileEnforced = ref(false)
|
||||
const mockReset = vi.fn()
|
||||
let emitTurnstileToken: ((token: string) => void) | undefined
|
||||
|
||||
vi.mock('@/composables/auth/useTurnstile', () => ({
|
||||
useTurnstile: () => ({
|
||||
enabled: mockTurnstileEnabled,
|
||||
enforced: mockTurnstileEnforced
|
||||
})
|
||||
}))
|
||||
|
||||
// Stub the real widget (which loads the external Turnstile script) with one that
|
||||
// exposes a spyable reset() and lets a test drive the v-model token the way a
|
||||
// solved challenge would.
|
||||
vi.mock('./TurnstileWidget.vue', async () => {
|
||||
const { defineComponent: defineMock } = await import('vue')
|
||||
return {
|
||||
default: defineMock({
|
||||
name: 'TurnstileWidget',
|
||||
emits: ['update:token'],
|
||||
setup(_, { expose, emit }) {
|
||||
expose({ reset: mockReset })
|
||||
emitTurnstileToken = (token: string) => emit('update:token', token)
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const signUpButton = enMessages.auth.signup.signUpButton
|
||||
|
||||
function globalOptions() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
return {
|
||||
plugins: [PrimeVue, i18n],
|
||||
components: {
|
||||
Form,
|
||||
FormField,
|
||||
Button,
|
||||
InputText,
|
||||
Password,
|
||||
ProgressSpinner
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('SignUpForm', () => {
|
||||
beforeEach(() => {
|
||||
mockLoadingRef.value = false
|
||||
mockTurnstileEnabled.value = false
|
||||
mockTurnstileEnforced.value = false
|
||||
mockReset.mockClear()
|
||||
emitTurnstileToken = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
return render(SignUpForm, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
components: {
|
||||
Form,
|
||||
FormField,
|
||||
Button,
|
||||
InputText,
|
||||
Password,
|
||||
ProgressSpinner
|
||||
}
|
||||
function renderComponent(props: Record<string, unknown> = {}) {
|
||||
const user = userEvent.setup()
|
||||
const utils = render(SignUpForm, { global: globalOptions(), props })
|
||||
return { ...utils, user }
|
||||
}
|
||||
|
||||
/** Render through a host that keeps a ref, so the parent-facing exposed
|
||||
* `resetTurnstile()` can be invoked the way SignInContent would. */
|
||||
function renderWithRef() {
|
||||
const formRef = ref<{ resetTurnstile: () => void } | null>(null)
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
return () => h(SignUpForm, { ref: formRef })
|
||||
}
|
||||
})
|
||||
const utils = render(Host, { global: globalOptions() })
|
||||
return {
|
||||
...utils,
|
||||
form: () => {
|
||||
if (!formRef.value) throw new Error('form not mounted')
|
||||
return formRef.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const expectedValues = {
|
||||
email: 'new@example.com',
|
||||
password: 'Password1!',
|
||||
confirmPassword: 'Password1!'
|
||||
}
|
||||
|
||||
async function fillValidSignup(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(enMessages.auth.signup.emailPlaceholder),
|
||||
expectedValues.email
|
||||
)
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(enMessages.auth.signup.passwordPlaceholder),
|
||||
expectedValues.password
|
||||
)
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(
|
||||
enMessages.auth.login.confirmPasswordPlaceholder
|
||||
),
|
||||
expectedValues.confirmPassword
|
||||
)
|
||||
}
|
||||
|
||||
describe('Password manager autofill attributes', () => {
|
||||
@@ -107,4 +190,97 @@ describe('SignUpForm', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Turnstile single-use token reset', () => {
|
||||
it('exposes resetTurnstile() that resets the rendered widget', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
const { form } = renderWithRef()
|
||||
await nextTick()
|
||||
|
||||
form().resetTurnstile()
|
||||
|
||||
expect(mockReset).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not reset the widget on the initial render', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
renderWithRef()
|
||||
await nextTick()
|
||||
|
||||
expect(mockReset).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Turnstile token hygiene', () => {
|
||||
it('clears the stale token when Turnstile becomes disabled', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const { user } = renderComponent()
|
||||
await fillValidSignup(user)
|
||||
|
||||
emitTurnstileToken!('stale-token')
|
||||
await nextTick()
|
||||
expect(
|
||||
screen.getByRole('button', { name: signUpButton })
|
||||
).not.toBeDisabled()
|
||||
|
||||
mockTurnstileEnabled.value = false
|
||||
await nextTick()
|
||||
|
||||
// re-enable: the stale token must have been cleared so submit is blocked again
|
||||
mockTurnstileEnabled.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Turnstile submit gating', () => {
|
||||
it('disables the submit button in enforce mode until a token is present', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('does not emit submit in enforce mode while the token is empty', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: signUpButton }))
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits submit with the token in enforce mode once the challenge is solved', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
|
||||
emitTurnstileToken!('token-xyz')
|
||||
await nextTick()
|
||||
await user.click(screen.getByRole('button', { name: signUpButton }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedValues, 'token-xyz')
|
||||
})
|
||||
|
||||
it('emits submit without a token in shadow mode (never blocks)', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = false
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: signUpButton }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedValues, undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,13 +29,34 @@
|
||||
|
||||
<PasswordFields />
|
||||
|
||||
<TurnstileWidget
|
||||
v-if="turnstileEnabled"
|
||||
ref="turnstileWidget"
|
||||
v-model:token="turnstileToken"
|
||||
/>
|
||||
|
||||
<small
|
||||
v-show="submitBlockedByTurnstile"
|
||||
id="comfy-org-sign-up-turnstile-hint"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="opacity-80"
|
||||
>
|
||||
{{ t('auth.turnstile.submitBlockedHint') }}
|
||||
</small>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<ProgressSpinner v-if="loading" class="mx-auto size-8" />
|
||||
<Button
|
||||
v-else
|
||||
type="submit"
|
||||
class="mt-4 h-10 font-medium"
|
||||
:disabled="!$form.valid"
|
||||
:disabled="!$form.valid || submitBlockedByTurnstile"
|
||||
:aria-describedby="
|
||||
submitBlockedByTurnstile
|
||||
? 'comfy-org-sign-up-turnstile-hint'
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
{{ t('auth.signup.signUpButton') }}
|
||||
</Button>
|
||||
@@ -49,27 +70,58 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTurnstile } from '@/composables/auth/useTurnstile'
|
||||
import { signUpSchema } from '@/schemas/signInSchema'
|
||||
import type { SignUpData } from '@/schemas/signInSchema'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
import PasswordFields from './PasswordFields.vue'
|
||||
import TurnstileWidget from './TurnstileWidget.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const loading = computed(() => authStore.loading)
|
||||
|
||||
const { enabled: turnstileEnabled, enforced: turnstileEnforced } =
|
||||
useTurnstile()
|
||||
const turnstileToken = ref('')
|
||||
const turnstileWidget =
|
||||
useTemplateRef<InstanceType<typeof TurnstileWidget>>('turnstileWidget')
|
||||
const submitBlockedByTurnstile = computed(
|
||||
() => turnstileEnforced.value && !turnstileToken.value
|
||||
)
|
||||
|
||||
watch(turnstileEnabled, (on) => {
|
||||
if (!on) turnstileToken.value = ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [values: SignUpData]
|
||||
submit: [values: SignUpData, turnstileToken?: string]
|
||||
}>()
|
||||
|
||||
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
|
||||
if (event.valid) {
|
||||
emit('submit', event.values as SignUpData)
|
||||
if (event.valid && !submitBlockedByTurnstile.value) {
|
||||
emit(
|
||||
'submit',
|
||||
event.values as SignUpData,
|
||||
turnstileToken.value || undefined
|
||||
)
|
||||
}
|
||||
}, 1_500)
|
||||
|
||||
// Turnstile tokens are single-use. The parent calls this after a FAILED signup
|
||||
// (the form can't observe the submit outcome itself) to discard the spent token
|
||||
// and request a fresh challenge. Driving it from the actual result — instead of
|
||||
// watching the store-global loading flag — keeps an unrelated auth action from
|
||||
// wiping a freshly-solved token, and avoids resetting a widget that is about to
|
||||
// unmount on success.
|
||||
function resetTurnstile() {
|
||||
turnstileWidget.value?.reset()
|
||||
}
|
||||
|
||||
defineExpose({ resetTurnstile })
|
||||
</script>
|
||||
|
||||
264
src/components/dialog/content/signin/TurnstileWidget.test.ts
Normal file
264
src/components/dialog/content/signin/TurnstileWidget.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render } from '@testing-library/vue'
|
||||
|
||||
import type { TurnstileRenderOptions } from '@/composables/auth/turnstileScript'
|
||||
|
||||
import TurnstileWidget from './TurnstileWidget.vue'
|
||||
|
||||
const { mockLoadTurnstile, mockGetSiteKey, mockLightTheme } = vi.hoisted(
|
||||
() => ({
|
||||
mockLoadTurnstile: vi.fn(),
|
||||
mockGetSiteKey: vi.fn(() => 'site-key'),
|
||||
mockLightTheme: { value: true }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/auth/turnstileScript', () => ({
|
||||
loadTurnstile: mockLoadTurnstile
|
||||
}))
|
||||
vi.mock('@/config/turnstile', () => ({
|
||||
getTurnstileSiteKey: mockGetSiteKey
|
||||
}))
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => ({
|
||||
completedActivePalette: {
|
||||
get light_theme() {
|
||||
return mockLightTheme.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
auth: {
|
||||
turnstile: {
|
||||
expired: 'Challenge expired',
|
||||
failed: 'Verification failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** A controllable Cloudflare Turnstile global whose render() captures options. */
|
||||
function fakeTurnstile() {
|
||||
let captured: TurnstileRenderOptions | undefined
|
||||
const api = {
|
||||
render: vi.fn((_el: unknown, options: TurnstileRenderOptions) => {
|
||||
captured = options
|
||||
return 'widget-id'
|
||||
}),
|
||||
reset: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}
|
||||
return { api, options: () => captured }
|
||||
}
|
||||
|
||||
/** Drain the onMounted async (loadTurnstile) plus any follow-up microtasks. */
|
||||
const flush = async () => {
|
||||
await Promise.resolve()
|
||||
await new Promise((resolve) => setTimeout(resolve))
|
||||
}
|
||||
|
||||
const renderWidget = () =>
|
||||
render(TurnstileWidget, { global: { plugins: [i18n] } })
|
||||
|
||||
/**
|
||||
* Render TurnstileWidget through a thin host that keeps a ref to it, so the
|
||||
* exposed `reset()` method can be invoked the way a parent (SignUpForm) would.
|
||||
*/
|
||||
const renderWidgetWithExpose = () => {
|
||||
const widgetRef = ref<{ reset: () => void } | null>(null)
|
||||
const Host = defineComponent({
|
||||
setup(_, { emit }) {
|
||||
return () =>
|
||||
h(TurnstileWidget, {
|
||||
ref: widgetRef,
|
||||
'onUpdate:token': (value: string) => emit('update:token', value)
|
||||
})
|
||||
}
|
||||
})
|
||||
const utils = render(Host, { global: { plugins: [i18n] } })
|
||||
return {
|
||||
...utils,
|
||||
getCurrentInstance: () => {
|
||||
if (!widgetRef.value) throw new Error('widget not mounted')
|
||||
return widgetRef.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('TurnstileWidget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetSiteKey.mockReturnValue('site-key')
|
||||
mockLightTheme.value = true
|
||||
delete window.turnstile
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.turnstile
|
||||
})
|
||||
|
||||
it('renders the widget with the configured sitekey and light theme', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
renderWidget()
|
||||
await flush()
|
||||
|
||||
expect(mockLoadTurnstile).toHaveBeenCalledOnce()
|
||||
expect(api.render).toHaveBeenCalledOnce()
|
||||
expect(options()?.sitekey).toBe('site-key')
|
||||
expect(options()?.theme).toBe('light')
|
||||
})
|
||||
|
||||
it('uses the dark theme when the active palette is not light', async () => {
|
||||
mockLightTheme.value = false
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
renderWidget()
|
||||
await flush()
|
||||
|
||||
expect(options()?.theme).toBe('dark')
|
||||
})
|
||||
|
||||
it('emits the solved token via v-model and shows no error', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted, container } = renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:token'].at(-1)).toEqual(['token-abc'])
|
||||
expect(container.textContent).not.toContain('Challenge expired')
|
||||
expect(container.textContent).not.toContain('Verification failed')
|
||||
})
|
||||
|
||||
it('clears the token and surfaces the expired message on expiry', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted, container } = renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
options()!['expired-callback']!()
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:token'].at(-1)).toEqual([''])
|
||||
expect(container.textContent).toContain('Challenge expired')
|
||||
})
|
||||
|
||||
it('clears the token and surfaces the failure message on widget error', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted, container } = renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
options()!['error-callback']!()
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:token'].at(-1)).toEqual([''])
|
||||
expect(container.textContent).toContain('Verification failed')
|
||||
})
|
||||
|
||||
it('resets the widget on a challenge error to fetch a fresh challenge', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!['error-callback']!()
|
||||
await flush()
|
||||
|
||||
expect(api.reset).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
|
||||
it('shows the failure message when the Turnstile script fails to load', async () => {
|
||||
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
|
||||
|
||||
const { container } = renderWidget()
|
||||
await flush()
|
||||
|
||||
expect(container.textContent).toContain('Verification failed')
|
||||
})
|
||||
|
||||
it('reset() clears the token model and resets the rendered widget', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
const { emitted, getCurrentInstance } = renderWidgetWithExpose()
|
||||
await flush()
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
await flush()
|
||||
expect(emitted()['update:token'].at(-1)).toEqual(['token-abc'])
|
||||
|
||||
getCurrentInstance().reset()
|
||||
await flush()
|
||||
|
||||
expect(api.reset).toHaveBeenCalledWith('widget-id')
|
||||
expect(emitted()['update:token'].at(-1)).toEqual([''])
|
||||
})
|
||||
|
||||
it('reset() clears a stale error so it does not linger over a fresh challenge', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
const { container, getCurrentInstance } = renderWidgetWithExpose()
|
||||
await flush()
|
||||
|
||||
options()!['error-callback']!()
|
||||
await flush()
|
||||
expect(container.textContent).toContain('Verification failed')
|
||||
|
||||
getCurrentInstance().reset()
|
||||
await flush()
|
||||
expect(container.textContent).not.toContain('Verification failed')
|
||||
})
|
||||
|
||||
it('reset() clears the token even when the widget never rendered', async () => {
|
||||
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
|
||||
|
||||
const { emitted, getCurrentInstance } = renderWidgetWithExpose()
|
||||
await flush()
|
||||
|
||||
getCurrentInstance().reset()
|
||||
await flush()
|
||||
|
||||
// No widget id was captured, so window.turnstile.reset is never called,
|
||||
// but the token model is still cleared.
|
||||
expect(emitted()['update:token']?.at(-1) ?? ['']).toEqual([''])
|
||||
})
|
||||
|
||||
it('removes the widget on unmount when one was rendered', async () => {
|
||||
const { api } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
const { unmount } = renderWidget()
|
||||
await flush()
|
||||
|
||||
unmount()
|
||||
|
||||
expect(api.remove).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
})
|
||||
92
src/components/dialog/content/signin/TurnstileWidget.vue
Normal file
92
src/components/dialog/content/signin/TurnstileWidget.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div ref="containerRef"></div>
|
||||
<small
|
||||
v-if="errorMessage"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
class="text-red-500"
|
||||
>{{ errorMessage }}</small
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { loadTurnstile } from '@/composables/auth/turnstileScript'
|
||||
import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
const token = defineModel<string>('token', { default: '' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const errorMessage = ref('')
|
||||
let widgetId: string | undefined
|
||||
|
||||
const clearToken = () => {
|
||||
token.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a fresh challenge and clear the current token.
|
||||
*
|
||||
* Turnstile tokens are single-use, so after a token is consumed by a submit
|
||||
* attempt that did not succeed, the spent token must be discarded and a new
|
||||
* challenge requested. Clearing the model re-blocks submission until the user
|
||||
* solves the fresh challenge; clearing the error drops any stale failure text
|
||||
* so it can't linger over the new challenge.
|
||||
*/
|
||||
const reset = () => {
|
||||
clearToken()
|
||||
errorMessage.value = ''
|
||||
if (widgetId && window.turnstile) {
|
||||
window.turnstile.reset(widgetId)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reset })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const turnstile = await loadTurnstile()
|
||||
if (!containerRef.value) return
|
||||
|
||||
const theme = colorPaletteStore.completedActivePalette.light_theme
|
||||
? 'light'
|
||||
: 'dark'
|
||||
|
||||
widgetId = turnstile.render(containerRef.value, {
|
||||
sitekey: getTurnstileSiteKey(),
|
||||
theme,
|
||||
callback: (newToken: string) => {
|
||||
errorMessage.value = ''
|
||||
token.value = newToken
|
||||
},
|
||||
'expired-callback': () => {
|
||||
clearToken()
|
||||
errorMessage.value = t('auth.turnstile.expired')
|
||||
},
|
||||
'error-callback': () => {
|
||||
clearToken()
|
||||
console.warn('Turnstile challenge failed')
|
||||
errorMessage.value = t('auth.turnstile.failed')
|
||||
if (widgetId && window.turnstile) window.turnstile.reset(widgetId)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Turnstile failed to load', error)
|
||||
errorMessage.value = t('auth.turnstile.failed')
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (widgetId && window.turnstile) {
|
||||
window.turnstile.remove(widgetId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -195,10 +195,7 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
|
||||
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
|
||||
import { useUrlActionLoaders } from '@/composables/useUrlActionLoaders'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{
|
||||
@@ -457,10 +454,7 @@ useEventListener(
|
||||
|
||||
const comfyAppReady = ref(false)
|
||||
const workflowPersistence = useWorkflowPersistence()
|
||||
const { flags } = useFeatureFlags()
|
||||
// Set up URL loaders during setup phase so useRoute/useRouter work correctly
|
||||
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
|
||||
const createWorkspaceUrlLoader = isCloud ? useCreateWorkspaceUrlLoader() : null
|
||||
const { runUrlActionLoaders } = useUrlActionLoaders()
|
||||
useCanvasDrop(canvasRef)
|
||||
useLitegraphSettings()
|
||||
useNodeBadge()
|
||||
@@ -569,23 +563,8 @@ onMounted(async () => {
|
||||
() => canvasStore.updateSelectedItems()
|
||||
)
|
||||
|
||||
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
|
||||
// WorkspaceAuthGate ensures flag state is resolved before GraphCanvas mounts
|
||||
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {
|
||||
await inviteUrlLoader.loadInviteFromUrl()
|
||||
}
|
||||
|
||||
// Open create workspace dialog from URL if present (e.g., ?create_workspace=1)
|
||||
if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) {
|
||||
try {
|
||||
await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[GraphCanvas] Failed to load create workspace from URL:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
// Run query-param deep-link loaders (?invite, ?create_workspace, ?pricing)
|
||||
await runUrlActionLoaders()
|
||||
|
||||
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
|
||||
const { useReleaseStore } =
|
||||
|
||||
@@ -26,7 +26,6 @@ const singleErrorCard: ErrorCardData = {
|
||||
title: 'CLIPTextEncode',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeTitle: 'CLIP Text Encode (Prompt)',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input "text" is missing.',
|
||||
@@ -40,7 +39,6 @@ const multipleErrorsCard: ErrorCardData = {
|
||||
title: 'VAEDecode',
|
||||
nodeId: createNodeExecutionId([24]),
|
||||
nodeTitle: 'VAE Decode',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input "samples" is missing.',
|
||||
@@ -58,7 +56,6 @@ const runtimeErrorCard: ErrorCardData = {
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([45]),
|
||||
nodeTitle: 'KSampler',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'OutOfMemoryError: CUDA out of memory. Tried to allocate 1.2GB.',
|
||||
@@ -73,20 +70,6 @@ const runtimeErrorCard: ErrorCardData = {
|
||||
]
|
||||
}
|
||||
|
||||
const subgraphErrorCard: ErrorCardData = {
|
||||
id: 'node-3:15',
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([3, 15]),
|
||||
nodeTitle: 'Nested KSampler',
|
||||
isSubgraphNode: true,
|
||||
errors: [
|
||||
{
|
||||
message: 'Latent input is required.',
|
||||
details: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const promptOnlyCard: ErrorCardData = {
|
||||
id: '__prompt__',
|
||||
title: 'Prompt has no outputs.',
|
||||
@@ -104,13 +87,6 @@ export const SingleValidationError: Story = {
|
||||
}
|
||||
}
|
||||
|
||||
/** Subgraph node error — shows "Enter subgraph" button */
|
||||
export const WithEnterSubgraphButton: Story = {
|
||||
args: {
|
||||
card: subgraphErrorCard
|
||||
}
|
||||
}
|
||||
|
||||
/** Multiple validation errors on one node */
|
||||
export const MultipleErrors: Story = {
|
||||
args: {
|
||||
|
||||
@@ -79,7 +79,6 @@ describe('ErrorNodeCard.vue', () => {
|
||||
},
|
||||
rightSidePanel: {
|
||||
locateNode: 'Locate Node',
|
||||
enterSubgraph: 'Enter Subgraph',
|
||||
errorLog: 'Error log',
|
||||
findOnGithubTooltip: 'Search GitHub issues for related problems',
|
||||
getHelpTooltip:
|
||||
|
||||
@@ -21,15 +21,6 @@
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasRuntimeError"
|
||||
variant="textonly"
|
||||
@@ -202,7 +193,6 @@ const { card, compact = false } = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateNode: [nodeId: string]
|
||||
enterSubgraph: [nodeId: string]
|
||||
copyToClipboard: [text: string]
|
||||
}>()
|
||||
|
||||
@@ -233,12 +223,6 @@ function handleLocateNode() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnterSubgraph() {
|
||||
if (card.nodeId) {
|
||||
emit('enterSubgraph', card.nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyError(idx: number) {
|
||||
const details = displayedDetailsMap.value[idx]
|
||||
const message = getCopyMessage(card.errors[idx])
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
const mockFocusNode = vi.hoisted(() => vi.fn())
|
||||
const mockEnterSubgraph = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
@@ -35,16 +34,9 @@ vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: vi.fn(() => ({
|
||||
fitView: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/canvas/useFocusNode', () => ({
|
||||
useFocusNode: vi.fn(() => ({
|
||||
focusNode: mockFocusNode,
|
||||
enterSubgraph: mockEnterSubgraph
|
||||
focusNode: mockFocusNode
|
||||
}))
|
||||
}))
|
||||
|
||||
|
||||
@@ -249,7 +249,6 @@
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
@@ -357,7 +356,7 @@ const ErrorPanelSurveyCta =
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { focusNode } = useFocusNode()
|
||||
const { openGitHubIssues, contactSupport } = useErrorActions()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
@@ -523,8 +522,4 @@ function handleReplaceGroup(group: SwapNodeGroup) {
|
||||
function handleReplaceAll() {
|
||||
replaceAllGroups(swapNodeGroups.value)
|
||||
}
|
||||
|
||||
function handleEnterSubgraph(nodeId: string) {
|
||||
enterSubgraph(nodeId, errorNodeCache.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -16,7 +16,6 @@ export interface ErrorCardData {
|
||||
nodeId?: NodeExecutionId
|
||||
nodeTitle?: string
|
||||
graphNodeId?: string
|
||||
isSubgraphNode?: boolean
|
||||
errors: ErrorItem[]
|
||||
}
|
||||
|
||||
|
||||
@@ -671,30 +671,6 @@ describe('useErrorGroups', () => {
|
||||
expect(nodeIds).toEqual(['1', '2', '10'])
|
||||
})
|
||||
|
||||
it('marks only nested execution paths as subgraph node cards', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
},
|
||||
'1:20': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroup?.cards).toMatchObject([
|
||||
{ nodeId: '1', isSubgraphNode: false },
|
||||
{ nodeId: '1:20', isSubgraphNode: true }
|
||||
])
|
||||
})
|
||||
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
|
||||
@@ -130,7 +130,6 @@ function createErrorCard(
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: nodeId.includes(':'),
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,18 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { h, ref } from 'vue'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import type { BalanceInfo, SubscriptionInfo } from '@/composables/billing/types'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
|
||||
|
||||
// Mock all firebase modules
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
getApp: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('firebase/auth', () => ({
|
||||
getAuth: vi.fn(),
|
||||
setPersistence: vi.fn(),
|
||||
browserLocalPersistence: {},
|
||||
onAuthStateChanged: vi.fn(),
|
||||
signInWithEmailAndPassword: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock pinia
|
||||
vi.mock('pinia')
|
||||
|
||||
// Mock showSettingsDialog and showTopUpCreditsDialog
|
||||
const mockShowSettingsDialog = vi.fn()
|
||||
const mockShowTopUpCreditsDialog = vi.fn()
|
||||
|
||||
// Mock the settings dialog composable
|
||||
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
useSettingsDialog: vi.fn(() => ({
|
||||
show: mockShowSettingsDialog,
|
||||
@@ -40,7 +21,6 @@ vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock window.open
|
||||
const originalWindowOpen = window.open
|
||||
beforeEach(() => {
|
||||
window.open = vi.fn()
|
||||
@@ -50,7 +30,6 @@ afterAll(() => {
|
||||
window.open = originalWindowOpen
|
||||
})
|
||||
|
||||
// Mock the useCurrentUser composable
|
||||
const mockHandleSignOut = vi.fn()
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: vi.fn(() => ({
|
||||
@@ -61,60 +40,50 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useAuthActions composable
|
||||
const mockLogout = vi.fn()
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: vi.fn(() => ({
|
||||
fetchBalance: vi.fn().mockResolvedValue(undefined),
|
||||
logout: mockLogout
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the dialog service
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => ({
|
||||
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the authStore with hoisted state for per-test manipulation
|
||||
const mockAuthStoreState = vi.hoisted(() => ({
|
||||
balance: {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 100_000,
|
||||
currency: 'usd'
|
||||
} as {
|
||||
amount_micros?: number
|
||||
effective_balance_micros?: number
|
||||
currency: string
|
||||
},
|
||||
isFetchingBalance: false
|
||||
}))
|
||||
function makeSubscription(
|
||||
overrides: Partial<SubscriptionInfo> = {}
|
||||
): SubscriptionInfo {
|
||||
return {
|
||||
isActive: true,
|
||||
tier: 'CREATOR',
|
||||
duration: 'MONTHLY',
|
||||
planSlug: null,
|
||||
renewalDate: null,
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
hasFunds: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => ({
|
||||
getAuthHeader: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),
|
||||
balance: mockAuthStoreState.balance,
|
||||
isFetchingBalance: mockAuthStoreState.isFetchingBalance
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useSubscription composable
|
||||
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
|
||||
const mockFetchBalance = vi.fn().mockResolvedValue(undefined)
|
||||
const mockIsActiveSubscription = ref(true)
|
||||
const mockIsFreeTier = ref(false)
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: vi.fn(() => ({
|
||||
isActiveSubscription: ref(true),
|
||||
const mockTier = ref<SubscriptionInfo['tier']>('CREATOR')
|
||||
const mockSubscription = ref<SubscriptionInfo | null>(makeSubscription())
|
||||
const mockBalance = ref<BalanceInfo | null>(null)
|
||||
const mockIsLoading = ref(false)
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
isActiveSubscription: mockIsActiveSubscription,
|
||||
isFreeTier: mockIsFreeTier,
|
||||
subscriptionTierName: ref('Creator'),
|
||||
subscriptionTier: ref('CREATOR'),
|
||||
fetchStatus: mockFetchStatus
|
||||
tier: mockTier,
|
||||
subscription: mockSubscription,
|
||||
balance: mockBalance,
|
||||
isLoading: mockIsLoading,
|
||||
fetchStatus: mockFetchStatus,
|
||||
fetchBalance: mockFetchBalance
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useSubscriptionDialog composable
|
||||
const mockShowPricingTable = vi.fn()
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
@@ -127,7 +96,6 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
// Mock UserAvatar component
|
||||
vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
default: {
|
||||
name: 'UserAvatarMock',
|
||||
@@ -137,22 +105,10 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock UserCredit component
|
||||
vi.mock('@/components/common/UserCredit.vue', () => ({
|
||||
default: {
|
||||
name: 'UserCreditMock',
|
||||
render() {
|
||||
return h('div', 'Credit: 100')
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock formatCreditsFromCents
|
||||
vi.mock('@/base/credits/comfyCredits', () => ({
|
||||
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
|
||||
}))
|
||||
|
||||
// Mock useExternalLink
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: vi.fn(() => ({
|
||||
buildDocsUrl: vi.fn((path) => `https://docs.comfy.org${path}`),
|
||||
@@ -162,14 +118,12 @@ vi.mock('@/composables/useExternalLink', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock useTelemetry
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackAddApiCreditButtonClicked: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock isCloud with hoisted state for per-test toggling
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: true }))
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
@@ -178,25 +132,37 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
|
||||
default: {
|
||||
default: defineComponent({
|
||||
name: 'SubscribeButtonMock',
|
||||
render() {
|
||||
return h('div', 'Subscribe Button')
|
||||
emits: ['subscribed'],
|
||||
setup(_, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
'data-testid': 'subscribe-button-mock',
|
||||
onClick: () => emit('subscribed')
|
||||
},
|
||||
'Subscribe Button'
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
describe('CurrentUserPopoverLegacy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockIsActiveSubscription.value = true
|
||||
mockIsFreeTier.value = false
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 100_000,
|
||||
mockTier.value = 'CREATOR'
|
||||
mockSubscription.value = makeSubscription()
|
||||
mockBalance.value = {
|
||||
amountMicros: 100_000,
|
||||
effectiveBalanceMicros: 100_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
mockAuthStoreState.isFetchingBalance = false
|
||||
mockIsLoading.value = false
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
@@ -230,7 +196,47 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
|
||||
it('fetches the balance through the billing facade on mount', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(mockFetchBalance).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes subscription status through the billing facade after subscribing', async () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByTestId('subscribe-button-mock'))
|
||||
|
||||
expect(mockFetchStatus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('subscription tier badge', () => {
|
||||
it('renders the tier name derived from the facade tier', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('Creator')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the yearly tier name when the facade subscription is annual', () => {
|
||||
mockSubscription.value = makeSubscription({ duration: 'ANNUAL' })
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('Creator Yearly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the badge when the facade reports no tier', () => {
|
||||
mockTier.value = null
|
||||
mockSubscription.value = null
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.queryByText('Creator')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('formats and displays the facade balance', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(formatCreditsFromCents).toHaveBeenCalledWith({
|
||||
@@ -245,6 +251,14 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('1000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a skeleton instead of the balance while billing is loading', () => {
|
||||
mockIsLoading.value = true
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.queryByText('1000')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders logout menu item with correct text', () => {
|
||||
renderComponent()
|
||||
|
||||
@@ -324,11 +338,11 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
describe('effective_balance_micros handling', () => {
|
||||
it('uses effective_balance_micros when present (positive balance)', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 200_000,
|
||||
effective_balance_micros: 150_000,
|
||||
describe('facade balance handling', () => {
|
||||
it('uses effectiveBalanceMicros when present (positive balance)', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 200_000,
|
||||
effectiveBalanceMicros: 150_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -345,10 +359,10 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('1500')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses effective_balance_micros when zero', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 0,
|
||||
it('uses effectiveBalanceMicros when zero', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 100_000,
|
||||
effectiveBalanceMicros: 0,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -365,10 +379,10 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses effective_balance_micros when negative', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 0,
|
||||
effective_balance_micros: -50_000,
|
||||
it('uses effectiveBalanceMicros when negative', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 0,
|
||||
effectiveBalanceMicros: -50_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -385,9 +399,9 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('-500')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to amount_micros when effective_balance_micros is missing', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
it('falls back to amountMicros when effectiveBalanceMicros is missing', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 100_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -404,10 +418,8 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('1000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
currency: 'usd'
|
||||
}
|
||||
it('falls back to 0 when the facade reports no balance', () => {
|
||||
mockBalance.value = null
|
||||
|
||||
renderComponent()
|
||||
|
||||
@@ -466,8 +478,11 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
})
|
||||
|
||||
it('hides subscribe button', () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
renderComponent()
|
||||
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('subscribe-button-mock')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still shows partner nodes menu item', () => {
|
||||
|
||||
@@ -32,12 +32,7 @@
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<Skeleton
|
||||
v-if="authStore.isFetchingBalance"
|
||||
width="4rem"
|
||||
height="1.25rem"
|
||||
class="w-full"
|
||||
/>
|
||||
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
}}</span>
|
||||
@@ -162,16 +157,15 @@ import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useWorkspaceTierLabel } from '@/platform/workspace/composables/useWorkspaceTierLabel'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
@@ -181,25 +175,29 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const authActions = useAuthActions()
|
||||
const authStore = useAuthStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const {
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
subscriptionTierName,
|
||||
subscriptionTier,
|
||||
fetchStatus
|
||||
} = useSubscription()
|
||||
tier,
|
||||
subscription,
|
||||
balance,
|
||||
isLoading,
|
||||
fetchStatus,
|
||||
fetchBalance
|
||||
} = useBillingContext()
|
||||
const { formatTierName } = useWorkspaceTierLabel()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
const { locale } = useI18n()
|
||||
|
||||
const subscriptionTierName = computed(() =>
|
||||
formatTierName(tier.value, subscription.value?.duration === 'ANNUAL')
|
||||
)
|
||||
|
||||
const formattedBalance = computed(() => {
|
||||
const cents =
|
||||
authStore.balance?.effective_balance_micros ??
|
||||
authStore.balance?.amount_micros ??
|
||||
0
|
||||
balance.value?.effectiveBalanceMicros ?? balance.value?.amountMicros ?? 0
|
||||
return formatCreditsFromCents({
|
||||
cents,
|
||||
locale: locale.value,
|
||||
@@ -211,12 +209,12 @@ const formattedBalance = computed(() => {
|
||||
})
|
||||
|
||||
const canUpgrade = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
const currentTier = tier.value
|
||||
return (
|
||||
tier === 'FREE' ||
|
||||
tier === 'FOUNDERS_EDITION' ||
|
||||
tier === 'STANDARD' ||
|
||||
tier === 'CREATOR'
|
||||
currentTier === 'FREE' ||
|
||||
currentTier === 'FOUNDERS_EDITION' ||
|
||||
currentTier === 'STANDARD' ||
|
||||
currentTier === 'CREATOR'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -270,6 +268,6 @@ const handleSubscribed = async () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void authActions.fetchBalance()
|
||||
void fetchBalance()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -22,6 +22,8 @@ export const buttonVariants = cva({
|
||||
link: 'bg-transparent text-muted-foreground hover:text-base-foreground',
|
||||
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
|
||||
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
|
||||
tertiary:
|
||||
'bg-tertiary-background text-base-foreground hover:bg-tertiary-background-hover',
|
||||
gradient:
|
||||
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
|
||||
},
|
||||
@@ -54,6 +56,7 @@ const variants = [
|
||||
'destructive-textonly',
|
||||
'link',
|
||||
'base',
|
||||
'tertiary',
|
||||
'overlay-white',
|
||||
'gradient'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
|
||||
@@ -13,7 +13,8 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import {
|
||||
DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
TEAM_PLAN_CREDIT_STOPS
|
||||
TEAM_PLAN_CREDIT_STOPS,
|
||||
getStopDiscountedMonthlyUsd
|
||||
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import type { CreditStop } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
|
||||
@@ -83,7 +84,7 @@ const effectiveDiscountPercent = computed(() =>
|
||||
: current.value.discountPercentYearly
|
||||
)
|
||||
const discountedMonthly = computed(() =>
|
||||
Math.round(current.value.usd * (1 - effectiveDiscountPercent.value / 100))
|
||||
getStopDiscountedMonthlyUsd(current.value, cycle)
|
||||
)
|
||||
const saveAmount = computed(() => current.value.usd - discountedMonthly.value)
|
||||
const hasDiscount = computed(() => effectiveDiscountPercent.value > 0)
|
||||
|
||||
232
src/composables/auth/turnstileScript.test.ts
Normal file
232
src/composables/auth/turnstileScript.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { TurnstileApi } from '@/composables/auth/turnstileScript'
|
||||
|
||||
const TURNSTILE_SRC =
|
||||
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||
|
||||
const fakeApi = (): TurnstileApi => ({
|
||||
render: vi.fn(() => 'widget-id'),
|
||||
reset: vi.fn(),
|
||||
remove: vi.fn()
|
||||
})
|
||||
|
||||
/**
|
||||
* Controllable stand-in for the injected <script>. We never insert a real
|
||||
* external script because jsdom would try (and fail) to fetch it and fire its
|
||||
* own `error` event, making `load` impossible to simulate deterministically.
|
||||
* Instead `createElement`/`querySelector`/`appendChild` are spied to route
|
||||
* through this fake, so the test drives `load`/`error`/timeout itself.
|
||||
*/
|
||||
class FakeScript {
|
||||
src = ''
|
||||
async = false
|
||||
private handlers: Record<string, Array<(e: Event) => void>> = {}
|
||||
|
||||
addEventListener(type: string, cb: (e: Event) => void) {
|
||||
;(this.handlers[type] ??= []).push(cb)
|
||||
}
|
||||
|
||||
dispatchEvent(event: Event): boolean {
|
||||
for (const cb of this.handlers[event.type] ?? []) cb(event)
|
||||
return true
|
||||
}
|
||||
|
||||
remove() {
|
||||
const i = inserted.indexOf(this)
|
||||
if (i >= 0) inserted.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
let inserted: FakeScript[] = []
|
||||
|
||||
const scriptEl = (): FakeScript | null =>
|
||||
inserted.find((s) => s.src === TURNSTILE_SRC) ?? null
|
||||
|
||||
const scriptCount = () => inserted.filter((s) => s.src === TURNSTILE_SRC).length
|
||||
|
||||
/**
|
||||
* The module keeps a private singleton promise, so each test imports a fresh
|
||||
* copy after `vi.resetModules()`.
|
||||
*/
|
||||
async function freshLoadTurnstile() {
|
||||
vi.resetModules()
|
||||
const mod = await import('@/composables/auth/turnstileScript')
|
||||
return mod.loadTurnstile
|
||||
}
|
||||
|
||||
describe('loadTurnstile', () => {
|
||||
beforeEach(() => {
|
||||
inserted = []
|
||||
delete window.turnstile
|
||||
|
||||
const realCreateElement = document.createElement.bind(document)
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tag: string) =>
|
||||
tag === 'script'
|
||||
? (new FakeScript() as unknown as HTMLElement)
|
||||
: realCreateElement(tag)
|
||||
)
|
||||
vi.spyOn(document, 'querySelector').mockImplementation((sel: string) =>
|
||||
typeof sel === 'string' && sel.includes('challenges.cloudflare.com')
|
||||
? (scriptEl() as unknown as Element | null)
|
||||
: null
|
||||
)
|
||||
vi.spyOn(document.head, 'appendChild').mockImplementation((node: Node) => {
|
||||
inserted.push(node as unknown as FakeScript)
|
||||
return node
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('resolves immediately with the existing global and appends no script', async () => {
|
||||
const api = fakeApi()
|
||||
window.turnstile = api
|
||||
|
||||
const loadTurnstile = await freshLoadTurnstile()
|
||||
|
||||
await expect(loadTurnstile()).resolves.toBe(api)
|
||||
expect(scriptEl()).toBeNull()
|
||||
})
|
||||
|
||||
it('appends the script and resolves once it loads and exposes the global', async () => {
|
||||
const loadTurnstile = await freshLoadTurnstile()
|
||||
|
||||
const promise = loadTurnstile()
|
||||
const el = scriptEl()
|
||||
expect(el).not.toBeNull()
|
||||
expect(el?.async).toBe(true)
|
||||
|
||||
const api = fakeApi()
|
||||
window.turnstile = api
|
||||
el!.dispatchEvent(new Event('load'))
|
||||
|
||||
await expect(promise).resolves.toBe(api)
|
||||
})
|
||||
|
||||
it('caches the in-flight promise so concurrent callers share one load', async () => {
|
||||
const loadTurnstile = await freshLoadTurnstile()
|
||||
|
||||
const p1 = loadTurnstile()
|
||||
const p2 = loadTurnstile()
|
||||
|
||||
expect(p1).toBe(p2)
|
||||
expect(scriptCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('polls for the global when it is published asynchronously after the load event', async () => {
|
||||
vi.useFakeTimers()
|
||||
const loadTurnstile = await freshLoadTurnstile()
|
||||
|
||||
const promise = loadTurnstile()
|
||||
scriptEl()!.dispatchEvent(new Event('load'))
|
||||
// global published shortly after load
|
||||
const api = fakeApi()
|
||||
window.turnstile = api
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
await expect(promise).resolves.toBe(api)
|
||||
// tag stays in place on success
|
||||
expect(scriptEl()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('rejects and clears the cache when the global never appears after load (poll timeout)', async () => {
|
||||
vi.useFakeTimers()
|
||||
const loadTurnstile = await freshLoadTurnstile()
|
||||
|
||||
const promise = loadTurnstile()
|
||||
scriptEl()!.dispatchEvent(new Event('load'))
|
||||
// global never published; deadline elapses
|
||||
const assertion = expect(promise).rejects.toThrow(/timed out/i)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
await assertion
|
||||
// dead tag is removed so a later retry starts clean
|
||||
expect(scriptEl()).toBeNull()
|
||||
// cache was reset → a later call starts a brand-new load
|
||||
const retry = loadTurnstile()
|
||||
expect(retry).not.toBe(promise)
|
||||
// settle the throwaway retry so it doesn't leak a 10s timer
|
||||
retry.catch(() => {})
|
||||
scriptEl()!.dispatchEvent(new Event('error'))
|
||||
await expect(retry).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects, removes the self-appended script, and clears the cache on load error', async () => {
|
||||
const loadTurnstile = await freshLoadTurnstile()
|
||||
|
||||
const promise = loadTurnstile()
|
||||
scriptEl()!.dispatchEvent(new Event('error'))
|
||||
|
||||
await expect(promise).rejects.toThrow(/failed to load/i)
|
||||
expect(scriptEl()).toBeNull()
|
||||
// cache was reset → a later call starts a brand-new load
|
||||
const retry = loadTurnstile()
|
||||
expect(retry).not.toBe(promise)
|
||||
// settle the throwaway retry so it doesn't leak a 10s timer
|
||||
retry.catch(() => {})
|
||||
scriptEl()!.dispatchEvent(new Event('error'))
|
||||
await expect(retry).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects, removes the script, and clears the cache on timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
const loadTurnstile = await freshLoadTurnstile()
|
||||
|
||||
const promise = loadTurnstile()
|
||||
const assertion = expect(promise).rejects.toThrow(/timed out/i)
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
await assertion
|
||||
expect(scriptEl()).toBeNull()
|
||||
})
|
||||
|
||||
it('reuses a pre-existing script tag and resolves promptly once the global appears (no duplicate, tag left in place)', async () => {
|
||||
vi.useFakeTimers()
|
||||
const existing = new FakeScript()
|
||||
existing.src = TURNSTILE_SRC
|
||||
inserted.push(existing)
|
||||
|
||||
const loadTurnstile = await freshLoadTurnstile()
|
||||
const promise = loadTurnstile()
|
||||
|
||||
// no duplicate appended
|
||||
expect(scriptCount()).toBe(1)
|
||||
|
||||
// The pre-existing tag's load event may have already fired before we
|
||||
// attached listeners, so resolution must come from polling for the global
|
||||
// rather than from a (dead) load event.
|
||||
const api = fakeApi()
|
||||
window.turnstile = api
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
await expect(promise).resolves.toBe(api)
|
||||
// a pre-existing tag is left alone (never removed by this loader)
|
||||
expect(scriptEl()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('reuses a pre-existing script tag and times out (clearing the cache) if the global never appears, leaving the tag in place', async () => {
|
||||
vi.useFakeTimers()
|
||||
const existing = new FakeScript()
|
||||
existing.src = TURNSTILE_SRC
|
||||
inserted.push(existing)
|
||||
|
||||
const loadTurnstile = await freshLoadTurnstile()
|
||||
const promise = loadTurnstile()
|
||||
const assertion = expect(promise).rejects.toThrow(/timed out/i)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
await assertion
|
||||
// pre-existing tag is never removed by the loader
|
||||
expect(scriptEl()).not.toBeNull()
|
||||
// cache was reset → a later call starts a brand-new load
|
||||
const retry = loadTurnstile()
|
||||
expect(retry).not.toBe(promise)
|
||||
// drain the throwaway retry's timer/promise so nothing leaks
|
||||
retry.catch(() => {})
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
})
|
||||
})
|
||||
36
src/composables/auth/turnstileScript.ts
Normal file
36
src/composables/auth/turnstileScript.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createScriptLoader } from '@/utils/loadExternalScript'
|
||||
|
||||
const TURNSTILE_SRC =
|
||||
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||
|
||||
export interface TurnstileRenderOptions {
|
||||
sitekey: string
|
||||
theme?: 'light' | 'dark' | 'auto'
|
||||
callback?: (token: string) => void
|
||||
'expired-callback'?: () => void
|
||||
'error-callback'?: () => void
|
||||
}
|
||||
|
||||
export interface TurnstileApi {
|
||||
render: (
|
||||
container: string | HTMLElement,
|
||||
options: TurnstileRenderOptions
|
||||
) => string
|
||||
reset: (widgetId?: string) => void
|
||||
remove: (widgetId: string) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: TurnstileApi
|
||||
}
|
||||
}
|
||||
|
||||
const loadTurnstileScript = createScriptLoader(
|
||||
TURNSTILE_SRC,
|
||||
() => window.turnstile ?? null
|
||||
)
|
||||
|
||||
export function loadTurnstile(): Promise<TurnstileApi> {
|
||||
return loadTurnstileScript()
|
||||
}
|
||||
@@ -199,8 +199,8 @@ export const useAuthActions = () => {
|
||||
)
|
||||
|
||||
const signUpWithEmail = wrapWithErrorHandlingAsync(
|
||||
async (email: string, password: string) => {
|
||||
return await authStore.register(email, password)
|
||||
async (email: string, password: string, turnstileToken?: string) => {
|
||||
return await authStore.register(email, password, turnstileToken)
|
||||
},
|
||||
reportError
|
||||
)
|
||||
|
||||
139
src/composables/auth/useTurnstile.test.ts
Normal file
139
src/composables/auth/useTurnstile.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
isTurnstileEnabled,
|
||||
normalizeTurnstileMode,
|
||||
useTurnstile
|
||||
} from '@/composables/auth/useTurnstile'
|
||||
import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { api } from '@/scripts/api'
|
||||
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
|
||||
|
||||
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
|
||||
remoteConfig: { value: {} }
|
||||
}))
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: { getServerFeature: vi.fn() }
|
||||
}))
|
||||
vi.mock('@/utils/devFeatureFlagOverride', () => ({
|
||||
getDevOverride: vi.fn()
|
||||
}))
|
||||
vi.mock('@/config/turnstile', () => ({
|
||||
getTurnstileSiteKey: vi.fn()
|
||||
}))
|
||||
|
||||
const mockedDevOverride = vi.mocked(getDevOverride)
|
||||
const mockedGetServerFeature = vi.mocked(api.getServerFeature)
|
||||
const mockedSiteKey = vi.mocked(getTurnstileSiteKey)
|
||||
|
||||
describe('normalizeTurnstileMode', () => {
|
||||
it('passes through known modes', () => {
|
||||
expect(normalizeTurnstileMode('off')).toBe('off')
|
||||
expect(normalizeTurnstileMode('shadow')).toBe('shadow')
|
||||
expect(normalizeTurnstileMode('enforce')).toBe('enforce')
|
||||
})
|
||||
|
||||
it('clamps unknown or missing values to off', () => {
|
||||
expect(normalizeTurnstileMode('enfroce')).toBe('off')
|
||||
expect(normalizeTurnstileMode('')).toBe('off')
|
||||
expect(normalizeTurnstileMode(undefined)).toBe('off')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTurnstileEnabled', () => {
|
||||
it('renders when the flag is active and a sitekey is configured', () => {
|
||||
expect(isTurnstileEnabled('shadow', 'site-key')).toBe(true)
|
||||
expect(isTurnstileEnabled('enforce', 'site-key')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render when the flag is off', () => {
|
||||
expect(isTurnstileEnabled('off', 'site-key')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not render without a sitekey (OSS / local builds)', () => {
|
||||
expect(isTurnstileEnabled('shadow', '')).toBe(false)
|
||||
expect(isTurnstileEnabled('enforce', '')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTurnstile', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
remoteConfig.value = {}
|
||||
mockedDevOverride.mockReturnValue(undefined)
|
||||
mockedGetServerFeature.mockReturnValue('off')
|
||||
mockedSiteKey.mockReturnValue('site-key')
|
||||
})
|
||||
|
||||
describe('mode precedence', () => {
|
||||
it('prefers the dev override over remote config and the server feature', () => {
|
||||
mockedDevOverride.mockReturnValue('enforce')
|
||||
remoteConfig.value = { signup_turnstile: 'shadow' }
|
||||
mockedGetServerFeature.mockReturnValue('off')
|
||||
|
||||
expect(useTurnstile().mode.value).toBe('enforce')
|
||||
})
|
||||
|
||||
it('uses remote config when there is no dev override', () => {
|
||||
remoteConfig.value = { signup_turnstile: 'shadow' }
|
||||
|
||||
expect(useTurnstile().mode.value).toBe('shadow')
|
||||
})
|
||||
|
||||
it('falls back to the server feature flag (default off) when nothing else is set', () => {
|
||||
mockedGetServerFeature.mockReturnValue('enforce')
|
||||
|
||||
expect(useTurnstile().mode.value).toBe('enforce')
|
||||
expect(mockedGetServerFeature).toHaveBeenCalledWith(
|
||||
'signup_turnstile',
|
||||
'off'
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps an unknown remote-config value to off', () => {
|
||||
remoteConfig.value = {
|
||||
signup_turnstile: 'bogus' as unknown as 'shadow'
|
||||
}
|
||||
|
||||
expect(useTurnstile().mode.value).toBe('off')
|
||||
})
|
||||
|
||||
it('resolves to off when every source is unset', () => {
|
||||
expect(useTurnstile().mode.value).toBe('off')
|
||||
})
|
||||
})
|
||||
|
||||
describe('enabled / enforced', () => {
|
||||
it('is enabled but not enforced in shadow with a sitekey', () => {
|
||||
remoteConfig.value = { signup_turnstile: 'shadow' }
|
||||
|
||||
const { enabled, enforced } = useTurnstile()
|
||||
expect(enabled.value).toBe(true)
|
||||
expect(enforced.value).toBe(false)
|
||||
})
|
||||
|
||||
it('is enabled and enforced in enforce with a sitekey', () => {
|
||||
remoteConfig.value = { signup_turnstile: 'enforce' }
|
||||
|
||||
const { enabled, enforced } = useTurnstile()
|
||||
expect(enabled.value).toBe(true)
|
||||
expect(enforced.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is neither enabled nor enforced without a sitekey, even in enforce', () => {
|
||||
remoteConfig.value = { signup_turnstile: 'enforce' }
|
||||
mockedSiteKey.mockReturnValue('')
|
||||
|
||||
const { enabled, enforced } = useTurnstile()
|
||||
expect(enabled.value).toBe(false)
|
||||
expect(enforced.value).toBe(false)
|
||||
})
|
||||
|
||||
it('is disabled when the mode is off', () => {
|
||||
const { enabled, enforced } = useTurnstile()
|
||||
expect(enabled.value).toBe(false)
|
||||
expect(enforced.value).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
44
src/composables/auth/useTurnstile.ts
Normal file
44
src/composables/auth/useTurnstile.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import type { TurnstileMode } from '@/platform/remoteConfig/types'
|
||||
|
||||
/**
|
||||
* Clamp an externally-sourced value to a known TurnstileMode. Unknown strings
|
||||
* (typos, stale flag variants) resolve to 'off' so a bad value can never leave
|
||||
* the widget rendered-but-unenforced — mirrors the server-side resolver.
|
||||
*/
|
||||
export function normalizeTurnstileMode(raw: string | undefined): TurnstileMode {
|
||||
return raw === 'shadow' || raw === 'enforce' ? raw : 'off'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the signup Turnstile widget should render. Purely config-driven: the
|
||||
* flag must be shadow/enforce and a sitekey must be configured. OSS / local
|
||||
* builds resolve no sitekey — the real per-env keys are tree-shaken out via the
|
||||
* __DISTRIBUTION__ build define (see config/turnstile.ts) — so the widget never
|
||||
* renders. The local-OSS exemption lives server-side (loopback-IP check in
|
||||
* CreateCustomer).
|
||||
*/
|
||||
export function isTurnstileEnabled(
|
||||
mode: TurnstileMode,
|
||||
siteKey: string
|
||||
): boolean {
|
||||
return mode !== 'off' && siteKey !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive Turnstile state for the signup form.
|
||||
* - `enabled`: render the widget
|
||||
* - `enforced`: block submit until the challenge is solved
|
||||
*/
|
||||
export function useTurnstile() {
|
||||
const { flags } = useFeatureFlags()
|
||||
const mode = computed(() => normalizeTurnstileMode(flags.signupTurnstileMode))
|
||||
const siteKey = computed(getTurnstileSiteKey)
|
||||
const enabled = computed(() => isTurnstileEnabled(mode.value, siteKey.value))
|
||||
const enforced = computed(() => enabled.value && mode.value === 'enforce')
|
||||
|
||||
return { mode, siteKey, enabled, enforced }
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import type {
|
||||
CreateTopupResponse,
|
||||
CurrentTeamCreditStop,
|
||||
Plan,
|
||||
PreviewSubscribeOptions,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeOptions,
|
||||
SubscribeResponse,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier,
|
||||
@@ -21,9 +23,9 @@ export interface SubscriptionInfo {
|
||||
tier: SubscriptionTier | null
|
||||
duration: SubscriptionDuration | null
|
||||
planSlug: string | null
|
||||
/** ISO 8601 */
|
||||
/** ISO 8601; format at the display site. */
|
||||
renewalDate: string | null
|
||||
/** ISO 8601 */
|
||||
/** ISO 8601; format at the display site. */
|
||||
endDate: string | null
|
||||
isCancelled: boolean
|
||||
hasFunds: boolean
|
||||
@@ -43,16 +45,27 @@ export interface BillingActions {
|
||||
fetchBalance: () => Promise<void>
|
||||
subscribe: (
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
options?: SubscribeOptions
|
||||
) => Promise<SubscribeResponse | void>
|
||||
previewSubscribe: (
|
||||
planSlug: string
|
||||
planSlug: string,
|
||||
options?: PreviewSubscribeOptions
|
||||
) => Promise<PreviewSubscribeResponse | null>
|
||||
manageSubscription: () => Promise<void>
|
||||
cancelSubscription: () => Promise<void>
|
||||
/**
|
||||
* Reactivates a cancelled-but-still-active subscription. Legacy has no
|
||||
* dedicated endpoint, so the legacy adapter re-runs the checkout flow.
|
||||
* The workspace adapter refreshes status and balance internally on success.
|
||||
*/
|
||||
resubscribe: () => Promise<void>
|
||||
/** `amountCents` must be a whole-dollar multiple of 100. */
|
||||
/**
|
||||
* Purchases additional credits. Standardized on **whole-dollar cents**
|
||||
* (multiples of 100); the legacy adapter divides by 100 for the
|
||||
* dollar-based /customers/credit endpoint.
|
||||
* Pass-through by design: the caller owns the completed/pending follow-up
|
||||
* (balance refresh or billing-op polling), so this does not refresh.
|
||||
*/
|
||||
topup: (amountCents: number) => Promise<CreateTopupResponse | void>
|
||||
fetchPlans: () => Promise<void>
|
||||
/**
|
||||
@@ -80,8 +93,11 @@ export interface BillingState {
|
||||
isLoading: Ref<boolean>
|
||||
error: Ref<string | null>
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
/** Reflects the active workspace's tier, not the user's personal tier. */
|
||||
isFreeTier: ComputedRef<boolean>
|
||||
/** Coarse funding state (`billing_status`); legacy reports null. */
|
||||
billingStatus: ComputedRef<BillingStatus | null>
|
||||
/** Lifecycle state; legacy synthesizes it from active/cancelled flags. */
|
||||
subscriptionStatus: ComputedRef<BillingSubscriptionStatus | null>
|
||||
tier: ComputedRef<SubscriptionTier | null>
|
||||
renewalDate: ComputedRef<string | null>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import type {
|
||||
BillingStatusResponse,
|
||||
Plan
|
||||
@@ -20,12 +22,14 @@ const {
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits,
|
||||
mockUpdateActiveWorkspace,
|
||||
mockBillingStatus
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn(),
|
||||
mockUpdateActiveWorkspace: vi.fn(),
|
||||
mockBillingStatus: {
|
||||
value: {
|
||||
is_active: true,
|
||||
@@ -44,15 +48,25 @@ vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
}
|
||||
vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const teamWorkspacesEnabledRef = ref(mockTeamWorkspacesEnabled.value)
|
||||
Object.defineProperty(mockTeamWorkspacesEnabled, 'value', {
|
||||
get: () => teamWorkspacesEnabledRef.value,
|
||||
set: (value: boolean) => {
|
||||
teamWorkspacesEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
}))
|
||||
return {
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
@@ -64,7 +78,7 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
? { id: 'personal-123', type: 'personal' }
|
||||
: { id: 'team-456', type: 'team' }
|
||||
},
|
||||
updateActiveWorkspace: vi.fn()
|
||||
updateActiveWorkspace: mockUpdateActiveWorkspace
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -142,11 +156,28 @@ describe('useBillingContext', () => {
|
||||
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
|
||||
})
|
||||
|
||||
it('returns legacy type for personal workspace', () => {
|
||||
it('selects legacy type when team workspaces are disabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('selects workspace type for personal when team workspaces are enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('workspace')
|
||||
})
|
||||
|
||||
it('selects workspace type for team when team workspaces are enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('workspace')
|
||||
})
|
||||
|
||||
it('provides subscription info from legacy billing', () => {
|
||||
const { subscription } = useBillingContext()
|
||||
|
||||
@@ -206,6 +237,14 @@ describe('useBillingContext', () => {
|
||||
expect(mockPurchaseCredits).toHaveBeenCalledWith(5)
|
||||
})
|
||||
|
||||
it('rejects topup amounts that are not positive whole-dollar cents', async () => {
|
||||
const { topup } = useBillingContext()
|
||||
await expect(topup(550)).rejects.toThrow()
|
||||
await expect(topup(0)).rejects.toThrow()
|
||||
await expect(topup(-100)).rejects.toThrow()
|
||||
await expect(topup(99.5)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('provides isActiveSubscription convenience computed', () => {
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
expect(isActiveSubscription.value).toBe(true)
|
||||
@@ -221,6 +260,42 @@ describe('useBillingContext', () => {
|
||||
expect(() => showSubscriptionDialog()).not.toThrow()
|
||||
})
|
||||
|
||||
it('reinitializes workspace billing when the type flips on after legacy init', async () => {
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type, initialize } = useBillingContext()
|
||||
await initialize()
|
||||
await nextTick()
|
||||
|
||||
expect(type.value).toBe('legacy')
|
||||
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
|
||||
// Authenticated remote config resolves the flag on for the same workspace
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(workspaceApi.getBillingStatus).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscription mirror to workspace store', () => {
|
||||
it('mirrors subscription for personal workspaces when team workspaces are enabled', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
await initialize()
|
||||
await nextTick()
|
||||
|
||||
expect(mockUpdateActiveWorkspace).toHaveBeenCalledWith({
|
||||
isSubscribed: true,
|
||||
subscriptionPlan: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMaxSeats', () => {
|
||||
it('returns 1 for personal workspaces regardless of tier', () => {
|
||||
const { getMaxSeats } = useBillingContext()
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
getTierFeatures
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
PreviewSubscribeOptions,
|
||||
SubscribeOptions
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import type {
|
||||
@@ -27,11 +31,11 @@ import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspa
|
||||
const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
|
||||
/**
|
||||
* Unified billing context that automatically switches between legacy (user-scoped)
|
||||
* and workspace billing based on the active workspace type.
|
||||
* Unified billing context that selects the billing implementation by build/flag.
|
||||
*
|
||||
* - Personal workspaces use legacy billing via /customers/* endpoints
|
||||
* - Team workspaces use workspace billing via /billing/* endpoints
|
||||
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
|
||||
* - Team workspaces enabled: workspace billing via /api/billing/* for both
|
||||
* personal (single-seat workspace) and team workspaces
|
||||
*
|
||||
* The context automatically initializes when the workspace changes and provides
|
||||
* a unified interface for subscription status, balance, and billing actions.
|
||||
@@ -92,16 +96,14 @@ function useBillingContextInternal(): BillingContext {
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Determines which billing type to use:
|
||||
* - If team workspaces feature is disabled: always use legacy (/customers)
|
||||
* - If team workspaces feature is enabled:
|
||||
* - Personal workspace: use legacy (/customers)
|
||||
* - Team workspace: use workspace (/billing)
|
||||
* Determines which billing type to use, keyed only on the build/flag:
|
||||
* - Team workspaces feature disabled (OSS/Desktop): legacy (/customers)
|
||||
* - Team workspaces feature enabled: workspace (/api/billing), for both
|
||||
* personal (single-seat workspace) and team workspaces
|
||||
*/
|
||||
const type = computed<BillingType>(() => {
|
||||
if (!flags.teamWorkspacesEnabled) return 'legacy'
|
||||
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
|
||||
})
|
||||
const type = computed<BillingType>(() =>
|
||||
flags.teamWorkspacesEnabled ? 'workspace' : 'legacy'
|
||||
)
|
||||
|
||||
const activeContext = computed(() =>
|
||||
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
|
||||
@@ -173,7 +175,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
watch(
|
||||
subscription,
|
||||
(sub) => {
|
||||
if (!sub || store.isInPersonalWorkspace) return
|
||||
if (!sub) return
|
||||
|
||||
store.updateActiveWorkspace({
|
||||
isSubscribed: sub.isActive && !sub.isCancelled,
|
||||
@@ -183,26 +185,28 @@ function useBillingContextInternal(): BillingContext {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Initialize billing when workspace changes
|
||||
function resetBillingState() {
|
||||
isInitialized.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// type can flip after setup when the team-workspaces flag resolves from
|
||||
// authenticated config, swapping the active backend; a fresh init is needed.
|
||||
// The watch fires only when id or type actually changes, so any fire with a
|
||||
// workspace selected warrants a reinit.
|
||||
watch(
|
||||
() => store.activeWorkspace?.id,
|
||||
async (newWorkspaceId, oldWorkspaceId) => {
|
||||
[() => store.activeWorkspace?.id, () => type.value],
|
||||
async ([newWorkspaceId]) => {
|
||||
if (!newWorkspaceId) {
|
||||
// No workspace selected - reset state
|
||||
isInitialized.value = false
|
||||
error.value = null
|
||||
resetBillingState()
|
||||
return
|
||||
}
|
||||
|
||||
if (newWorkspaceId !== oldWorkspaceId) {
|
||||
// Workspace changed - reinitialize
|
||||
isInitialized.value = false
|
||||
try {
|
||||
await initialize()
|
||||
} catch (err) {
|
||||
// Error is already captured in error ref
|
||||
console.error('Failed to initialize billing context:', err)
|
||||
}
|
||||
isInitialized.value = false
|
||||
try {
|
||||
await initialize()
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize billing context:', err)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -233,16 +237,15 @@ function useBillingContextInternal(): BillingContext {
|
||||
return activeContext.value.fetchBalance()
|
||||
}
|
||||
|
||||
async function subscribe(
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
) {
|
||||
return activeContext.value.subscribe(planSlug, returnUrl, cancelUrl)
|
||||
async function subscribe(planSlug: string, options?: SubscribeOptions) {
|
||||
return activeContext.value.subscribe(planSlug, options)
|
||||
}
|
||||
|
||||
async function previewSubscribe(planSlug: string) {
|
||||
return activeContext.value.previewSubscribe(planSlug)
|
||||
async function previewSubscribe(
|
||||
planSlug: string,
|
||||
options?: PreviewSubscribeOptions
|
||||
) {
|
||||
return activeContext.value.previewSubscribe(planSlug, options)
|
||||
}
|
||||
|
||||
async function manageSubscription() {
|
||||
@@ -258,6 +261,15 @@ function useBillingContextInternal(): BillingContext {
|
||||
}
|
||||
|
||||
async function topup(amountCents: number) {
|
||||
if (
|
||||
!Number.isInteger(amountCents) ||
|
||||
amountCents <= 0 ||
|
||||
amountCents % 100 !== 0
|
||||
) {
|
||||
throw new Error(
|
||||
'Top-up amount must be a positive whole-dollar cent value'
|
||||
)
|
||||
}
|
||||
return activeContext.value.topup(amountCents)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
|
||||
import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
PreviewSubscribeOptions,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeOptions,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -147,15 +149,15 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
|
||||
async function subscribe(
|
||||
_planSlug: string,
|
||||
_returnUrl?: string,
|
||||
_cancelUrl?: string
|
||||
_options?: SubscribeOptions
|
||||
): Promise<SubscribeResponse | void> {
|
||||
// Legacy billing uses Stripe checkout flow via useSubscription
|
||||
await legacySubscribe()
|
||||
}
|
||||
|
||||
async function previewSubscribe(
|
||||
_planSlug: string
|
||||
_planSlug: string,
|
||||
_options?: PreviewSubscribeOptions
|
||||
): Promise<PreviewSubscribeResponse | null> {
|
||||
// Legacy billing doesn't support preview - returns null
|
||||
return null
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
|
||||
async function navigateToGraph(targetGraph: LGraph) {
|
||||
const canvasStore = useCanvasStore()
|
||||
@@ -49,23 +48,7 @@ export function useFocusNode() {
|
||||
canvasStore.canvas?.animateToBounds(graphNode.boundingRect)
|
||||
}
|
||||
|
||||
async function enterSubgraph(
|
||||
nodeId: string,
|
||||
executionIdMap?: Map<string, LGraphNode>
|
||||
) {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
const graphNode = executionIdMap
|
||||
? executionIdMap.get(nodeId)
|
||||
: getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
if (!graphNode?.graph) return
|
||||
|
||||
await navigateToGraph(graphNode.graph as LGraph)
|
||||
useLitegraphService().fitView()
|
||||
}
|
||||
|
||||
return {
|
||||
focusNode,
|
||||
enterSubgraph
|
||||
focusNode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,39 @@ describe('useFeatureFlags', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('signupTurnstileMode', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('falls back to the server feature flag with default off', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.SIGNUP_TURNSTILE) return 'enforce'
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.signupTurnstileMode).toBe('enforce')
|
||||
expect(api.getServerFeature).toHaveBeenCalledWith(
|
||||
ServerFeatureFlag.SIGNUP_TURNSTILE,
|
||||
'off'
|
||||
)
|
||||
})
|
||||
|
||||
it('lets a dev override beat the server value', () => {
|
||||
vi.mocked(api.getServerFeature).mockReturnValue('off')
|
||||
localStorage.setItem(
|
||||
`ff:${ServerFeatureFlag.SIGNUP_TURNSTILE}`,
|
||||
'"shadow"'
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.signupTurnstileMode).toBe('shadow')
|
||||
})
|
||||
})
|
||||
|
||||
describe('unifiedCloudAuthEnabled', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
|
||||
@@ -29,7 +29,8 @@ export enum ServerFeatureFlag {
|
||||
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth'
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,6 +174,13 @@ export function useFeatureFlags() {
|
||||
remoteConfig.value.unified_cloud_auth,
|
||||
false
|
||||
)
|
||||
},
|
||||
get signupTurnstileMode() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.SIGNUP_TURNSTILE,
|
||||
remoteConfig.value.signup_turnstile,
|
||||
'off'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
96
src/composables/useUrlActionLoaders.test.ts
Normal file
96
src/composables/useUrlActionLoaders.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useUrlActionLoaders } from './useUrlActionLoaders'
|
||||
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: true }))
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
const mockFlags = vi.hoisted(() => ({ value: { teamWorkspacesEnabled: true } }))
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags.value })
|
||||
}))
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadInvite: vi.fn().mockResolvedValue(undefined),
|
||||
loadCreateWorkspace: vi.fn().mockResolvedValue(undefined),
|
||||
loadPricingTable: vi.fn().mockResolvedValue(undefined),
|
||||
useInvite: vi.fn(),
|
||||
useCreateWorkspace: vi.fn(),
|
||||
usePricingTable: vi.fn()
|
||||
}))
|
||||
mocks.useInvite.mockImplementation(() => ({
|
||||
loadInviteFromUrl: mocks.loadInvite
|
||||
}))
|
||||
mocks.useCreateWorkspace.mockImplementation(() => ({
|
||||
loadCreateWorkspaceFromUrl: mocks.loadCreateWorkspace
|
||||
}))
|
||||
mocks.usePricingTable.mockImplementation(() => ({
|
||||
loadPricingTableFromUrl: mocks.loadPricingTable
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useInviteUrlLoader', () => ({
|
||||
useInviteUrlLoader: mocks.useInvite
|
||||
}))
|
||||
vi.mock('@/platform/workspace/composables/useCreateWorkspaceUrlLoader', () => ({
|
||||
useCreateWorkspaceUrlLoader: mocks.useCreateWorkspace
|
||||
}))
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/usePricingTableUrlLoader',
|
||||
() => ({ usePricingTableUrlLoader: mocks.usePricingTable })
|
||||
)
|
||||
|
||||
describe('useUrlActionLoaders', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockFlags.value = { teamWorkspacesEnabled: true }
|
||||
})
|
||||
|
||||
it('does not instantiate or run any loader off cloud', async () => {
|
||||
mockIsCloud.value = false
|
||||
|
||||
const { runUrlActionLoaders } = useUrlActionLoaders()
|
||||
await runUrlActionLoaders()
|
||||
|
||||
expect(mocks.useInvite).not.toHaveBeenCalled()
|
||||
expect(mocks.useCreateWorkspace).not.toHaveBeenCalled()
|
||||
expect(mocks.usePricingTable).not.toHaveBeenCalled()
|
||||
expect(mocks.loadInvite).not.toHaveBeenCalled()
|
||||
expect(mocks.loadCreateWorkspace).not.toHaveBeenCalled()
|
||||
expect(mocks.loadPricingTable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs all loaders on cloud when team workspaces are enabled', async () => {
|
||||
const { runUrlActionLoaders } = useUrlActionLoaders()
|
||||
await runUrlActionLoaders()
|
||||
|
||||
expect(mocks.loadInvite).toHaveBeenCalledOnce()
|
||||
expect(mocks.loadCreateWorkspace).toHaveBeenCalledOnce()
|
||||
expect(mocks.loadPricingTable).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('runs the pricing loader but skips the flag-gated loaders when team workspaces are disabled', async () => {
|
||||
mockFlags.value = { teamWorkspacesEnabled: false }
|
||||
|
||||
const { runUrlActionLoaders } = useUrlActionLoaders()
|
||||
await runUrlActionLoaders()
|
||||
|
||||
expect(mocks.loadInvite).not.toHaveBeenCalled()
|
||||
expect(mocks.loadCreateWorkspace).not.toHaveBeenCalled()
|
||||
expect(mocks.loadPricingTable).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('isolates a pricing-loader failure so it does not abort the boot chain', async () => {
|
||||
mocks.loadPricingTable.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
const { runUrlActionLoaders } = useUrlActionLoaders()
|
||||
await expect(runUrlActionLoaders()).resolves.toBeUndefined()
|
||||
|
||||
expect(mocks.loadInvite).toHaveBeenCalledOnce()
|
||||
expect(mocks.loadCreateWorkspace).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
55
src/composables/useUrlActionLoaders.ts
Normal file
55
src/composables/useUrlActionLoaders.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { usePricingTableUrlLoader } from '@/platform/cloud/subscription/composables/usePricingTableUrlLoader'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
|
||||
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
|
||||
|
||||
/**
|
||||
* Aggregates the query-param "deep link" loaders the cloud app checks on mount
|
||||
* (`?invite`, `?create_workspace`, `?pricing`). The loaders are instantiated in
|
||||
* setup so their `useRoute`/`useRouter` resolve; call `runUrlActionLoaders()`
|
||||
* from `onMounted` once the app is ready.
|
||||
*/
|
||||
export function useUrlActionLoaders() {
|
||||
const { flags } = useFeatureFlags()
|
||||
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
|
||||
const createWorkspaceUrlLoader = isCloud
|
||||
? useCreateWorkspaceUrlLoader()
|
||||
: null
|
||||
const pricingTableUrlLoader = isCloud ? usePricingTableUrlLoader() : null
|
||||
|
||||
async function runUrlActionLoaders() {
|
||||
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN).
|
||||
// WorkspaceAuthGate ensures flag state is resolved before the app mounts.
|
||||
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {
|
||||
await inviteUrlLoader.loadInviteFromUrl()
|
||||
}
|
||||
|
||||
// Open create workspace dialog from URL if present (e.g., ?create_workspace=1).
|
||||
if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) {
|
||||
try {
|
||||
await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[UrlActionLoaders] Failed to load create workspace from URL:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Open the pricing table from URL if present (e.g., ?pricing=1 / ?pricing=team).
|
||||
// Not gated on the team-workspaces flag: it also drives personal/legacy users.
|
||||
if (pricingTableUrlLoader) {
|
||||
try {
|
||||
await pricingTableUrlLoader.loadPricingTableFromUrl()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[UrlActionLoaders] Failed to load pricing table from URL:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { runUrlActionLoaders }
|
||||
}
|
||||
72
src/config/turnstile.test.ts
Normal file
72
src/config/turnstile.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
|
||||
const TURNSTILE_TEST_SITE_KEY = '1x00000000000000000000AA'
|
||||
// __USE_PROD_CONFIG__ is false under vitest (see vitest.setup.ts), so the
|
||||
// build-time fallback resolves to the staging sitekey.
|
||||
const STAGING_TURNSTILE_SITE_KEY = '0x4AAAAAADnYY4_Q0qxHZ5a7'
|
||||
|
||||
// Mutable containers go through vi.hoisted so the hoisted vi.mock factories can
|
||||
// reference them without a temporal-dead-zone crash (which surfaces under
|
||||
// coverage instrumentation, not a plain run).
|
||||
const { mockRemoteConfig } = vi.hoisted(() => ({
|
||||
mockRemoteConfig: { value: {} as Record<string, unknown> }
|
||||
}))
|
||||
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
|
||||
remoteConfig: mockRemoteConfig,
|
||||
configValueOrDefault: (
|
||||
cfg: Record<string, unknown>,
|
||||
key: string,
|
||||
fallback: unknown
|
||||
) => cfg[key] || fallback
|
||||
}))
|
||||
|
||||
describe('getTurnstileSiteKey', () => {
|
||||
beforeEach(() => {
|
||||
mockRemoteConfig.value = {}
|
||||
vi.stubGlobal('__DISTRIBUTION__', 'localhost')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('OSS / non-cloud build', () => {
|
||||
it('falls back to the always-pass test key in dev', () => {
|
||||
vi.stubEnv('DEV', true)
|
||||
|
||||
expect(getTurnstileSiteKey()).toBe(TURNSTILE_TEST_SITE_KEY)
|
||||
})
|
||||
|
||||
it('returns empty string outside dev so the widget never renders', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
|
||||
expect(getTurnstileSiteKey()).toBe('')
|
||||
})
|
||||
|
||||
it('ignores remote config (the widget is cloud-only)', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
mockRemoteConfig.value = { turnstile_sitekey: '0xshould-not-be-used' }
|
||||
|
||||
expect(getTurnstileSiteKey()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud build', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('__DISTRIBUTION__', 'cloud')
|
||||
})
|
||||
|
||||
it('returns the sitekey delivered via remote config', () => {
|
||||
mockRemoteConfig.value = { turnstile_sitekey: '0x4AAAAAreal' }
|
||||
|
||||
expect(getTurnstileSiteKey()).toBe('0x4AAAAAreal')
|
||||
})
|
||||
|
||||
it('falls back to the build-time per-env sitekey during a remote-config gap', () => {
|
||||
expect(getTurnstileSiteKey()).toBe(STAGING_TURNSTILE_SITE_KEY)
|
||||
})
|
||||
})
|
||||
})
|
||||
43
src/config/turnstile.ts
Normal file
43
src/config/turnstile.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
configValueOrDefault,
|
||||
remoteConfig
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
/**
|
||||
* Cloudflare Turnstile always-pass test sitekey, used only in local dev so the
|
||||
* signup flow can be exercised without a real key.
|
||||
* @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/
|
||||
*/
|
||||
const TURNSTILE_TEST_SITE_KEY = '1x00000000000000000000AA'
|
||||
|
||||
// Public per-environment sitekeys, baked at build time so a cloud build renders
|
||||
// the widget even before (or without) remote config; remote config still
|
||||
// overrides them, so keys rotate live without a rebuild.
|
||||
const PROD_TURNSTILE_SITE_KEY = '0x4AAAAAADnYZPVOpFCL_zeo'
|
||||
const STAGING_TURNSTILE_SITE_KEY = '0x4AAAAAADnYY4_Q0qxHZ5a7'
|
||||
|
||||
/**
|
||||
* Returns the Cloudflare Turnstile sitekey for the current environment.
|
||||
* - OSS / localhost never renders the cloud widget (server-side loopback
|
||||
* exemption covers local signup); in dev it falls back to the always-pass test
|
||||
* key so the flow is exercisable locally, otherwise ''.
|
||||
* - Cloud builds prefer the per-env sitekey delivered via remote config
|
||||
* (`turnstile_sitekey`) and fall back to the build-time constant, so the widget
|
||||
* still renders during a remote-config gap rather than silently disappearing.
|
||||
*/
|
||||
export function getTurnstileSiteKey(): string {
|
||||
// Gate on the __DISTRIBUTION__ build define rather than the cross-module
|
||||
// `isCloud` const so dead-code elimination strips the real per-env sitekeys
|
||||
// from OSS/desktop bundles — same idiom as initTelemetry.ts, enforced by the
|
||||
// dist scan in ci-dist-telemetry-scan.yaml.
|
||||
const isCloudBuild = __DISTRIBUTION__ === 'cloud'
|
||||
if (!isCloudBuild) {
|
||||
return import.meta.env.DEV ? TURNSTILE_TEST_SITE_KEY : ''
|
||||
}
|
||||
|
||||
return configValueOrDefault(
|
||||
remoteConfig.value,
|
||||
'turnstile_sitekey',
|
||||
__USE_PROD_CONFIG__ ? PROD_TURNSTILE_SITE_KEY : STAGING_TURNSTILE_SITE_KEY
|
||||
)
|
||||
}
|
||||
@@ -3682,7 +3682,6 @@
|
||||
"keepSubscription": "الاحتفاظ بالاشتراك",
|
||||
"title": "إلغاء الاشتراك"
|
||||
},
|
||||
"cancelSubscription": "إلغاء الاشتراك",
|
||||
"cancelSuccess": "تم إلغاء الاشتراك بنجاح",
|
||||
"canceled": "تم الإلغاء",
|
||||
"canceledCard": {
|
||||
@@ -3847,7 +3846,7 @@
|
||||
"workspaceNotSubscribed": "هذه مساحة العمل ليست مشتركة",
|
||||
"yearly": "سنوي",
|
||||
"yearlyCreditsLabel": "إجمالي الرصيد السنوي",
|
||||
"yearlyDiscount": "خصم 20%",
|
||||
"saveYearly": "وفّر 20%",
|
||||
"yourPlanIncludes": "خطتك تشمل:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -2347,6 +2347,11 @@
|
||||
"personalDataConsentLabel": "I agree to the processing of my personal data.",
|
||||
"emailNotEligibleForFreeTier": "Email sign-up is not eligible for Free Tier."
|
||||
},
|
||||
"turnstile": {
|
||||
"failed": "Verification failed. Please try again.",
|
||||
"expired": "Verification expired. Please complete the challenge again.",
|
||||
"submitBlockedHint": "Complete the verification challenge above to enable sign up."
|
||||
},
|
||||
"signOut": {
|
||||
"signOut": "Log Out",
|
||||
"success": "Signed out successfully",
|
||||
@@ -2507,13 +2512,18 @@
|
||||
"creditSliderSave": "Save {percent}% ({amount})",
|
||||
"renewsDate": "Renews {date}",
|
||||
"expiresDate": "Expires {date}",
|
||||
"renewsOnDate": "Renews on {date}",
|
||||
"endsOnDate": "Ends on {date}",
|
||||
"manageSubscription": "Manage subscription",
|
||||
"managePayment": "Manage Payment",
|
||||
"cancelSubscription": "Cancel Subscription",
|
||||
"manageBilling": "Manage billing",
|
||||
"changePlan": "Change plan",
|
||||
"cancelPlan": "Cancel plan",
|
||||
"canceled": "Canceled",
|
||||
"resubscribe": "Resubscribe",
|
||||
"reactivatePlan": "Reactivate plan",
|
||||
"resubscribeTo": "Resubscribe to {plan}",
|
||||
"resubscribeSuccess": "Subscription reactivated successfully",
|
||||
"subscribeFailed": "Failed to subscribe",
|
||||
"canceledCard": {
|
||||
"title": "Your subscription has been canceled",
|
||||
"description": "You won't be charged again. Your features remain active until {date}."
|
||||
@@ -2553,16 +2563,48 @@
|
||||
"creditsRemainingThisMonth": "Included (Refills {date})",
|
||||
"creditsRemainingThisYear": "Included (Refills {date})",
|
||||
"creditsYouveAdded": "Additional",
|
||||
"remaining": "remaining",
|
||||
"refillsDate": "Refills {date}",
|
||||
"refillsNextCycle": "Refills next cycle",
|
||||
"creditsUsed": "{used} used",
|
||||
"creditsLeftOfTotal": "{remaining} left of {total}",
|
||||
"monthlyUsageProgress": "{used} of {total} monthly credits used",
|
||||
"additionalCreditsInfo": "About additional credits",
|
||||
"additionalCredits": "Additional credits",
|
||||
"additionalCreditsInUse": "In use",
|
||||
"usedAfterMonthly": "Used after monthly runs out",
|
||||
"monthlyCreditsUsedUpTitle": "Monthly credits are used up. Refills {date}",
|
||||
"monthlyCreditsUsedUpTitleNoDate": "Monthly credits are used up",
|
||||
"monthlyCreditsUsedUpDescription": "You're now spending additional credits.",
|
||||
"outOfCreditsTitle": "You're out of credits. Credits refill {date}",
|
||||
"outOfCreditsTitleNoDate": "You're out of credits",
|
||||
"outOfCreditsDescription": "Add more credits to continue generating.",
|
||||
"additionalCreditsTooltip": "Credits you add on top of your plan. Used after monthly credits run out. Each expires one year after purchase.",
|
||||
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
|
||||
"viewMoreDetailsPlans": "View more details about plans & pricing",
|
||||
"nextBillingCycle": "next billing cycle",
|
||||
"yourPlanIncludes": "Your plan includes:",
|
||||
"whatsIncluded": "What's included:",
|
||||
"planLoadError": "We couldn't load your plan details.",
|
||||
"planLoadErrorRetry": "Try again",
|
||||
"teamPlanName": "Team",
|
||||
"teamPlanIncludes": "Your plan includes everything in {plan}, plus:",
|
||||
"teamPerks": {
|
||||
"inviteMembers": "Invite members",
|
||||
"concurrentRuns": "Members can run workflows concurrently",
|
||||
"sharedCreditPool": "Shared credit pool for all members",
|
||||
"rolePermissions": "Role-based permissions"
|
||||
},
|
||||
"freePerks": {
|
||||
"maxRuntime": "{duration} max runtime"
|
||||
},
|
||||
"viewMoreDetails": "View more details",
|
||||
"learnMore": "Learn more",
|
||||
"billedMonthly": "Billed monthly",
|
||||
"billedYearly": "{total} Billed yearly",
|
||||
"monthly": "Monthly",
|
||||
"yearly": "Yearly",
|
||||
"saveYearly": "Save 20%",
|
||||
"tierNameYearly": "{name} Yearly",
|
||||
"messageSupport": "Message support",
|
||||
"invoiceHistory": "Invoice history",
|
||||
@@ -2573,7 +2615,6 @@
|
||||
"benefit2": "Up to 1 hour runtime per job on Pro",
|
||||
"benefit3": "Bring your own models (Creator & Pro)"
|
||||
},
|
||||
"yearlyDiscount": "20% DISCOUNT",
|
||||
"tiers": {
|
||||
"free": {
|
||||
"name": "Free"
|
||||
@@ -2614,6 +2655,7 @@
|
||||
"subscribeToRunFull": "Subscribe to Run",
|
||||
"subscribeForMore": "Upgrade",
|
||||
"upgradeToAddCredits": "Upgrade to add credits",
|
||||
"subscribe": "Subscribe",
|
||||
"subscribeNow": "Subscribe Now",
|
||||
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
|
||||
"workspaceNotSubscribed": "This workspace is not on a subscription",
|
||||
@@ -2650,9 +2692,9 @@
|
||||
"perkProjectAssets": "Project & asset management",
|
||||
"cta": "Subscribe to Team Yearly",
|
||||
"ctaMonthly": "Subscribe to Team Monthly",
|
||||
"unavailable": "This team plan is not available right now.",
|
||||
"changePlan": "Change plan",
|
||||
"currentPlan": "Current plan",
|
||||
"checkoutComingSoon": "Team plan checkout is coming soon."
|
||||
"currentPlan": "Current plan"
|
||||
},
|
||||
"enterprise": {
|
||||
"name": "Enterprise",
|
||||
@@ -2682,6 +2724,7 @@
|
||||
"upgradeCta": "View plans"
|
||||
},
|
||||
"partnerNodesCredits": "Partner nodes pricing",
|
||||
"partnerNodesPricingTable": "Partner Nodes pricing table",
|
||||
"plansAndPricing": "Plans & pricing",
|
||||
"managePlan": "Manage plan",
|
||||
"upgrade": "UPGRADE",
|
||||
@@ -2692,8 +2735,6 @@
|
||||
"monthlyCreditsPerMemberLabel": "Monthly credits / member",
|
||||
"maxMembersLabel": "Max. members",
|
||||
"yearlyCreditsLabel": "Total yearly credits",
|
||||
"membersLabel": "Up to {count} members",
|
||||
"nextMonthInvoice": "Next month invoice",
|
||||
"memberCount": "{count} member | {count} members",
|
||||
"maxDurationLabel": "Max run duration",
|
||||
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
||||
@@ -2708,7 +2749,7 @@
|
||||
"upgradeTo": "Upgrade to {plan}",
|
||||
"changeTo": "Change to {plan}",
|
||||
"maxDuration": {
|
||||
"free": "30 min",
|
||||
"free": "10 min",
|
||||
"standard": "30 min",
|
||||
"creator": "30 min",
|
||||
"pro": "1 hr",
|
||||
@@ -2721,10 +2762,11 @@
|
||||
"preview": {
|
||||
"confirmPayment": "Confirm your payment",
|
||||
"confirmPlanChange": "Confirm your plan change",
|
||||
"startingToday": "Starting today",
|
||||
"startingToday": "Starts today",
|
||||
"starting": "Starting {date}",
|
||||
"ends": "Ends {date}",
|
||||
"eachMonthCreditsRefill": "Each month credits refill to",
|
||||
"eachYearCreditsRefill": "Each year credits refill to",
|
||||
"everyMonthStarting": "Every month starting {date}",
|
||||
"creditsRefillTo": "Credits refill to",
|
||||
"youllBeCharged": "You'll be charged",
|
||||
@@ -2735,6 +2777,24 @@
|
||||
"proratedCharge": "Prorated charge for {plan}",
|
||||
"totalDueToday": "Total due today",
|
||||
"nextPaymentDue": "Next payment due {date}. Cancel anytime.",
|
||||
"confirmUpgradeTitle": "Confirm your upgrade",
|
||||
"confirmUpgradeCta": "Confirm upgrade",
|
||||
"confirmChange": "Confirm change",
|
||||
"confirmChangeTitle": "Review your scheduled change",
|
||||
"paymentPopupBlocked": "Couldn't open the payment page — please allow popups and try again.",
|
||||
"switchesToday": "Switches today",
|
||||
"startsOn": "Starts {date}",
|
||||
"yearlySubscription": "Yearly subscription",
|
||||
"newMonthlySubscription": "New monthly subscription",
|
||||
"creditFromCurrent": "Credit from current {plan}",
|
||||
"currentMonthly": "monthly plan",
|
||||
"commitment": "commitment",
|
||||
"creditsYoullGetToday": "Credits you'll get today",
|
||||
"refillReplacesNote": "Replaces your monthly refill. Existing balance is kept.",
|
||||
"afterThat": "After that",
|
||||
"creditsRefillMonthlyTo": "Credits refill monthly to",
|
||||
"billedEachMonth": "{amount} billed each month. Cancel anytime.",
|
||||
"stayOnUntil": "You'll stay on {plan} until {date}.",
|
||||
"termsAgreement": "By continuing, you agree to Comfy Org's {terms} and {privacy}.",
|
||||
"terms": "Terms",
|
||||
"privacyPolicy": "Privacy Policy",
|
||||
@@ -2746,8 +2806,12 @@
|
||||
},
|
||||
"success": {
|
||||
"allSet": "You're all set",
|
||||
"inviteEmailsPlaceholder": "Enter emails separated by commas",
|
||||
"inviteSubtext": "You can also invite people later from Settings",
|
||||
"inviteTitle": "Invite your team",
|
||||
"planUpdated": "Your plan has been successfully updated.",
|
||||
"receiptEmailed": "A receipt has been emailed to you."
|
||||
"receiptEmailed": "A receipt has been emailed to you.",
|
||||
"sendInvites": "Send invites"
|
||||
}
|
||||
},
|
||||
"userSettings": {
|
||||
@@ -2763,7 +2827,7 @@
|
||||
"workspacePanel": {
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of 50 members",
|
||||
"inviteLimitReached": "You've reached the maximum of {count} members",
|
||||
"tabs": {
|
||||
"dashboard": "Dashboard",
|
||||
"planCredits": "Plan & Credits",
|
||||
@@ -2773,7 +2837,8 @@
|
||||
"placeholder": "Dashboard workspace settings"
|
||||
},
|
||||
"members": {
|
||||
"membersCount": "{count}/{maxSeats} Members",
|
||||
"header": "Members",
|
||||
"membersCount": "{count} of {maxSeats} members",
|
||||
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
|
||||
"tabs": {
|
||||
"active": "Active",
|
||||
@@ -2782,26 +2847,30 @@
|
||||
"columns": {
|
||||
"inviteDate": "Invite date",
|
||||
"expiryDate": "Expiry date",
|
||||
"joinDate": "Join date"
|
||||
"role": "Role"
|
||||
},
|
||||
"actions": {
|
||||
"copyLink": "Copy invite link",
|
||||
"revokeInvite": "Revoke invite",
|
||||
"resendInvite": "Resend invite",
|
||||
"cancelInvite": "Cancel invite",
|
||||
"changeRole": "Change role",
|
||||
"removeMember": "Remove member"
|
||||
},
|
||||
"upsellBannerSubscribe": "Subscribe to the Creator plan or above to invite team members to this workspace.",
|
||||
"upsellBannerUpgrade": "Upgrade to the Creator plan or above to invite additional team members.",
|
||||
"viewPlans": "View plans",
|
||||
"upsellBanner": "To add teammates, upgrade your plan.",
|
||||
"upsellBannerReactivate": "To add more teammates, reactivate your plan.",
|
||||
"upgradeToTeam": "Upgrade to Team",
|
||||
"reactivateTeam": "Reactivate Team",
|
||||
"needMoreMembers": "Need more members?",
|
||||
"contactUs": "Contact us",
|
||||
"noInvites": "No pending invites",
|
||||
"noMembers": "No members",
|
||||
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
|
||||
"createNewWorkspace": "create a new one."
|
||||
"searchPlaceholder": "Search..."
|
||||
},
|
||||
"menu": {
|
||||
"editWorkspace": "Edit workspace details",
|
||||
"leaveWorkspace": "Leave Workspace",
|
||||
"deleteWorkspace": "Delete Workspace",
|
||||
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first"
|
||||
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
|
||||
"creatorCannotLeave": "The workspace creator can't leave the workspace they created"
|
||||
},
|
||||
"editWorkspaceDialog": {
|
||||
"title": "Edit workspace details",
|
||||
@@ -2825,32 +2894,38 @@
|
||||
"success": "Member removed",
|
||||
"error": "Failed to remove member"
|
||||
},
|
||||
"changeRoleDialog": {
|
||||
"promoteTitle": "Make {name} an owner?",
|
||||
"promoteIntro": "They'll be able to:",
|
||||
"promotePermissionCredits": "Add additional credits",
|
||||
"promotePermissionManage": "Manage members, payment methods, and workspace settings",
|
||||
"promotePermissionRoles": "Promote and demote other owners (except the workspace creator).",
|
||||
"promoteConfirm": "Make owner",
|
||||
"demoteTitle": "Demote {name} to member?",
|
||||
"demoteMessage": "They'll lose admin access.",
|
||||
"demoteConfirm": "Demote to member",
|
||||
"success": "Role updated",
|
||||
"error": "Failed to update role"
|
||||
},
|
||||
"revokeInviteDialog": {
|
||||
"title": "Uninvite this person?",
|
||||
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
|
||||
"revoke": "Uninvite"
|
||||
},
|
||||
"inviteUpsellDialog": {
|
||||
"titleNotSubscribed": "A subscription is required to invite members",
|
||||
"titleNotSubscribed": "A Team plan is required to invite members",
|
||||
"titleSingleSeat": "Your current plan supports a single seat",
|
||||
"messageNotSubscribed": "To add team members to this workspace, you need a Creator plan or above. The Standard plan supports only a single seat (the owner).",
|
||||
"messageSingleSeat": "The Standard plan includes one seat for the workspace owner. To invite additional members, upgrade to the Creator plan or above to unlock multiple seats.",
|
||||
"viewPlans": "View Plans",
|
||||
"upgradeToCreator": "Upgrade to Creator"
|
||||
"messageNotSubscribed": "To add teammates to this workspace, upgrade to a Team plan.",
|
||||
"messageSingleSeat": "Your current plan includes one seat for the workspace owner. To add teammates, upgrade to a Team plan.",
|
||||
"upgradeToTeam": "Upgrade to Team"
|
||||
},
|
||||
"inviteMemberDialog": {
|
||||
"title": "Invite a person to this workspace",
|
||||
"message": "Create a shareable invite link to send to someone",
|
||||
"placeholder": "Enter the person's email",
|
||||
"createLink": "Create link",
|
||||
"linkStep": {
|
||||
"title": "Send this link to the person",
|
||||
"message": "Make sure their account uses this email.",
|
||||
"copyLink": "Copy Link",
|
||||
"done": "Done"
|
||||
},
|
||||
"linkCopied": "Copied",
|
||||
"linkCopyFailed": "Failed to copy link"
|
||||
"title": "Invite members to this workspace",
|
||||
"placeholder": "Enter emails separated by commas",
|
||||
"invalidEmailCount": "{count} invalid email address | {count} invalid email addresses",
|
||||
"failedCount": "Couldn't send {count} invite. Try again. | Couldn't send {count} invites. Try again.",
|
||||
"invitedMessage": "An invite was sent to {emails} | Invites were sent to {emails}",
|
||||
"seatLimitReached": "You can invite up to {count} teammate. | You can invite up to {count} teammates."
|
||||
},
|
||||
"createWorkspaceDialog": {
|
||||
"title": "Create a new workspace",
|
||||
@@ -2877,6 +2952,8 @@
|
||||
"title": "Left workspace",
|
||||
"message": "You have left the workspace."
|
||||
},
|
||||
"inviteResent": "Invite resent",
|
||||
"inviteResendFailed": "Failed to resend invite",
|
||||
"failedToUpdateWorkspace": "Failed to update workspace",
|
||||
"failedToCreateWorkspace": "Failed to create workspace",
|
||||
"failedToDeleteWorkspace": "Failed to delete workspace",
|
||||
@@ -3787,7 +3864,6 @@
|
||||
"errorLog": "Error log",
|
||||
"findOnGithubTooltip": "Search GitHub issues for related problems",
|
||||
"getHelpTooltip": "Report this error and we'll help you resolve it",
|
||||
"enterSubgraph": "Enter subgraph",
|
||||
"seeError": "See Error",
|
||||
"errorHelp": "For more help, {github} or {support}",
|
||||
"errorHelpGithub": "submit a GitHub issue",
|
||||
|
||||
@@ -3682,7 +3682,6 @@
|
||||
"keepSubscription": "Mantener suscripción",
|
||||
"title": "Cancelar suscripción"
|
||||
},
|
||||
"cancelSubscription": "Cancelar suscripción",
|
||||
"cancelSuccess": "Suscripción cancelada correctamente",
|
||||
"canceled": "Cancelada",
|
||||
"canceledCard": {
|
||||
@@ -3847,7 +3846,7 @@
|
||||
"workspaceNotSubscribed": "Este espacio de trabajo no tiene una suscripción",
|
||||
"yearly": "Anual",
|
||||
"yearlyCreditsLabel": "Total de créditos anuales",
|
||||
"yearlyDiscount": "20% DESCUENTO",
|
||||
"saveYearly": "Ahorra 20%",
|
||||
"yourPlanIncludes": "Tu plan incluye:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3694,7 +3694,6 @@
|
||||
"keepSubscription": "حفظ اشتراک",
|
||||
"title": "لغو اشتراک"
|
||||
},
|
||||
"cancelSubscription": "لغو اشتراک",
|
||||
"cancelSuccess": "اشتراک با موفقیت لغو شد",
|
||||
"canceled": "لغو شد",
|
||||
"canceledCard": {
|
||||
@@ -3859,7 +3858,7 @@
|
||||
"workspaceNotSubscribed": "این محیط کاری اشتراک فعال ندارد",
|
||||
"yearly": "سالانه",
|
||||
"yearlyCreditsLabel": "کل اعتبار سالانه",
|
||||
"yearlyDiscount": "٪۲۰ تخفیف",
|
||||
"saveYearly": "٪۲۰ صرفهجویی",
|
||||
"yourPlanIncludes": "طرح شما شامل:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3682,7 +3682,6 @@
|
||||
"keepSubscription": "Conserver l'abonnement",
|
||||
"title": "Annuler l'abonnement"
|
||||
},
|
||||
"cancelSubscription": "Annuler l’abonnement",
|
||||
"cancelSuccess": "Abonnement annulé avec succès",
|
||||
"canceled": "Annulé",
|
||||
"canceledCard": {
|
||||
@@ -3847,7 +3846,7 @@
|
||||
"workspaceNotSubscribed": "Cet espace de travail n’a pas d’abonnement",
|
||||
"yearly": "Annuel",
|
||||
"yearlyCreditsLabel": "Crédits annuels totaux",
|
||||
"yearlyDiscount": "20% DE RÉDUCTION",
|
||||
"saveYearly": "Économisez 20 %",
|
||||
"yourPlanIncludes": "Votre forfait comprend :"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3682,7 +3682,6 @@
|
||||
"keepSubscription": "サブスクリプションを維持する",
|
||||
"title": "サブスクリプションのキャンセル"
|
||||
},
|
||||
"cancelSubscription": "サブスクリプションをキャンセル",
|
||||
"cancelSuccess": "サブスクリプションが正常にキャンセルされました",
|
||||
"canceled": "キャンセル済み",
|
||||
"canceledCard": {
|
||||
@@ -3847,7 +3846,7 @@
|
||||
"workspaceNotSubscribed": "このワークスペースはサブスクリプションに加入していません",
|
||||
"yearly": "年額",
|
||||
"yearlyCreditsLabel": "年間合計クレジット",
|
||||
"yearlyDiscount": "20%割引",
|
||||
"saveYearly": "20%お得",
|
||||
"yourPlanIncludes": "ご利用プランに含まれるもの:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3682,7 +3682,6 @@
|
||||
"keepSubscription": "구독 유지",
|
||||
"title": "구독 취소"
|
||||
},
|
||||
"cancelSubscription": "구독 취소",
|
||||
"cancelSuccess": "구독이 성공적으로 취소되었습니다",
|
||||
"canceled": "취소됨",
|
||||
"canceledCard": {
|
||||
@@ -3847,7 +3846,7 @@
|
||||
"workspaceNotSubscribed": "이 워크스페이스는 구독 중이 아닙니다",
|
||||
"yearly": "연간",
|
||||
"yearlyCreditsLabel": "연간 총 크레딧",
|
||||
"yearlyDiscount": "20% 할인",
|
||||
"saveYearly": "20% 절감",
|
||||
"yourPlanIncludes": "귀하의 플랜 포함 사항:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3694,7 +3694,6 @@
|
||||
"keepSubscription": "Manter assinatura",
|
||||
"title": "Cancelar assinatura"
|
||||
},
|
||||
"cancelSubscription": "Cancelar assinatura",
|
||||
"cancelSuccess": "Assinatura cancelada com sucesso",
|
||||
"canceled": "Cancelado",
|
||||
"canceledCard": {
|
||||
@@ -3859,7 +3858,7 @@
|
||||
"workspaceNotSubscribed": "Este espaço de trabalho não possui uma assinatura",
|
||||
"yearly": "Anual",
|
||||
"yearlyCreditsLabel": "Total de créditos anuais",
|
||||
"yearlyDiscount": "20% DE DESCONTO",
|
||||
"saveYearly": "Economize 20%",
|
||||
"yourPlanIncludes": "Seu plano inclui:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3682,7 +3682,6 @@
|
||||
"keepSubscription": "Сохранить подписку",
|
||||
"title": "Отмена подписки"
|
||||
},
|
||||
"cancelSubscription": "Отменить подписку",
|
||||
"cancelSuccess": "Подписка успешно отменена",
|
||||
"canceled": "Отменено",
|
||||
"canceledCard": {
|
||||
@@ -3847,7 +3846,7 @@
|
||||
"workspaceNotSubscribed": "Это рабочее пространство не имеет подписки",
|
||||
"yearly": "Ежегодно",
|
||||
"yearlyCreditsLabel": "Годовые кредиты",
|
||||
"yearlyDiscount": "СКИДКА 20%",
|
||||
"saveYearly": "Экономия 20%",
|
||||
"yourPlanIncludes": "Ваш план включает:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3682,7 +3682,6 @@
|
||||
"keepSubscription": "Aboneliği sürdür",
|
||||
"title": "Aboneliği iptal et"
|
||||
},
|
||||
"cancelSubscription": "Aboneliği İptal Et",
|
||||
"cancelSuccess": "Abonelik başarıyla iptal edildi",
|
||||
"canceled": "İptal edildi",
|
||||
"canceledCard": {
|
||||
@@ -3847,7 +3846,7 @@
|
||||
"workspaceNotSubscribed": "Bu çalışma alanı bir aboneliğe sahip değil",
|
||||
"yearly": "Yıllık",
|
||||
"yearlyCreditsLabel": "Toplam yıllık krediler",
|
||||
"yearlyDiscount": "%20 İNDİRİM",
|
||||
"saveYearly": "%20 tasarruf",
|
||||
"yourPlanIncludes": "Planınız şunları içerir:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3682,7 +3682,6 @@
|
||||
"keepSubscription": "保留訂閱",
|
||||
"title": "取消訂閱"
|
||||
},
|
||||
"cancelSubscription": "取消訂閱",
|
||||
"cancelSuccess": "訂閱已成功取消",
|
||||
"canceled": "已取消",
|
||||
"canceledCard": {
|
||||
@@ -3847,7 +3846,7 @@
|
||||
"workspaceNotSubscribed": "此工作區尚未訂閱",
|
||||
"yearly": "每年",
|
||||
"yearlyCreditsLabel": "年度總點數",
|
||||
"yearlyDiscount": "八折優惠",
|
||||
"saveYearly": "節省 20%",
|
||||
"yourPlanIncludes": "您的方案包含:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3694,7 +3694,6 @@
|
||||
"keepSubscription": "保留订阅",
|
||||
"title": "取消订阅"
|
||||
},
|
||||
"cancelSubscription": "取消订阅",
|
||||
"cancelSuccess": "订阅取消成功",
|
||||
"canceled": "已取消",
|
||||
"canceledCard": {
|
||||
@@ -3859,7 +3858,7 @@
|
||||
"workspaceNotSubscribed": "此工作区未订阅",
|
||||
"yearly": "年度",
|
||||
"yearlyCreditsLabel": "总共年度积分",
|
||||
"yearlyDiscount": "20% 减免",
|
||||
"saveYearly": "立省 20%",
|
||||
"yourPlanIncludes": "您的计划包括:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -116,7 +116,9 @@ app
|
||||
modal: 1800,
|
||||
overlay: 1800,
|
||||
menu: 1800,
|
||||
tooltip: 1800
|
||||
// Tooltips sit above modals/menus so a menu-item tooltip isn't hidden
|
||||
// behind a body-portaled dropdown that lifts itself to modal + 1.
|
||||
tooltip: 2000
|
||||
},
|
||||
theme: {
|
||||
preset: ComfyUIPreset,
|
||||
|
||||
73
src/platform/agent/common/agentProtocol.test.ts
Normal file
73
src/platform/agent/common/agentProtocol.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseAgentEvent } from './agentProtocol'
|
||||
|
||||
const base = { threadId: 't1', messageId: 'm1' }
|
||||
|
||||
describe('parseAgentEvent', () => {
|
||||
it('decodes a draft_patch', () => {
|
||||
const event = parseAgentEvent({
|
||||
...base,
|
||||
type: 'draft_patch',
|
||||
workflowId: 'wf1',
|
||||
content: { nodes: [] },
|
||||
version: 8,
|
||||
baseVersion: 7
|
||||
})
|
||||
expect(event).toMatchObject({
|
||||
type: 'draft_patch',
|
||||
workflowId: 'wf1',
|
||||
version: 8
|
||||
})
|
||||
})
|
||||
|
||||
it('decodes a tool call and drops absent optional fields', () => {
|
||||
const event = parseAgentEvent({
|
||||
...base,
|
||||
type: 'agent_tool_call',
|
||||
toolCallId: 'tc1',
|
||||
toolName: 'workflow set-slot',
|
||||
status: 'success'
|
||||
})
|
||||
expect(event).toEqual({
|
||||
...base,
|
||||
type: 'agent_tool_call',
|
||||
toolCallId: 'tc1',
|
||||
toolName: 'workflow set-slot',
|
||||
status: 'success'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an unknown event type', () => {
|
||||
expect(parseAgentEvent({ ...base, type: 'nope' })).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a payload missing the base identifiers', () => {
|
||||
expect(
|
||||
parseAgentEvent({ type: 'agent_message_delta', delta: 'hi' })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a draft_patch with a malformed version', () => {
|
||||
const event = parseAgentEvent({
|
||||
...base,
|
||||
type: 'draft_patch',
|
||||
workflowId: 'wf1',
|
||||
content: {},
|
||||
version: '8',
|
||||
baseVersion: 7
|
||||
})
|
||||
expect(event).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a tool call with an invalid status', () => {
|
||||
const event = parseAgentEvent({
|
||||
...base,
|
||||
type: 'agent_tool_call',
|
||||
toolCallId: 'tc1',
|
||||
toolName: 'run',
|
||||
status: 'pending'
|
||||
})
|
||||
expect(event).toBeNull()
|
||||
})
|
||||
})
|
||||
177
src/platform/agent/common/agentProtocol.ts
Normal file
177
src/platform/agent/common/agentProtocol.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* In-App Agent protocol (prototype — ADR-0011).
|
||||
*
|
||||
* The cross-repo contract between the frontend (TS) and the server-side agent
|
||||
* (Go, `Comfy-Org/cloud`). Inbound requests go to ingest `/api/agent/*`;
|
||||
* outbound events arrive over the existing Redis-PubSub -> WebSocket bridge on
|
||||
* `channel:ws:{workspaceId}:u:{userId}`.
|
||||
*
|
||||
* This is the single TS definition of that contract; it should be kept in sync
|
||||
* with the Go side (open question: where the schema lives + how drift is caught).
|
||||
*/
|
||||
|
||||
export type WorkflowId = string
|
||||
export type ThreadId = string
|
||||
export type MessageId = string
|
||||
export type NodeId = string
|
||||
|
||||
/** Full save-format graph. Opaque here; validated by the workflow schema layer. */
|
||||
export type WorkflowGraph = Record<string, unknown>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inbound: browser -> agent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Where an agent write lands (ADR-0001). */
|
||||
export type AgentWriteTarget = 'active' | 'new_tab'
|
||||
|
||||
export interface AgentTurnRequest {
|
||||
content: string
|
||||
/** Selected node ids — the awareness input (ADR-0003). */
|
||||
selection?: NodeId[]
|
||||
/** Uploaded asset ids referenced by the turn. */
|
||||
attachments?: string[]
|
||||
target?: AgentWriteTarget
|
||||
/** The tab's current draft version when `target === 'active'` (ADR-0005). */
|
||||
baseVersion?: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outbound: agent -> browser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AgentToolCallStatus = 'running' | 'success' | 'error'
|
||||
|
||||
interface AgentEventBase {
|
||||
threadId: ThreadId
|
||||
messageId: MessageId
|
||||
}
|
||||
|
||||
export interface AgentMessageDeltaEvent extends AgentEventBase {
|
||||
type: 'agent_message_delta'
|
||||
delta: string
|
||||
}
|
||||
|
||||
export interface AgentToolCallEvent extends AgentEventBase {
|
||||
type: 'agent_tool_call'
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
status: AgentToolCallStatus
|
||||
durationMs?: number
|
||||
errorCode?: string
|
||||
}
|
||||
|
||||
/** A graph write: full-document replace guarded by `version` (ADR-0004). */
|
||||
export interface DraftPatchEvent extends AgentEventBase {
|
||||
type: 'draft_patch'
|
||||
workflowId: WorkflowId
|
||||
content: WorkflowGraph
|
||||
/** The new authoritative version after the agent's CAS commit. */
|
||||
version: number
|
||||
/** The version the agent started from; compared against the tab (ADR-0005). */
|
||||
baseVersion: number
|
||||
}
|
||||
|
||||
export interface AgentMessageDoneEvent extends AgentEventBase {
|
||||
type: 'agent_message_done'
|
||||
tokenUsage?: { input: number; output: number }
|
||||
}
|
||||
|
||||
export type AgentEvent =
|
||||
| AgentMessageDeltaEvent
|
||||
| AgentToolCallEvent
|
||||
| DraftPatchEvent
|
||||
| AgentMessageDoneEvent
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
type WithBase = Record<string, unknown> & {
|
||||
threadId: string
|
||||
messageId: string
|
||||
}
|
||||
|
||||
function hasBase(value: Record<string, unknown>): value is WithBase {
|
||||
return (
|
||||
typeof value.threadId === 'string' && typeof value.messageId === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isToolCallStatus(value: unknown): value is AgentToolCallStatus {
|
||||
return value === 'running' || value === 'success' || value === 'error'
|
||||
}
|
||||
|
||||
function parseToolCall(raw: WithBase): AgentToolCallEvent | null {
|
||||
if (
|
||||
typeof raw.toolCallId !== 'string' ||
|
||||
typeof raw.toolName !== 'string' ||
|
||||
!isToolCallStatus(raw.status)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
type: 'agent_tool_call',
|
||||
threadId: raw.threadId,
|
||||
messageId: raw.messageId,
|
||||
toolCallId: raw.toolCallId,
|
||||
toolName: raw.toolName,
|
||||
status: raw.status,
|
||||
...(typeof raw.durationMs === 'number'
|
||||
? { durationMs: raw.durationMs }
|
||||
: {}),
|
||||
...(typeof raw.errorCode === 'string' ? { errorCode: raw.errorCode } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function parseDraftPatch(raw: WithBase): DraftPatchEvent | null {
|
||||
if (
|
||||
typeof raw.workflowId !== 'string' ||
|
||||
!isRecord(raw.content) ||
|
||||
typeof raw.version !== 'number' ||
|
||||
typeof raw.baseVersion !== 'number'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
type: 'draft_patch',
|
||||
threadId: raw.threadId,
|
||||
messageId: raw.messageId,
|
||||
workflowId: raw.workflowId,
|
||||
content: raw.content,
|
||||
version: raw.version,
|
||||
baseVersion: raw.baseVersion
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an untrusted WebSocket payload into a typed `AgentEvent`, or `null` if
|
||||
* it is not a well-formed agent event. Keeps the transport boundary type-safe.
|
||||
*/
|
||||
export function parseAgentEvent(raw: unknown): AgentEvent | null {
|
||||
if (!isRecord(raw) || !hasBase(raw)) return null
|
||||
|
||||
switch (raw.type) {
|
||||
case 'agent_message_delta':
|
||||
return typeof raw.delta === 'string'
|
||||
? {
|
||||
type: 'agent_message_delta',
|
||||
threadId: raw.threadId,
|
||||
messageId: raw.messageId,
|
||||
delta: raw.delta
|
||||
}
|
||||
: null
|
||||
case 'agent_tool_call':
|
||||
return parseToolCall(raw)
|
||||
case 'draft_patch':
|
||||
return parseDraftPatch(raw)
|
||||
case 'agent_message_done':
|
||||
return {
|
||||
type: 'agent_message_done',
|
||||
threadId: raw.threadId,
|
||||
messageId: raw.messageId
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
43
src/platform/agent/common/draftReconciler.test.ts
Normal file
43
src/platform/agent/common/draftReconciler.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { DraftPatchEvent } from './agentProtocol'
|
||||
import { reconcileDraftPatch } from './draftReconciler'
|
||||
|
||||
function patch(overrides: Partial<DraftPatchEvent> = {}): DraftPatchEvent {
|
||||
return {
|
||||
type: 'draft_patch',
|
||||
threadId: 't1',
|
||||
messageId: 'm1',
|
||||
workflowId: 'wf1',
|
||||
content: { nodes: [] },
|
||||
version: 8,
|
||||
baseVersion: 7,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('reconcileDraftPatch', () => {
|
||||
it('applies when the patch is based on the current tab version', () => {
|
||||
const result = reconcileDraftPatch(patch({ baseVersion: 7, version: 8 }), 7)
|
||||
expect(result).toEqual({ kind: 'apply', version: 8 })
|
||||
})
|
||||
|
||||
it('flags a conflict when a concurrent edit advanced the tab', () => {
|
||||
// Agent started from v7, but the user pushed the tab to v8 mid-turn.
|
||||
const result = reconcileDraftPatch(patch({ baseVersion: 7, version: 9 }), 8)
|
||||
expect(result).toEqual({ kind: 'conflict' })
|
||||
})
|
||||
|
||||
it('ignores a stale patch the tab already supersedes', () => {
|
||||
const result = reconcileDraftPatch(patch({ baseVersion: 7, version: 8 }), 8)
|
||||
expect(result).toEqual({ kind: 'stale' })
|
||||
})
|
||||
|
||||
it('ignores an older duplicate patch', () => {
|
||||
const result = reconcileDraftPatch(
|
||||
patch({ baseVersion: 5, version: 6 }),
|
||||
10
|
||||
)
|
||||
expect(result).toEqual({ kind: 'stale' })
|
||||
})
|
||||
})
|
||||
31
src/platform/agent/common/draftReconciler.ts
Normal file
31
src/platform/agent/common/draftReconciler.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Draft reconciliation (prototype — ADR-0004 / ADR-0005).
|
||||
*
|
||||
* Pure decision logic for an incoming `draft_patch`, given the version the tab
|
||||
* currently holds. This is the load-bearing correctness piece: it decides
|
||||
* whether a full-document replace applies cleanly, must surface the merge
|
||||
* dialog, or is a stale/duplicate that should be ignored.
|
||||
*/
|
||||
import type { DraftPatchEvent } from './agentProtocol'
|
||||
|
||||
export type ReconcileResult =
|
||||
/** Patch is based on the tab's current version — apply and adopt `version`. */
|
||||
| { kind: 'apply'; version: number }
|
||||
/** A concurrent user edit advanced the tab — surface the merge dialog. */
|
||||
| { kind: 'conflict' }
|
||||
/** Patch is superseded by what the tab already has — ignore (idempotency). */
|
||||
| { kind: 'stale' }
|
||||
|
||||
/** User's choice in the merge dialog (ADR-0005). */
|
||||
export type ConflictResolution = 'accept-agent' | 'keep-mine' | 'new-tab'
|
||||
|
||||
export function reconcileDraftPatch(
|
||||
patch: DraftPatchEvent,
|
||||
currentVersion: number
|
||||
): ReconcileResult {
|
||||
if (patch.version <= currentVersion) return { kind: 'stale' }
|
||||
if (patch.baseVersion === currentVersion) {
|
||||
return { kind: 'apply', version: patch.version }
|
||||
}
|
||||
return { kind: 'conflict' }
|
||||
}
|
||||
133
src/platform/agent/common/useAgentDraftSync.test.ts
Normal file
133
src/platform/agent/common/useAgentDraftSync.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DraftPatchEvent } from './agentProtocol'
|
||||
import type { AgentDraftPorts } from './useAgentDraftSync'
|
||||
import { useAgentDraftSync } from './useAgentDraftSync'
|
||||
|
||||
function makePorts(): AgentDraftPorts {
|
||||
return {
|
||||
applyToTab: vi.fn(),
|
||||
openInNewTab: vi.fn(),
|
||||
discardAgentResult: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function patch(overrides: Partial<DraftPatchEvent> = {}): DraftPatchEvent {
|
||||
return {
|
||||
type: 'draft_patch',
|
||||
threadId: 't1',
|
||||
messageId: 'm1',
|
||||
workflowId: 'wf1',
|
||||
content: { nodes: ['ksampler'] },
|
||||
version: 8,
|
||||
baseVersion: 7,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useAgentDraftSync', () => {
|
||||
it('applies a patch to the active tab and adopts the new version', () => {
|
||||
const ports = makePorts()
|
||||
const sync = useAgentDraftSync(ports)
|
||||
sync.registerWorkflow('wf1', 7)
|
||||
|
||||
const outcome = sync.handlePatch(patch({ baseVersion: 7, version: 8 }))
|
||||
|
||||
expect(outcome).toBe('applied')
|
||||
expect(ports.applyToTab).toHaveBeenCalledWith(
|
||||
'wf1',
|
||||
{ nodes: ['ksampler'] },
|
||||
8
|
||||
)
|
||||
expect(sync.baseVersions.value.get('wf1')).toBe(8)
|
||||
expect(sync.pendingConflict.value).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces a conflict when the user edited the graph mid-turn', () => {
|
||||
const ports = makePorts()
|
||||
const sync = useAgentDraftSync(ports)
|
||||
sync.registerWorkflow('wf1', 7)
|
||||
sync.setVersion('wf1', 8) // local autosave advanced the tab
|
||||
|
||||
const outcome = sync.handlePatch(patch({ baseVersion: 7, version: 9 }))
|
||||
|
||||
expect(outcome).toBe('conflict')
|
||||
expect(ports.applyToTab).not.toHaveBeenCalled()
|
||||
expect(sync.pendingConflict.value).toMatchObject({
|
||||
workflowId: 'wf1',
|
||||
version: 9
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a stale patch', () => {
|
||||
const ports = makePorts()
|
||||
const sync = useAgentDraftSync(ports)
|
||||
sync.registerWorkflow('wf1', 8)
|
||||
|
||||
const outcome = sync.handlePatch(patch({ baseVersion: 7, version: 8 }))
|
||||
|
||||
expect(outcome).toBe('ignored')
|
||||
expect(ports.applyToTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens a new tab when the workflow has no open tab', () => {
|
||||
const ports = makePorts()
|
||||
const sync = useAgentDraftSync(ports)
|
||||
|
||||
const outcome = sync.handlePatch(patch({ workflowId: 'wf-new' }))
|
||||
|
||||
expect(outcome).toBe('opened-new-tab')
|
||||
expect(ports.openInNewTab).toHaveBeenCalledWith(
|
||||
'wf-new',
|
||||
{ nodes: ['ksampler'] },
|
||||
8
|
||||
)
|
||||
})
|
||||
|
||||
describe('resolveConflict', () => {
|
||||
function setupConflict() {
|
||||
const ports = makePorts()
|
||||
const sync = useAgentDraftSync(ports)
|
||||
sync.registerWorkflow('wf1', 7)
|
||||
sync.setVersion('wf1', 8)
|
||||
sync.handlePatch(patch({ baseVersion: 7, version: 9 }))
|
||||
return { ports, sync }
|
||||
}
|
||||
|
||||
it('accept-agent applies the agent version and adopts it', () => {
|
||||
const { ports, sync } = setupConflict()
|
||||
sync.resolveConflict('accept-agent')
|
||||
|
||||
expect(ports.applyToTab).toHaveBeenCalledWith(
|
||||
'wf1',
|
||||
{ nodes: ['ksampler'] },
|
||||
9
|
||||
)
|
||||
expect(sync.baseVersions.value.get('wf1')).toBe(9)
|
||||
expect(sync.pendingConflict.value).toBeNull()
|
||||
})
|
||||
|
||||
it('keep-mine discards the agent result and keeps the tab version', () => {
|
||||
const { ports, sync } = setupConflict()
|
||||
sync.resolveConflict('keep-mine')
|
||||
|
||||
expect(ports.discardAgentResult).toHaveBeenCalledWith('wf1')
|
||||
expect(ports.applyToTab).not.toHaveBeenCalled()
|
||||
expect(sync.baseVersions.value.get('wf1')).toBe(8)
|
||||
expect(sync.pendingConflict.value).toBeNull()
|
||||
})
|
||||
|
||||
it('new-tab opens the agent result without touching the active tab', () => {
|
||||
const { ports, sync } = setupConflict()
|
||||
sync.resolveConflict('new-tab')
|
||||
|
||||
expect(ports.openInNewTab).toHaveBeenCalledWith(
|
||||
'wf1',
|
||||
{ nodes: ['ksampler'] },
|
||||
9
|
||||
)
|
||||
expect(ports.applyToTab).not.toHaveBeenCalled()
|
||||
expect(sync.pendingConflict.value).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
132
src/platform/agent/common/useAgentDraftSync.ts
Normal file
132
src/platform/agent/common/useAgentDraftSync.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Agent draft sync (prototype — ADR-0011).
|
||||
*
|
||||
* Orchestrates the frontend side of agent graph writes: tracks the base
|
||||
* `version` per open workflow (the version lifecycle), reconciles incoming
|
||||
* `draft_patch` events, and drives the three merge-dialog outcomes.
|
||||
*
|
||||
* The canvas-facing effects are injected as `ports` so this is decoupled from
|
||||
* litegraph / the workflow store and is unit-testable. In the real app the
|
||||
* ports map to: `applyToTab` -> a destructive variant of `app.loadGraphData`;
|
||||
* `openInNewTab` -> the existing non-destructive load; `discardAgentResult` ->
|
||||
* a no-op (keep the user's canvas as-is).
|
||||
*/
|
||||
import { readonly, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
DraftPatchEvent,
|
||||
WorkflowGraph,
|
||||
WorkflowId
|
||||
} from './agentProtocol'
|
||||
import type { ConflictResolution } from './draftReconciler'
|
||||
import { reconcileDraftPatch } from './draftReconciler'
|
||||
|
||||
export interface AgentDraftPorts {
|
||||
applyToTab(
|
||||
workflowId: WorkflowId,
|
||||
content: WorkflowGraph,
|
||||
version: number
|
||||
): void
|
||||
openInNewTab(
|
||||
workflowId: WorkflowId,
|
||||
content: WorkflowGraph,
|
||||
version: number
|
||||
): void
|
||||
discardAgentResult(workflowId: WorkflowId): void
|
||||
}
|
||||
|
||||
export interface PendingConflict {
|
||||
workflowId: WorkflowId
|
||||
content: WorkflowGraph
|
||||
version: number
|
||||
baseVersion: number
|
||||
}
|
||||
|
||||
export type PatchOutcome = 'applied' | 'conflict' | 'ignored' | 'opened-new-tab'
|
||||
|
||||
export function useAgentDraftSync(ports: AgentDraftPorts) {
|
||||
const baseVersions = ref(new Map<WorkflowId, number>())
|
||||
const pendingConflict = ref<PendingConflict | null>(null)
|
||||
|
||||
/** Call when a draft tab opens, adopting its known version. */
|
||||
function registerWorkflow(workflowId: WorkflowId, version: number): void {
|
||||
baseVersions.value.set(workflowId, version)
|
||||
}
|
||||
|
||||
function forgetWorkflow(workflowId: WorkflowId): void {
|
||||
baseVersions.value.delete(workflowId)
|
||||
}
|
||||
|
||||
/** Call after a local autosave returns a new server version. */
|
||||
function setVersion(workflowId: WorkflowId, version: number): void {
|
||||
baseVersions.value.set(workflowId, version)
|
||||
}
|
||||
|
||||
function handlePatch(patch: DraftPatchEvent): PatchOutcome {
|
||||
const current = baseVersions.value.get(patch.workflowId)
|
||||
|
||||
// Unknown workflow = no open tab for it (a new-tab write, or a tab the user
|
||||
// closed mid-edit — see ADR-0011 open question). Route to a new tab.
|
||||
if (current === undefined) {
|
||||
ports.openInNewTab(patch.workflowId, patch.content, patch.version)
|
||||
baseVersions.value.set(patch.workflowId, patch.version)
|
||||
return 'opened-new-tab'
|
||||
}
|
||||
|
||||
const result = reconcileDraftPatch(patch, current)
|
||||
switch (result.kind) {
|
||||
case 'apply':
|
||||
ports.applyToTab(patch.workflowId, patch.content, result.version)
|
||||
baseVersions.value.set(patch.workflowId, result.version)
|
||||
return 'applied'
|
||||
case 'conflict':
|
||||
pendingConflict.value = {
|
||||
workflowId: patch.workflowId,
|
||||
content: patch.content,
|
||||
version: patch.version,
|
||||
baseVersion: patch.baseVersion
|
||||
}
|
||||
return 'conflict'
|
||||
case 'stale':
|
||||
return 'ignored'
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConflict(decision: ConflictResolution): void {
|
||||
const conflict = pendingConflict.value
|
||||
if (!conflict) return
|
||||
|
||||
switch (decision) {
|
||||
case 'accept-agent':
|
||||
ports.applyToTab(
|
||||
conflict.workflowId,
|
||||
conflict.content,
|
||||
conflict.version
|
||||
)
|
||||
baseVersions.value.set(conflict.workflowId, conflict.version)
|
||||
break
|
||||
case 'keep-mine':
|
||||
ports.discardAgentResult(conflict.workflowId)
|
||||
break
|
||||
case 'new-tab':
|
||||
ports.openInNewTab(
|
||||
conflict.workflowId,
|
||||
conflict.content,
|
||||
conflict.version
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
pendingConflict.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
baseVersions: readonly(baseVersions),
|
||||
pendingConflict: readonly(pendingConflict),
|
||||
registerWorkflow,
|
||||
forgetWorkflow,
|
||||
setVersion,
|
||||
handlePatch,
|
||||
resolveConflict
|
||||
}
|
||||
}
|
||||
48
src/platform/agent/crdt/agentRoom.test.ts
Normal file
48
src/platform/agent/crdt/agentRoom.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { AgentRoom } from './agentRoom'
|
||||
|
||||
describe('AgentRoom', () => {
|
||||
it('reconciles two peers via state-vector diff without conflict', () => {
|
||||
const user = new AgentRoom('wf-1')
|
||||
const agent = new AgentRoom('wf-1')
|
||||
|
||||
user.nodes.set('a', { title: 'Load Checkpoint' })
|
||||
agent.nodes.set('b', { title: 'KSampler' })
|
||||
|
||||
agent.applyRemoteUpdate(user.diffSince(agent.encodeStateVector()))
|
||||
user.applyRemoteUpdate(agent.diffSince(user.encodeStateVector()))
|
||||
|
||||
expect([...user.nodes.keys()].sort()).toEqual(['a', 'b'])
|
||||
expect([...agent.nodes.keys()].sort()).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('merges concurrent edits to the same map deterministically', () => {
|
||||
const user = new AgentRoom('wf-1')
|
||||
const agent = new AgentRoom('wf-1')
|
||||
agent.applyRemoteUpdate(user.encodeState())
|
||||
|
||||
user.nodes.set('shared', { x: 1 })
|
||||
agent.nodes.set('shared', { x: 2 })
|
||||
|
||||
user.applyRemoteUpdate(agent.diffSince(user.encodeStateVector()))
|
||||
agent.applyRemoteUpdate(user.diffSince(agent.encodeStateVector()))
|
||||
|
||||
expect(user.nodes.get('shared')).toEqual(agent.nodes.get('shared'))
|
||||
})
|
||||
|
||||
it('reports an agent participant as editing via presence', () => {
|
||||
const room = new AgentRoom('wf-1')
|
||||
expect(room.isAgentEditing()).toBe(false)
|
||||
|
||||
room.setPresence({
|
||||
actor: 'agent-1',
|
||||
kind: 'agent',
|
||||
status: 'editing',
|
||||
focus: ['a'],
|
||||
updatedAt: 0
|
||||
})
|
||||
|
||||
expect(room.isAgentEditing()).toBe(true)
|
||||
})
|
||||
})
|
||||
98
src/platform/agent/crdt/agentRoom.ts
Normal file
98
src/platform/agent/crdt/agentRoom.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Agent CRDT room (prototype — ADR-0011, building on ADR-0003).
|
||||
*
|
||||
* One collaborative workflow = one room: a thin wrapper over a Yjs `Y.Doc` that
|
||||
* the browser and the server-side agent share. The agent is the first
|
||||
* multiplayer peer. Top-level types (`nodes`/`links`/`reroutes`) mirror the live
|
||||
* layout store so a room can drive the real canvas via binary updates
|
||||
* (see `layoutStoreBinding.ts`). Conflict resolution is Yjs's job — no version
|
||||
* CAS at this layer.
|
||||
*/
|
||||
import * as Y from 'yjs'
|
||||
|
||||
export type WorkflowId = string
|
||||
export type ActorId = string
|
||||
export type UpdateOrigin = unknown
|
||||
|
||||
export interface RoomPresence {
|
||||
actor: ActorId
|
||||
kind: 'user' | 'agent'
|
||||
status: 'idle' | 'editing'
|
||||
focus: string[]
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
const PRESENCE_KEY = '__presence'
|
||||
|
||||
export class AgentRoom {
|
||||
readonly workflowId: WorkflowId
|
||||
readonly doc: Y.Doc
|
||||
readonly nodes: Y.Map<unknown>
|
||||
readonly links: Y.Map<unknown>
|
||||
readonly reroutes: Y.Map<unknown>
|
||||
|
||||
private readonly presence: Y.Map<RoomPresence>
|
||||
|
||||
constructor(workflowId: WorkflowId, doc: Y.Doc = new Y.Doc()) {
|
||||
this.workflowId = workflowId
|
||||
this.doc = doc
|
||||
this.nodes = doc.getMap('nodes')
|
||||
this.links = doc.getMap('links')
|
||||
this.reroutes = doc.getMap('reroutes')
|
||||
this.presence = doc.getMap(PRESENCE_KEY)
|
||||
}
|
||||
|
||||
/** State vector describing what this peer already has (sync step 1). */
|
||||
encodeStateVector(): Uint8Array {
|
||||
return Y.encodeStateVector(this.doc)
|
||||
}
|
||||
|
||||
/** Minimal update a peer needs given its state vector (sync step 2). */
|
||||
diffSince(remoteStateVector: Uint8Array): Uint8Array {
|
||||
return Y.encodeStateAsUpdate(this.doc, remoteStateVector)
|
||||
}
|
||||
|
||||
/** Full state as a single update, used to seed a fresh peer. */
|
||||
encodeState(): Uint8Array {
|
||||
return Y.encodeStateAsUpdate(this.doc)
|
||||
}
|
||||
|
||||
applyRemoteUpdate(update: Uint8Array, origin?: UpdateOrigin): void {
|
||||
Y.applyUpdate(this.doc, update, origin)
|
||||
}
|
||||
|
||||
onUpdate(cb: (update: Uint8Array, origin: UpdateOrigin) => void): () => void {
|
||||
const handler = (update: Uint8Array, origin: UpdateOrigin) =>
|
||||
cb(update, origin)
|
||||
this.doc.on('update', handler)
|
||||
return () => this.doc.off('update', handler)
|
||||
}
|
||||
|
||||
setPresence(p: RoomPresence): void {
|
||||
this.presence.set(p.actor, p)
|
||||
}
|
||||
|
||||
clearPresence(actor: ActorId): void {
|
||||
this.presence.delete(actor)
|
||||
}
|
||||
|
||||
getPresence(): RoomPresence[] {
|
||||
return [...this.presence.values()]
|
||||
}
|
||||
|
||||
isAgentEditing(): boolean {
|
||||
return this.getPresence().some(
|
||||
(p) => p.kind === 'agent' && p.status === 'editing'
|
||||
)
|
||||
}
|
||||
|
||||
onPresenceChange(cb: (presence: RoomPresence[]) => void): () => void {
|
||||
const handler = () => cb(this.getPresence())
|
||||
this.presence.observe(handler)
|
||||
return () => this.presence.unobserve(handler)
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.doc.destroy()
|
||||
}
|
||||
}
|
||||
31
src/platform/agent/crdt/agentRoomManager.test.ts
Normal file
31
src/platform/agent/crdt/agentRoomManager.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { AgentRoomManager } from './agentRoomManager'
|
||||
|
||||
describe('AgentRoomManager', () => {
|
||||
it('keeps one room alive across overlapping tab references', () => {
|
||||
const manager = new AgentRoomManager()
|
||||
const first = manager.join('wf-1')
|
||||
const second = manager.join('wf-1')
|
||||
|
||||
expect(second).toBe(first)
|
||||
|
||||
manager.leave('wf-1')
|
||||
expect(manager.has('wf-1')).toBe(true)
|
||||
|
||||
manager.leave('wf-1')
|
||||
expect(manager.has('wf-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a pinned room alive with zero open tabs and reaps on unpin', () => {
|
||||
const manager = new AgentRoomManager()
|
||||
manager.join('wf-1')
|
||||
manager.pin('wf-1')
|
||||
|
||||
manager.leave('wf-1')
|
||||
expect(manager.has('wf-1')).toBe(true)
|
||||
|
||||
manager.unpin('wf-1')
|
||||
expect(manager.has('wf-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
71
src/platform/agent/crdt/agentRoomManager.ts
Normal file
71
src/platform/agent/crdt/agentRoomManager.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Agent room manager (prototype — ADR-0011).
|
||||
*
|
||||
* Owns room lifecycle. A tab switch is a `join`/`leave`; a room stays alive in
|
||||
* memory while any tab references it, so the agent can keep applying edits to a
|
||||
* backgrounded workflow and the user sees them lazily on return (the scenario
|
||||
* from the design meeting). `pin` keeps a room alive with zero open tabs while
|
||||
* the agent is mid-edit; the room is torn down only when unreferenced and
|
||||
* unpinned.
|
||||
*/
|
||||
import { AgentRoom } from './agentRoom'
|
||||
import type { WorkflowId } from './agentRoom'
|
||||
|
||||
interface RoomEntry {
|
||||
room: AgentRoom
|
||||
refs: number
|
||||
pinned: boolean
|
||||
}
|
||||
|
||||
export class AgentRoomManager {
|
||||
private readonly entries = new Map<WorkflowId, RoomEntry>()
|
||||
|
||||
join(workflowId: WorkflowId): AgentRoom {
|
||||
const existing = this.entries.get(workflowId)
|
||||
if (existing) {
|
||||
existing.refs += 1
|
||||
return existing.room
|
||||
}
|
||||
const room = new AgentRoom(workflowId)
|
||||
this.entries.set(workflowId, { room, refs: 1, pinned: false })
|
||||
return room
|
||||
}
|
||||
|
||||
leave(workflowId: WorkflowId): void {
|
||||
const entry = this.entries.get(workflowId)
|
||||
if (!entry) return
|
||||
entry.refs = Math.max(0, entry.refs - 1)
|
||||
this.reapIfIdle(workflowId, entry)
|
||||
}
|
||||
|
||||
pin(workflowId: WorkflowId): void {
|
||||
const entry = this.entries.get(workflowId)
|
||||
if (entry) entry.pinned = true
|
||||
}
|
||||
|
||||
unpin(workflowId: WorkflowId): void {
|
||||
const entry = this.entries.get(workflowId)
|
||||
if (!entry) return
|
||||
entry.pinned = false
|
||||
this.reapIfIdle(workflowId, entry)
|
||||
}
|
||||
|
||||
get(workflowId: WorkflowId): AgentRoom | undefined {
|
||||
return this.entries.get(workflowId)?.room
|
||||
}
|
||||
|
||||
has(workflowId: WorkflowId): boolean {
|
||||
return this.entries.has(workflowId)
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.entries.size
|
||||
}
|
||||
|
||||
private reapIfIdle(workflowId: WorkflowId, entry: RoomEntry): void {
|
||||
if (entry.refs === 0 && !entry.pinned) {
|
||||
entry.room.destroy()
|
||||
this.entries.delete(workflowId)
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/platform/agent/crdt/roomDocBinding.test.ts
Normal file
33
src/platform/agent/crdt/roomDocBinding.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import { AgentRoom } from './agentRoom'
|
||||
import { bindRoomToDoc } from './roomDocBinding'
|
||||
|
||||
describe('bindRoomToDoc', () => {
|
||||
it('seeds the room from the doc and pushes agent edits back', () => {
|
||||
const doc = new Y.Doc()
|
||||
doc.getMap('nodes').set('existing', { title: 'Existing' })
|
||||
|
||||
const room = new AgentRoom('wf-1')
|
||||
const unbind = bindRoomToDoc(room, doc)
|
||||
|
||||
expect(room.nodes.get('existing')).toEqual({ title: 'Existing' })
|
||||
|
||||
room.nodes.set('agent', { title: 'Agent Node' })
|
||||
expect(doc.getMap('nodes').get('agent')).toEqual({ title: 'Agent Node' })
|
||||
|
||||
unbind()
|
||||
room.nodes.set('after-unbind', {})
|
||||
expect(doc.getMap('nodes').has('after-unbind')).toBe(false)
|
||||
})
|
||||
|
||||
it('reflects doc edits into the room while bound', () => {
|
||||
const doc = new Y.Doc()
|
||||
const room = new AgentRoom('wf-1')
|
||||
bindRoomToDoc(room, doc)
|
||||
|
||||
doc.getMap('nodes').set('from-canvas', { title: 'Canvas' })
|
||||
expect(room.nodes.get('from-canvas')).toEqual({ title: 'Canvas' })
|
||||
})
|
||||
})
|
||||
35
src/platform/agent/crdt/roomDocBinding.ts
Normal file
35
src/platform/agent/crdt/roomDocBinding.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Binds an agent room to a Yjs document (prototype — ADR-0011).
|
||||
*
|
||||
* Generic, layer-safe core of the canvas binding: it works on any `Y.Doc`, so
|
||||
* it stays in the platform layer with no renderer dependency. The renderer-layer
|
||||
* wrapper (`renderer/core/layout/agent/bindRoomToLayoutStore.ts`) supplies the
|
||||
* live layout store's doc. Bidirectional Yjs sync with origin tags prevents echo
|
||||
* loops; Yjs reconciles concurrent edits.
|
||||
*/
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import type { AgentRoom } from './agentRoom'
|
||||
|
||||
const AGENT_ORIGIN = Symbol('agent-room->doc')
|
||||
const DOC_ORIGIN = Symbol('doc->agent-room')
|
||||
|
||||
export function bindRoomToDoc(room: AgentRoom, doc: Y.Doc): () => void {
|
||||
Y.applyUpdate(room.doc, Y.encodeStateAsUpdate(doc), DOC_ORIGIN)
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(room.doc), AGENT_ORIGIN)
|
||||
|
||||
const onRoom = (update: Uint8Array, origin: unknown) => {
|
||||
if (origin !== DOC_ORIGIN) Y.applyUpdate(doc, update, AGENT_ORIGIN)
|
||||
}
|
||||
const onDoc = (update: Uint8Array, origin: unknown) => {
|
||||
if (origin !== AGENT_ORIGIN) Y.applyUpdate(room.doc, update, DOC_ORIGIN)
|
||||
}
|
||||
|
||||
room.doc.on('update', onRoom)
|
||||
doc.on('update', onDoc)
|
||||
|
||||
return () => {
|
||||
room.doc.off('update', onRoom)
|
||||
doc.off('update', onDoc)
|
||||
}
|
||||
}
|
||||
59
src/platform/agent/crdt/roomSync.test.ts
Normal file
59
src/platform/agent/crdt/roomSync.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { AgentRoom } from './agentRoom'
|
||||
import type { RoomSyncMessage, RoomTransport } from './roomSync'
|
||||
import { syncRoom } from './roomSync'
|
||||
|
||||
class LoopbackTransport implements RoomTransport {
|
||||
private listeners = new Set<(m: RoomSyncMessage) => void>()
|
||||
peer: LoopbackTransport | null = null
|
||||
|
||||
send(message: RoomSyncMessage): void {
|
||||
this.peer?.listeners.forEach((cb) => cb(message))
|
||||
}
|
||||
|
||||
onMessage(cb: (m: RoomSyncMessage) => void): () => void {
|
||||
this.listeners.add(cb)
|
||||
return () => this.listeners.delete(cb)
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const a = new LoopbackTransport()
|
||||
const b = new LoopbackTransport()
|
||||
a.peer = b
|
||||
b.peer = a
|
||||
return [a, b] as const
|
||||
}
|
||||
|
||||
describe('syncRoom', () => {
|
||||
it('converges existing state on connect via the sync handshake', () => {
|
||||
const [t1, t2] = connect()
|
||||
const user = new AgentRoom('wf-1')
|
||||
const agent = new AgentRoom('wf-1')
|
||||
user.nodes.set('a', { title: 'Load Checkpoint' })
|
||||
|
||||
syncRoom(user, t1)
|
||||
syncRoom(agent, t2)
|
||||
|
||||
expect(agent.nodes.get('a')).toEqual({ title: 'Load Checkpoint' })
|
||||
})
|
||||
|
||||
it('propagates live edits and ignores other workflows', () => {
|
||||
const [t1, t2] = connect()
|
||||
const user = new AgentRoom('wf-1')
|
||||
const agent = new AgentRoom('wf-1')
|
||||
syncRoom(user, t1)
|
||||
syncRoom(agent, t2)
|
||||
|
||||
agent.nodes.set('b', { title: 'KSampler' })
|
||||
expect(user.nodes.get('b')).toEqual({ title: 'KSampler' })
|
||||
|
||||
t2.send({
|
||||
type: 'update',
|
||||
workflowId: 'other',
|
||||
update: agent.encodeState()
|
||||
})
|
||||
expect(user.nodes.has('b')).toBe(true)
|
||||
})
|
||||
})
|
||||
63
src/platform/agent/crdt/roomSync.ts
Normal file
63
src/platform/agent/crdt/roomSync.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Room sync protocol (prototype — ADR-0011).
|
||||
*
|
||||
* Transport-agnostic two-phase Yjs sync, modelled on `y-protocols/sync`:
|
||||
* step 1: a peer announces its state vector
|
||||
* step 2: the other peer replies with the diff that vector is missing
|
||||
* update: incremental updates are broadcast as they happen
|
||||
*
|
||||
* The transport is injected so this works over the existing Redis→WebSocket
|
||||
* bridge in V0 and a dedicated relay later. Updates applied from the transport
|
||||
* carry the `REMOTE_ORIGIN` tag so they are not echoed back.
|
||||
*/
|
||||
import type { AgentRoom } from './agentRoom'
|
||||
|
||||
export type RoomSyncMessage =
|
||||
| { type: 'sync-step-1'; workflowId: string; stateVector: Uint8Array }
|
||||
| { type: 'sync-step-2'; workflowId: string; update: Uint8Array }
|
||||
| { type: 'update'; workflowId: string; update: Uint8Array }
|
||||
|
||||
export interface RoomTransport {
|
||||
send(message: RoomSyncMessage): void
|
||||
onMessage(cb: (message: RoomSyncMessage) => void): () => void
|
||||
}
|
||||
|
||||
const REMOTE_ORIGIN = Symbol('agent-room-remote')
|
||||
|
||||
export function syncRoom(
|
||||
room: AgentRoom,
|
||||
transport: RoomTransport
|
||||
): () => void {
|
||||
const offUpdate = room.onUpdate((update, origin) => {
|
||||
if (origin === REMOTE_ORIGIN) return
|
||||
transport.send({ type: 'update', workflowId: room.workflowId, update })
|
||||
})
|
||||
|
||||
const offMessage = transport.onMessage((message) => {
|
||||
if (message.workflowId !== room.workflowId) return
|
||||
switch (message.type) {
|
||||
case 'sync-step-1':
|
||||
transport.send({
|
||||
type: 'sync-step-2',
|
||||
workflowId: room.workflowId,
|
||||
update: room.diffSince(message.stateVector)
|
||||
})
|
||||
return
|
||||
case 'sync-step-2':
|
||||
case 'update':
|
||||
room.applyRemoteUpdate(message.update, REMOTE_ORIGIN)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
transport.send({
|
||||
type: 'sync-step-1',
|
||||
workflowId: room.workflowId,
|
||||
stateVector: room.encodeStateVector()
|
||||
})
|
||||
|
||||
return () => {
|
||||
offUpdate()
|
||||
offMessage()
|
||||
}
|
||||
}
|
||||
102
src/platform/agent/session/agentSessionStore.test.ts
Normal file
102
src/platform/agent/session/agentSessionStore.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AgentEvent } from '../common/agentProtocol'
|
||||
import { createSessionState, sessionReducer } from './agentSessionStore'
|
||||
import type { SessionState } from './agentSessionStore'
|
||||
|
||||
function reduce(state: SessionState, events: AgentEvent[]): SessionState {
|
||||
return events.reduce(
|
||||
(acc, event) => sessionReducer(acc, { type: 'agent-event', event }),
|
||||
state
|
||||
)
|
||||
}
|
||||
|
||||
describe('sessionReducer', () => {
|
||||
it('accumulates streaming deltas into one agent message', () => {
|
||||
const start = sessionReducer(createSessionState('t-1'), {
|
||||
type: 'user-send',
|
||||
id: 'u-1',
|
||||
content: 'hello'
|
||||
})
|
||||
|
||||
const next = reduce(start, [
|
||||
{
|
||||
type: 'agent_message_delta',
|
||||
threadId: 't-1',
|
||||
messageId: 'm-1',
|
||||
delta: 'Hi '
|
||||
},
|
||||
{
|
||||
type: 'agent_message_delta',
|
||||
threadId: 't-1',
|
||||
messageId: 'm-1',
|
||||
delta: 'there'
|
||||
}
|
||||
])
|
||||
|
||||
expect(next.messages).toHaveLength(2)
|
||||
expect(next.messages[1]).toMatchObject({
|
||||
role: 'agent',
|
||||
content: 'Hi there',
|
||||
streaming: true
|
||||
})
|
||||
expect(next.status).toBe('streaming')
|
||||
})
|
||||
|
||||
it('does not mutate the previous state (structural sharing)', () => {
|
||||
const before = createSessionState('t-1')
|
||||
const after = sessionReducer(before, {
|
||||
type: 'user-send',
|
||||
id: 'u-1',
|
||||
content: 'hello'
|
||||
})
|
||||
|
||||
expect(before.messages).toHaveLength(0)
|
||||
expect(after.messages).toHaveLength(1)
|
||||
expect(after).not.toBe(before)
|
||||
})
|
||||
|
||||
it('tracks tool-call lifecycle and surfaces errors', () => {
|
||||
const next = reduce(createSessionState('t-1'), [
|
||||
{
|
||||
type: 'agent_tool_call',
|
||||
threadId: 't-1',
|
||||
messageId: 'm-1',
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'load_graph',
|
||||
status: 'running'
|
||||
},
|
||||
{
|
||||
type: 'agent_tool_call',
|
||||
threadId: 't-1',
|
||||
messageId: 'm-1',
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'load_graph',
|
||||
status: 'error',
|
||||
errorCode: 'BAD_GRAPH'
|
||||
}
|
||||
])
|
||||
|
||||
expect(next.messages[0].toolCalls).toHaveLength(1)
|
||||
expect(next.messages[0].toolCalls[0]).toMatchObject({
|
||||
status: 'error',
|
||||
errorCode: 'BAD_GRAPH'
|
||||
})
|
||||
expect(next.status).toBe('error')
|
||||
})
|
||||
|
||||
it('ends streaming on done', () => {
|
||||
const next = reduce(createSessionState('t-1'), [
|
||||
{
|
||||
type: 'agent_message_delta',
|
||||
threadId: 't-1',
|
||||
messageId: 'm-1',
|
||||
delta: 'x'
|
||||
},
|
||||
{ type: 'agent_message_done', threadId: 't-1', messageId: 'm-1' }
|
||||
])
|
||||
|
||||
expect(next.messages[0].streaming).toBe(false)
|
||||
expect(next.status).toBe('idle')
|
||||
})
|
||||
})
|
||||
110
src/platform/agent/session/agentSessionStore.ts
Normal file
110
src/platform/agent/session/agentSessionStore.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Agent chat session state (prototype — ADR-0011).
|
||||
*
|
||||
* Immer-backed reducer for the *local, single-client* chat surface: streaming
|
||||
* message deltas, tool-call lifecycle, run status. This is deliberately not a
|
||||
* CRDT — chat is owned by one browser tab, so structural-sharing immutable
|
||||
* updates (Immer) are the right tool. Graph state lives in the Yjs room layer
|
||||
* (`../crdt`); the two never mix.
|
||||
*/
|
||||
import { produce } from 'immer'
|
||||
|
||||
import type { AgentEvent, MessageId, ThreadId } from '../common/agentProtocol'
|
||||
|
||||
interface ToolCallView {
|
||||
id: string
|
||||
name: string
|
||||
status: 'running' | 'success' | 'error'
|
||||
durationMs?: number
|
||||
errorCode?: string
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: MessageId
|
||||
role: 'user' | 'agent'
|
||||
content: string
|
||||
streaming: boolean
|
||||
toolCalls: ToolCallView[]
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
threadId: ThreadId
|
||||
messages: ChatMessage[]
|
||||
status: 'idle' | 'streaming' | 'error'
|
||||
}
|
||||
|
||||
export type SessionAction =
|
||||
| { type: 'user-send'; id: MessageId; content: string }
|
||||
| { type: 'agent-event'; event: AgentEvent }
|
||||
|
||||
export function createSessionState(threadId: ThreadId): SessionState {
|
||||
return { threadId, messages: [], status: 'idle' }
|
||||
}
|
||||
|
||||
function ensureAgentMessage(state: SessionState, id: MessageId): ChatMessage {
|
||||
const existing = state.messages.find((m) => m.id === id)
|
||||
if (existing) return existing
|
||||
const created: ChatMessage = {
|
||||
id,
|
||||
role: 'agent',
|
||||
content: '',
|
||||
streaming: true,
|
||||
toolCalls: []
|
||||
}
|
||||
state.messages.push(created)
|
||||
return created
|
||||
}
|
||||
|
||||
function applyAgentEvent(state: SessionState, event: AgentEvent): void {
|
||||
switch (event.type) {
|
||||
case 'agent_message_delta': {
|
||||
const message = ensureAgentMessage(state, event.messageId)
|
||||
message.content += event.delta
|
||||
message.streaming = true
|
||||
state.status = 'streaming'
|
||||
return
|
||||
}
|
||||
case 'agent_tool_call': {
|
||||
const message = ensureAgentMessage(state, event.messageId)
|
||||
const existing = message.toolCalls.find((t) => t.id === event.toolCallId)
|
||||
const view: ToolCallView = {
|
||||
id: event.toolCallId,
|
||||
name: event.toolName,
|
||||
status: event.status,
|
||||
durationMs: event.durationMs,
|
||||
errorCode: event.errorCode
|
||||
}
|
||||
if (existing) Object.assign(existing, view)
|
||||
else message.toolCalls.push(view)
|
||||
if (event.status === 'error') state.status = 'error'
|
||||
return
|
||||
}
|
||||
case 'agent_message_done': {
|
||||
const message = ensureAgentMessage(state, event.messageId)
|
||||
message.streaming = false
|
||||
if (state.status !== 'error') state.status = 'idle'
|
||||
return
|
||||
}
|
||||
case 'draft_patch':
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionReducer(
|
||||
state: SessionState,
|
||||
action: SessionAction
|
||||
): SessionState {
|
||||
return produce(state, (draft) => {
|
||||
if (action.type === 'user-send') {
|
||||
draft.messages.push({
|
||||
id: action.id,
|
||||
role: 'user',
|
||||
content: action.content,
|
||||
streaming: false,
|
||||
toolCalls: []
|
||||
})
|
||||
return
|
||||
}
|
||||
applyAgentEvent(draft, action.event)
|
||||
})
|
||||
}
|
||||
377
src/platform/auth/unified/remintRetry.test.ts
Normal file
377
src/platform/auth/unified/remintRetry.test.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import type { AxiosAdapter } from 'axios'
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
attachUnifiedRemintInterceptor,
|
||||
fetchWithUnifiedRemint
|
||||
} from '@/platform/auth/unified/remintRetry'
|
||||
|
||||
const { mockRemint, flagState } = vi.hoisted(() => ({
|
||||
mockRemint: vi.fn(),
|
||||
flagState: { unifiedCloudAuthEnabled: true }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
|
||||
useWorkspaceAuthStore: () => ({ remintUnifiedOnce: mockRemint })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get unifiedCloudAuthEnabled() {
|
||||
return flagState.unifiedCloudAuthEnabled
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
// The axios interceptor gates on shouldRemintCloudRequest(), which is a no-op
|
||||
// off-cloud; the unit env is not a cloud build, so force it on.
|
||||
vi.mock('@/platform/distribution/types', () => ({ isCloud: true }))
|
||||
|
||||
describe('fetchWithUnifiedRemint', () => {
|
||||
const ok = { status: 200 } as Response
|
||||
const unauthorized = { status: 401 } as Response
|
||||
let mockFetch: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
mockRemint.mockReset()
|
||||
flagState.unifiedCloudAuthEnabled = true
|
||||
mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('re-mints once and retries with the fresh token on a 401 (AC1)', async () => {
|
||||
mockFetch.mockResolvedValueOnce(unauthorized).mockResolvedValueOnce(ok)
|
||||
mockRemint.mockResolvedValue('tokenB')
|
||||
|
||||
const result = await fetchWithUnifiedRemint(
|
||||
'https://cloud/x',
|
||||
{ headers: { Authorization: 'Bearer tokenA', 'Comfy-User': 'u1' } },
|
||||
true
|
||||
)
|
||||
|
||||
expect(result).toBe(ok)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
expect(mockRemint).toHaveBeenCalledTimes(1)
|
||||
|
||||
const retryHeaders = new Headers(mockFetch.mock.calls[1][1].headers)
|
||||
expect(retryHeaders.get('Authorization')).toBe('Bearer tokenB')
|
||||
expect(retryHeaders.get('Comfy-User')).toBe('u1')
|
||||
})
|
||||
|
||||
it('surfaces a persistent 401 after exactly one retry (AC2)', async () => {
|
||||
const secondUnauthorized = { status: 401 } as Response
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(unauthorized)
|
||||
.mockResolvedValueOnce(secondUnauthorized)
|
||||
mockRemint.mockResolvedValue('tokenB')
|
||||
|
||||
const result = await fetchWithUnifiedRemint(
|
||||
'https://cloud/x',
|
||||
{ headers: { Authorization: 'Bearer tokenA' } },
|
||||
true
|
||||
)
|
||||
|
||||
expect(result).toBe(secondUnauthorized)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
expect(mockRemint).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not re-mint or retry when the caller gate is false (AC3)', async () => {
|
||||
mockFetch.mockResolvedValueOnce(unauthorized)
|
||||
|
||||
const result = await fetchWithUnifiedRemint(
|
||||
'https://cloud/x',
|
||||
{ headers: { Authorization: 'Bearer tokenA' } },
|
||||
false
|
||||
)
|
||||
|
||||
expect(result).toBe(unauthorized)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(mockRemint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not retry a non-401 response', async () => {
|
||||
const serverError = { status: 500 } as Response
|
||||
mockFetch.mockResolvedValueOnce(serverError)
|
||||
|
||||
const result = await fetchWithUnifiedRemint(
|
||||
'https://cloud/x',
|
||||
{ headers: { Authorization: 'Bearer t' } },
|
||||
true
|
||||
)
|
||||
|
||||
expect(result).toBe(serverError)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(mockRemint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces the original 401 when the re-mint yields no token', async () => {
|
||||
mockFetch.mockResolvedValueOnce(unauthorized)
|
||||
mockRemint.mockResolvedValue(null)
|
||||
|
||||
const result = await fetchWithUnifiedRemint(
|
||||
'https://cloud/x',
|
||||
{ headers: { Authorization: 'Bearer t' } },
|
||||
true
|
||||
)
|
||||
|
||||
expect(result).toBe(unauthorized)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(mockRemint).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('surfaces the original 401 when the re-mint throws a permanent auth error', async () => {
|
||||
mockFetch.mockResolvedValueOnce(unauthorized)
|
||||
mockRemint.mockRejectedValue(new Error('INVALID_FIREBASE_TOKEN'))
|
||||
|
||||
const result = await fetchWithUnifiedRemint(
|
||||
'https://cloud/x',
|
||||
{ headers: { Authorization: 'Bearer t' } },
|
||||
true
|
||||
)
|
||||
|
||||
expect(result).toBe(unauthorized)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(mockRemint).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('surfaces the original 401 without re-minting when the body is a non-replayable stream', async () => {
|
||||
mockFetch.mockResolvedValueOnce(unauthorized)
|
||||
mockRemint.mockResolvedValue('tokenB')
|
||||
|
||||
const result = await fetchWithUnifiedRemint(
|
||||
'https://cloud/x',
|
||||
{
|
||||
method: 'POST',
|
||||
body: new ReadableStream<Uint8Array>(),
|
||||
headers: { Authorization: 'Bearer tokenA' }
|
||||
},
|
||||
true
|
||||
)
|
||||
|
||||
expect(result).toBe(unauthorized)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(mockRemint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.for([
|
||||
{
|
||||
shape: 'object',
|
||||
headers: { Authorization: 'Bearer tokenA', 'Comfy-User': 'u1' }
|
||||
},
|
||||
{
|
||||
shape: 'array of tuples',
|
||||
headers: [
|
||||
['Authorization', 'Bearer tokenA'],
|
||||
['Comfy-User', 'u1']
|
||||
]
|
||||
},
|
||||
{
|
||||
shape: 'Headers',
|
||||
headers: new Headers({
|
||||
Authorization: 'Bearer tokenA',
|
||||
'Comfy-User': 'u1'
|
||||
})
|
||||
}
|
||||
] as { shape: string; headers: HeadersInit }[])(
|
||||
'preserves method/body and replaces Authorization on a POST retry ($shape headers)',
|
||||
async ({ headers }) => {
|
||||
mockFetch.mockResolvedValueOnce(unauthorized).mockResolvedValueOnce(ok)
|
||||
mockRemint.mockResolvedValue('tokenB')
|
||||
const body = JSON.stringify({ amount: 5 })
|
||||
|
||||
await fetchWithUnifiedRemint(
|
||||
'https://cloud/x',
|
||||
{ method: 'POST', body, headers },
|
||||
true
|
||||
)
|
||||
|
||||
const retryInit = mockFetch.mock.calls[1][1]
|
||||
expect(retryInit.method).toBe('POST')
|
||||
expect(retryInit.body).toBe(body)
|
||||
const retryHeaders = new Headers(retryInit.headers)
|
||||
expect(retryHeaders.get('Authorization')).toBe('Bearer tokenB')
|
||||
expect(retryHeaders.get('Comfy-User')).toBe('u1')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('attachUnifiedRemintInterceptor', () => {
|
||||
beforeEach(() => {
|
||||
mockRemint.mockReset()
|
||||
flagState.unifiedCloudAuthEnabled = true
|
||||
})
|
||||
|
||||
// A custom axios adapter is responsible for its own status handling (axios
|
||||
// only applies validateStatus inside its built-in adapters), so reject
|
||||
// non-2xx with a real AxiosError to mirror a live response.
|
||||
function makeAdapter(statuses: number[]): ReturnType<typeof vi.fn> {
|
||||
let call = 0
|
||||
return vi.fn<AxiosAdapter>(async (config) => {
|
||||
const status = statuses[Math.min(call, statuses.length - 1)]
|
||||
call++
|
||||
const response = {
|
||||
data: status === 200 ? { ok: true } : { message: 'unauthorized' },
|
||||
status,
|
||||
statusText: String(status),
|
||||
headers: {},
|
||||
config
|
||||
}
|
||||
if (status >= 200 && status < 300) {
|
||||
return response
|
||||
}
|
||||
throw new AxiosError(
|
||||
`Request failed with status code ${status}`,
|
||||
AxiosError.ERR_BAD_REQUEST,
|
||||
config,
|
||||
null,
|
||||
response
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function makeClient(statuses: number[]) {
|
||||
const adapter = makeAdapter(statuses)
|
||||
const client = axios.create({ adapter: adapter as unknown as AxiosAdapter })
|
||||
attachUnifiedRemintInterceptor(client)
|
||||
return { client, adapter }
|
||||
}
|
||||
|
||||
it('re-mints once and retries the request with the fresh token (AC1)', async () => {
|
||||
const { client, adapter } = makeClient([401, 200])
|
||||
mockRemint.mockResolvedValue('tokenB')
|
||||
|
||||
const res = await client.get('https://cloud/x', {
|
||||
headers: { Authorization: 'Bearer tokenA' }
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(adapter).toHaveBeenCalledTimes(2)
|
||||
expect(mockRemint).toHaveBeenCalledTimes(1)
|
||||
expect(String(adapter.mock.calls[1][0].headers.Authorization)).toBe(
|
||||
'Bearer tokenB'
|
||||
)
|
||||
})
|
||||
|
||||
it('retries once then surfaces a persistent 401 (AC2)', async () => {
|
||||
const { client, adapter } = makeClient([401, 401])
|
||||
mockRemint.mockResolvedValue('tokenB')
|
||||
|
||||
await expect(
|
||||
client.get('https://cloud/x', {
|
||||
headers: { Authorization: 'Bearer tokenA' }
|
||||
})
|
||||
).rejects.toMatchObject({ response: { status: 401 } })
|
||||
|
||||
expect(adapter).toHaveBeenCalledTimes(2)
|
||||
expect(mockRemint).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not re-mint when the flag is OFF (AC3)', async () => {
|
||||
flagState.unifiedCloudAuthEnabled = false
|
||||
const { client, adapter } = makeClient([401])
|
||||
|
||||
await expect(
|
||||
client.get('https://cloud/x', {
|
||||
headers: { Authorization: 'Bearer tokenA' }
|
||||
})
|
||||
).rejects.toMatchObject({ response: { status: 401 } })
|
||||
|
||||
expect(adapter).toHaveBeenCalledTimes(1)
|
||||
expect(mockRemint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not re-mint a request flagged __skipUnifiedRemint (acceptInvite)', async () => {
|
||||
const { client, adapter } = makeClient([401])
|
||||
mockRemint.mockResolvedValue('tokenB')
|
||||
|
||||
await expect(
|
||||
client.post('https://cloud/invites/x/accept', null, {
|
||||
headers: { Authorization: 'Bearer firebase' },
|
||||
__skipUnifiedRemint: true
|
||||
})
|
||||
).rejects.toMatchObject({ response: { status: 401 } })
|
||||
|
||||
expect(adapter).toHaveBeenCalledTimes(1)
|
||||
expect(mockRemint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes a non-401 error through without re-minting', async () => {
|
||||
const { client } = makeClient([500])
|
||||
|
||||
await expect(
|
||||
client.get('https://cloud/x', { headers: { Authorization: 'Bearer t' } })
|
||||
).rejects.toMatchObject({ response: { status: 500 } })
|
||||
|
||||
expect(mockRemint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves the POST body and method on a retry, with the fresh token', async () => {
|
||||
const { client, adapter } = makeClient([401, 200])
|
||||
mockRemint.mockResolvedValue('tokenB')
|
||||
|
||||
const res = await client.post(
|
||||
'https://cloud/topup',
|
||||
{ amount: 5 },
|
||||
{ headers: { Authorization: 'Bearer tokenA' } }
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(adapter).toHaveBeenCalledTimes(2)
|
||||
const firstConfig = adapter.mock.calls[0][0]
|
||||
const retryConfig = adapter.mock.calls[1][0]
|
||||
expect(retryConfig.method).toBe('post')
|
||||
expect(retryConfig.data).toBe(firstConfig.data)
|
||||
expect(String(retryConfig.headers.Authorization)).toBe('Bearer tokenB')
|
||||
})
|
||||
|
||||
it('latches per request — a second request still retries once (no shared latch)', async () => {
|
||||
// Per-URL: first call 401, second (the retry) 200. The latch lives on each
|
||||
// request's config, so a second request must retry independently.
|
||||
const callsByUrl = new Map<string, number>()
|
||||
const adapter = vi.fn<AxiosAdapter>(async (config) => {
|
||||
const url = config.url ?? ''
|
||||
const nth = (callsByUrl.get(url) ?? 0) + 1
|
||||
callsByUrl.set(url, nth)
|
||||
const okStatus = nth >= 2
|
||||
const response = {
|
||||
data: okStatus ? { ok: true } : { message: 'unauthorized' },
|
||||
status: okStatus ? 200 : 401,
|
||||
statusText: okStatus ? '200' : '401',
|
||||
headers: {},
|
||||
config
|
||||
}
|
||||
if (okStatus) return response
|
||||
throw new AxiosError(
|
||||
'Request failed with status code 401',
|
||||
AxiosError.ERR_BAD_REQUEST,
|
||||
config,
|
||||
null,
|
||||
response
|
||||
)
|
||||
})
|
||||
const client = axios.create({ adapter: adapter as unknown as AxiosAdapter })
|
||||
attachUnifiedRemintInterceptor(client)
|
||||
mockRemint.mockResolvedValue('tokenB')
|
||||
|
||||
const a = await client.get('https://cloud/a', {
|
||||
headers: { Authorization: 'Bearer tokenA' }
|
||||
})
|
||||
const b = await client.get('https://cloud/b', {
|
||||
headers: { Authorization: 'Bearer tokenA' }
|
||||
})
|
||||
|
||||
expect(a.status).toBe(200)
|
||||
expect(b.status).toBe(200)
|
||||
// Each request: initial 401 + one retry = 4 adapter calls, one re-mint each.
|
||||
expect(adapter).toHaveBeenCalledTimes(4)
|
||||
expect(mockRemint).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
131
src/platform/auth/unified/remintRetry.ts
Normal file
131
src/platform/auth/unified/remintRetry.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type {
|
||||
AxiosError,
|
||||
AxiosInstance,
|
||||
InternalAxiosRequestConfig
|
||||
} from 'axios'
|
||||
import axios, { AxiosHeaders } from 'axios'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
let cachedUnifiedFlags:
|
||||
| { readonly unifiedCloudAuthEnabled: boolean }
|
||||
| undefined
|
||||
|
||||
/**
|
||||
* Single gate for the reactive guard: a cloud build with `unified_cloud_auth`
|
||||
* ON. Memoizes the feature-flag accessor so the hot `fetchApi` path does not
|
||||
* build a fresh reactive proxy per request (the cached getter still reflects
|
||||
* live flag changes), and is reused at every cloud request seam so the gate
|
||||
* cannot be forgotten on a new call site.
|
||||
*/
|
||||
export async function shouldRemintCloudRequest(): Promise<boolean> {
|
||||
if (!isCloud) return false
|
||||
if (!cachedUnifiedFlags) {
|
||||
const { useFeatureFlags } = await import('@/composables/useFeatureFlags')
|
||||
cachedUnifiedFlags = useFeatureFlags().flags
|
||||
}
|
||||
return cachedUnifiedFlags.unifiedCloudAuthEnabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-mints the unified Cloud JWT once from the current Firebase identity and
|
||||
* returns the fresh token, or `null` when there is nothing to retry with: no
|
||||
* active unified session, or the re-mint failed. A permanent auth failure is
|
||||
* surfaced + torn down inside `remintUnifiedOnce` (error toast + session clear,
|
||||
* matching the proactive refresh path); the `catch` here only guards an
|
||||
* unexpected throw (e.g. a chunk-load failure or no active Pinia), which it
|
||||
* logs. Either way `null` makes the caller surface its original 401 unchanged.
|
||||
*/
|
||||
async function tryRemintToken(): Promise<string | null> {
|
||||
try {
|
||||
const { useWorkspaceAuthStore } =
|
||||
await import('@/platform/workspace/stores/workspaceAuthStore')
|
||||
return await useWorkspaceAuthStore().remintUnifiedOnce()
|
||||
} catch (err) {
|
||||
console.warn('Unified re-mint primitive threw unexpectedly:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues a `fetch` and, on a `401`, re-mints the unified Cloud JWT once and
|
||||
* retries the request exactly once with the fresh token. A persistent `401`
|
||||
* (or a `null` re-mint) surfaces the original Response unchanged — no retry
|
||||
* loop. Requires a replayable body: a one-shot `ReadableStream` body cannot be
|
||||
* replayed, so such a request surfaces its original `401` without a retry (no
|
||||
* current cloud caller sends one).
|
||||
*
|
||||
* `shouldRetryOn401` is the caller's gate (see {@link shouldRemintCloudRequest}):
|
||||
* flag-OFF traffic returns after a single `fetch` and never enters the re-mint
|
||||
* path, so the legacy cascade stays untouched for instant rollback.
|
||||
*/
|
||||
export async function fetchWithUnifiedRemint(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit,
|
||||
shouldRetryOn401: boolean
|
||||
): Promise<Response> {
|
||||
const response = await fetch(input, init)
|
||||
if (!shouldRetryOn401 || response.status !== 401) {
|
||||
return response
|
||||
}
|
||||
|
||||
if (init.body instanceof ReadableStream) {
|
||||
console.warn(
|
||||
'fetchWithUnifiedRemint: a ReadableStream body is not replayable; surfacing the original 401'
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
const token = await tryRemintToken()
|
||||
if (!token) {
|
||||
return response
|
||||
}
|
||||
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
return fetch(input, { ...init, headers })
|
||||
}
|
||||
|
||||
function isRetriableUnauthorized(
|
||||
error: unknown
|
||||
): error is AxiosError & { config: InternalAxiosRequestConfig } {
|
||||
if (!axios.isAxiosError(error)) return false
|
||||
const config = error.config
|
||||
if (!config || config.__unifiedRetried || config.__skipUnifiedRemint) {
|
||||
return false
|
||||
}
|
||||
return error.response?.status === 401
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs a response interceptor that gives a cloud axios client the same
|
||||
* reactive 401 guard as {@link fetchWithUnifiedRemint}: a single re-mint + a
|
||||
* single retry on `401`, surfacing a persistent `401` unchanged. A strict
|
||||
* no-op while `unified_cloud_auth` is OFF — the original error rejects exactly
|
||||
* as it does today.
|
||||
*/
|
||||
export function attachUnifiedRemintInterceptor(client: AxiosInstance): void {
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: unknown) => {
|
||||
if (
|
||||
!isRetriableUnauthorized(error) ||
|
||||
!(await shouldRemintCloudRequest())
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const token = await tryRemintToken()
|
||||
if (!token) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Clone (don't mutate) the caller's config so the re-minted Bearer never
|
||||
// leaks into a caller-retained reference, matching fetchWithUnifiedRemint.
|
||||
const { config } = error
|
||||
const headers = new AxiosHeaders(config.headers)
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
return client.request({ ...config, headers, __unifiedRetried: true })
|
||||
}
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user