mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-26 17:47:17 +00:00
Compare commits
1 Commits
DynamicGro
...
rizumu/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e34506515 |
@@ -2,7 +2,6 @@ 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,24 +133,3 @@ 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'
|
||||
|
||||
3
.github/workflows/cla.yml
vendored
3
.github/workflows/cla.yml
vendored
@@ -5,7 +5,6 @@ on:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, closed]
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
@@ -41,7 +40,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,comfy-pr-bot,github-actions,*[bot],Glary Bot
|
||||
allowlist: actions-user,ampagent,claude,coderabbitai[bot],comfy-pr-bot,dependabot[bot],github-actions[bot],copilot-swe-agent[bot],devin-ai-integration[bot],*[bot]
|
||||
|
||||
# Custom PR comment messages
|
||||
custom-notsigned-prcomment: |
|
||||
|
||||
@@ -83,16 +83,6 @@ 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,11 +47,6 @@ 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()
|
||||
@@ -78,7 +73,7 @@ test.describe('Download page @smoke', () => {
|
||||
})
|
||||
|
||||
const windowsBtn = hero.locator(
|
||||
'a[href="https://comfy.org/download/windows/nsis/x64"]'
|
||||
'a[href="https://download.comfy.org/windows/nsis/x64"]'
|
||||
)
|
||||
await expect(windowsBtn).toBeVisible()
|
||||
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)
|
||||
|
||||
@@ -72,7 +72,6 @@ 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://comfy.org/download/windows/nsis/x64',
|
||||
windows: 'https://download.comfy.org/windows/nsis/x64',
|
||||
macArm: 'https://download.comfy.org/mac/dmg/arm64'
|
||||
} as const
|
||||
|
||||
|
||||
@@ -56,16 +56,12 @@ class ComfyPropertiesPanel {
|
||||
readonly panelTitle: Locator
|
||||
readonly searchBox: Locator
|
||||
readonly titleEditor: TitleEditor
|
||||
readonly toggleButton: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.root = page.getByTestId(TestIds.propertiesPanel.root)
|
||||
this.panelTitle = this.root.locator('h3')
|
||||
this.searchBox = this.root.getByPlaceholder(/^Search/)
|
||||
this.titleEditor = new TitleEditor(this.root)
|
||||
this.toggleButton = page.getByRole('button', {
|
||||
name: 'Toggle properties panel'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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' }))
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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')
|
||||
})
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
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 }
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
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,7 +4,8 @@ import type { Page } from '@playwright/test'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
/**
|
||||
* getSurveyCompletedStatus fails safe: a transient 401 on `/` must not bounce a
|
||||
@@ -15,12 +16,51 @@ import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
// `/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
|
||||
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([])))
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -49,13 +89,22 @@ 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.slow()
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page, { features: BOOT_FEATURES })
|
||||
await mockCloudBoot(page)
|
||||
await mockSurveyTransient401(page)
|
||||
await bootCloud(page)
|
||||
|
||||
@@ -73,9 +122,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.slow()
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page, { features: BOOT_FEATURES })
|
||||
await mockCloudBoot(page)
|
||||
await mockSurveyNotCompleted(page)
|
||||
await bootCloud(page)
|
||||
|
||||
|
||||
@@ -2,10 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type {
|
||||
BillingStatusResponse,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import type { 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'
|
||||
@@ -54,20 +51,6 @@ 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 = {
|
||||
@@ -122,32 +105,6 @@ 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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,264 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,128 +0,0 @@
|
||||
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 |
@@ -1,9 +1,6 @@
|
||||
import type { ConsoleMessage } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
@@ -98,225 +95,4 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
|
||||
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Detach Race Repro', { tag: ['@vue-nodes'] }, () => {
|
||||
const SUBGRAPH_NODE_TITLE = 'New Subgraph'
|
||||
|
||||
// Queues legacy onNodeRemoved/onSelectionChange so unpack completes first,
|
||||
// widening the race window so a guard regression deterministically surfaces.
|
||||
async function deferLegacyHandlers(comfyPage: ComfyPage) {
|
||||
return await comfyPage.page.evaluateHandle(() => {
|
||||
const graph = window.app!.graph!
|
||||
const canvas = window.app!.canvas!
|
||||
const queue: Array<() => void> = []
|
||||
const originalNodeRemoved = graph.onNodeRemoved
|
||||
const originalSelectionChange = canvas.onSelectionChange
|
||||
graph.onNodeRemoved = function (node) {
|
||||
queue.push(() => originalNodeRemoved?.call(this, node))
|
||||
}
|
||||
canvas.onSelectionChange = function (selected) {
|
||||
queue.push(() => originalSelectionChange?.call(this, selected))
|
||||
}
|
||||
return {
|
||||
drain: () => {
|
||||
for (const fn of queue.splice(0)) fn()
|
||||
},
|
||||
restore: () => {
|
||||
graph.onNodeRemoved = originalNodeRemoved
|
||||
canvas.onSelectionChange = originalSelectionChange
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type DeferredHandlers = Awaited<ReturnType<typeof deferLegacyHandlers>>
|
||||
|
||||
// Defers only the legacy selection-change callback, so the detached host
|
||||
// node lingers in the reactive selection while onNodeRemoved still runs
|
||||
// normally and clears it from the canvas. This isolates the panel render
|
||||
// path: a panel mounted during this window reads the stale selection.
|
||||
async function deferSelectionChange(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<DeferredHandlers> {
|
||||
return await comfyPage.page.evaluateHandle(() => {
|
||||
const canvas = window.app!.canvas!
|
||||
const queue: Array<() => void> = []
|
||||
const original = canvas.onSelectionChange
|
||||
canvas.onSelectionChange = function (selected) {
|
||||
queue.push(() => original?.call(this, selected))
|
||||
}
|
||||
return {
|
||||
drain: () => {
|
||||
for (const fn of queue.splice(0)) fn()
|
||||
},
|
||||
restore: () => {
|
||||
canvas.onSelectionChange = original
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function isNullGraphErrorText(text: string): boolean {
|
||||
return text.includes('NullGraphError') || text.endsWith('has no graph')
|
||||
}
|
||||
|
||||
// Vue's default errorHandler routes render throws to console.error,
|
||||
// not pageerror - listen to both.
|
||||
function captureNullGraphErrors(comfyPage: ComfyPage) {
|
||||
const captured: string[] = []
|
||||
const onPageError = (err: Error) => {
|
||||
if (
|
||||
err.name === 'NullGraphError' ||
|
||||
isNullGraphErrorText(err.message ?? '')
|
||||
) {
|
||||
captured.push(`pageerror ${err.name}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
const onConsoleMessage = (msg: ConsoleMessage) => {
|
||||
if (msg.type() !== 'error') return
|
||||
const text = msg.text()
|
||||
if (isNullGraphErrorText(text)) {
|
||||
captured.push(`console.error: ${text}`)
|
||||
}
|
||||
}
|
||||
comfyPage.page.on('pageerror', onPageError)
|
||||
comfyPage.page.on('console', onConsoleMessage)
|
||||
return {
|
||||
getErrors: () => [...captured],
|
||||
stop: () => {
|
||||
comfyPage.page.off('pageerror', onPageError)
|
||||
comfyPage.page.off('console', onConsoleMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function unpackViaContextMenu(comfyPage: ComfyPage, title: string) {
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
|
||||
await comfyPage.contextMenu.openForVueNode(fixture.header)
|
||||
await comfyPage.contextMenu.clickMenuItemExact('Unpack Subgraph')
|
||||
}
|
||||
|
||||
async function reopenRightSidePanel(comfyPage: ComfyPage) {
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
await propertiesPanel.toggleButton.click()
|
||||
await expect(propertiesPanel.root).toBeHidden()
|
||||
await propertiesPanel.toggleButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
// Unpacks the subgraph behind deferred teardown, runs an optional
|
||||
// interaction while the node is detached but not yet cleaned up, then
|
||||
// drains the deferred handlers and reports any NullGraphErrors seen.
|
||||
async function unpackAndCaptureNullGraphErrors(
|
||||
comfyPage: ComfyPage,
|
||||
options: {
|
||||
defer: (comfyPage: ComfyPage) => Promise<DeferredHandlers>
|
||||
duringWindow?: (comfyPage: ComfyPage) => Promise<void>
|
||||
}
|
||||
): Promise<string[]> {
|
||||
const subgraphNode =
|
||||
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
|
||||
const errors = captureNullGraphErrors(comfyPage)
|
||||
const deferred = await options.defer(comfyPage)
|
||||
try {
|
||||
await unpackViaContextMenu(comfyPage, SUBGRAPH_NODE_TITLE)
|
||||
await expect(subgraphNode).toHaveCount(0)
|
||||
await options.duringWindow?.(comfyPage)
|
||||
await deferred.evaluate((handlers) => handlers.drain())
|
||||
// Let drained-handler reactive flushes settle before stop().
|
||||
await comfyPage.nextFrame()
|
||||
return errors.getErrors()
|
||||
} finally {
|
||||
await deferred.evaluate((handlers) => handlers.restore())
|
||||
await deferred.dispose()
|
||||
errors.stop()
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
const subgraphNode =
|
||||
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
const fixture =
|
||||
await comfyPage.vueNodes.getFixtureByTitle(SUBGRAPH_NODE_TITLE)
|
||||
await fixture.header.click()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
|
||||
).toBeVisible()
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('unpack does not surface NullGraphError on the LGraphNode render path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferLegacyHandlers
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'LGraphNode render path: detach race must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('unpack does not surface NullGraphError from the TabSubgraphInputs panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferLegacyHandlers
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'TabSubgraphInputs panel: detach race must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('unpack with subgraph editor open does not surface NullGraphError from the SubgraphEditor panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferLegacyHandlers
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'SubgraphEditor panel: detach race must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('reopening the right side panel after unpack does not surface NullGraphError', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferSelectionChange,
|
||||
duringWindow: reopenRightSidePanel
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'TabSubgraphInputs remount: stale selection must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('reopening the right side panel with the subgraph editor open does not surface NullGraphError', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferSelectionChange,
|
||||
duringWindow: reopenRightSidePanel
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'SubgraphEditor remount: stale selection must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
const getHeaderPos = async (
|
||||
@@ -336,79 +335,6 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
})
|
||||
|
||||
test('pointerCancel stops autopan', async ({ comfyPage }) => {
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await ksampler.header.click({ trial: true })
|
||||
await comfyPage.page.mouse.down()
|
||||
|
||||
const getOffset = () => comfyPage.canvasOps.getOffset()
|
||||
const initialOffset = await getOffset()
|
||||
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
|
||||
await expect.poll(getOffset, 'drag with autopan').not.toEqual(initialOffset)
|
||||
|
||||
await test.step('move outside pan range and cancel drag', async () => {
|
||||
await comfyPage.page.mouse.move(400, 400, { steps: 20 })
|
||||
await ksampler.header.evaluate((node) =>
|
||||
node.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true }))
|
||||
)
|
||||
})
|
||||
|
||||
const secondaryOffset = await getOffset()
|
||||
|
||||
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
|
||||
await comfyPage.nextFrame()
|
||||
expect(await getOffset(), 'drag canceled').toEqual(secondaryOffset)
|
||||
})
|
||||
|
||||
test('dragging a node moves all selected items', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const samplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
const ksampler = new VueNodeFixture(samplerLocator)
|
||||
const loaderLocator = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const loader = new VueNodeFixture(loaderLocator)
|
||||
|
||||
await test.step('create graph with group and reroute', async () => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.searchBoxV2.addNode('Load Checkpoint')
|
||||
const samplerOptions = { position: { x: 800, y: 200 } }
|
||||
await comfyPage.searchBoxV2.addNode('KSampler', samplerOptions)
|
||||
await ksampler.getSlot('model').dragTo(loader.getSlot('MODEL'))
|
||||
|
||||
await test.step('add reroute', async () => {
|
||||
const b1 = await ksampler.getSlot('model').boundingBox()
|
||||
const b2 = await loader.getSlot('MODEL').boundingBox()
|
||||
if (!b1 || !b2) throw new Error('Failed to get bounds')
|
||||
|
||||
const x = (b1.x + b2.x + (b1.width + b2.width) / 2) / 2
|
||||
const y = (b1.y + b2.y + (b1.height + b2.height) / 2) / 2
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.click(x, y)
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
const rerouteCount = () =>
|
||||
comfyPage.page.evaluate(() => graph!.reroutes.size)
|
||||
await expect.poll(rerouteCount).toBe(1)
|
||||
})
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press('Control+G')
|
||||
await comfyPage.keyboard.selectAll()
|
||||
})
|
||||
|
||||
const getReroutePos = () =>
|
||||
comfyPage.page.evaluate(() => [...graph!.reroutes.values()][0])
|
||||
const getGroupPos = () =>
|
||||
comfyPage.page.evaluate(() => graph!.groups[0].pos)
|
||||
const initialReroutePos = await getReroutePos()
|
||||
const initialGroupPos = await getGroupPos()
|
||||
await comfyMouse.dragElementBy(ksampler.title, { x: 100 })
|
||||
|
||||
await expect.poll(getReroutePos).not.toEqual(initialReroutePos)
|
||||
await expect.poll(getGroupPos).not.toEqual(initialGroupPos)
|
||||
})
|
||||
|
||||
test(
|
||||
'@mobile should allow moving nodes by dragging on touch devices',
|
||||
{ tag: '@screenshot' },
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -73,16 +73,4 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(widget, 'Widget has restored value').toHaveText('scale width')
|
||||
})
|
||||
|
||||
test('Dynamic children have separate state', async ({ comfyPage }) => {
|
||||
const nodeName = 'Node With Dynamic Combo'
|
||||
await comfyPage.searchBoxV2.addNode(nodeName, {
|
||||
position: { x: 200, y: 150 }
|
||||
})
|
||||
const child = comfyPage.vueNodes.getWidgetByName(nodeName, 'suboption')
|
||||
await expect(child, 'initial state').toHaveText('1x')
|
||||
|
||||
await comfyPage.vueNodes.selectComboOption(nodeName, 'combo', 'option2')
|
||||
await expect(child, 'child of same name has new state').toHaveText('2x')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.47.5",
|
||||
"version": "1.47.3",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -19,10 +19,7 @@
|
||||
"size:collect": "node scripts/size-collect.js",
|
||||
"size:report": "node scripts/size-report.js",
|
||||
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
|
||||
"dev:cloud": "pnpm dev:cloud:test",
|
||||
"dev:cloud:test": "cross-env DEV_SERVER_COMFYUI_URL=https://testcloud.comfy.org/ vite --config vite.config.mts",
|
||||
"dev:cloud:staging": "cross-env DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/ vite --config vite.config.mts",
|
||||
"dev:cloud:prod": "cross-env DEV_SERVER_COMFYUI_URL=https://cloud.comfy.org/ vite --config vite.config.mts",
|
||||
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' vite --config vite.config.mts",
|
||||
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
|
||||
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
|
||||
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",
|
||||
|
||||
@@ -52,8 +52,6 @@
|
||||
--color-gold-500: #fdab34;
|
||||
--color-gold-600: #fd9903;
|
||||
|
||||
--color-credit: #fabc25;
|
||||
|
||||
--color-coral-500: #f75951;
|
||||
--color-coral-600: #e04e48;
|
||||
--color-coral-700: #b33a3a;
|
||||
@@ -238,8 +236,6 @@
|
||||
--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);
|
||||
@@ -388,8 +384,6 @@
|
||||
--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);
|
||||
@@ -560,8 +554,6 @@
|
||||
--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);
|
||||
|
||||
@@ -344,15 +344,6 @@ export const zDynamicComboInputSpec = z.tuple([
|
||||
})
|
||||
])
|
||||
|
||||
export const zDynamicGroupInputSpec = z.tuple([
|
||||
z.literal('COMFY_DYNAMICGROUP_V3'),
|
||||
zBaseInputOptions.extend({
|
||||
template: zComfyInputsSpec,
|
||||
min: z.number().int().nonnegative().optional().default(0),
|
||||
max: z.number().int().positive().max(100).optional().default(50)
|
||||
})
|
||||
])
|
||||
|
||||
export const zMatchTypeOptions = z.object({
|
||||
...zBaseInputOptions.shape,
|
||||
type: z.literal('COMFY_MATCHTYPE_V3'),
|
||||
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineOptions({
|
||||
@@ -52,27 +50,11 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem
|
||||
v-else
|
||||
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) }
|
||||
: {}
|
||||
"
|
||||
:class="itemClass"
|
||||
:disabled="toValue(item.disabled) ?? !item.command"
|
||||
@select="item.command?.({ originalEvent: $event, item })"
|
||||
>
|
||||
<!-- 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" />
|
||||
<i 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,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import {
|
||||
DropdownMenuArrow,
|
||||
@@ -8,16 +7,13 @@ import {
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, ref, toValue } from 'vue'
|
||||
import { computed, 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
|
||||
})
|
||||
@@ -45,20 +41,10 @@ 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 v-model:open="open">
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
|
||||
@@ -74,7 +60,6 @@ const contentStyle = computed(() => {
|
||||
:collision-padding="10"
|
||||
v-bind="$attrs"
|
||||
:class="contentClass"
|
||||
:style="contentStyle"
|
||||
>
|
||||
<slot :item-class>
|
||||
<DropdownItem
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -51,7 +51,8 @@ const mockHandleNativeDrop = vi.fn()
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({
|
||||
startDrag: mockStartDrag,
|
||||
handleNativeDrop: mockHandleNativeDrop
|
||||
handleNativeDrop: mockHandleNativeDrop,
|
||||
isDragging: ref(false)
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<Message v-if="userIsInChina" severity="warn" class="mb-4">
|
||||
{{ t('auth.signup.regionRestrictionChina') }}
|
||||
</Message>
|
||||
<SignUpForm v-else ref="signUpForm" @submit="signUpWithEmail" />
|
||||
<SignUpForm v-else @submit="signUpWithEmail" />
|
||||
</template>
|
||||
|
||||
<!-- Divider -->
|
||||
@@ -206,21 +206,9 @@ const signInWithEmail = async (values: SignInData) => {
|
||||
}
|
||||
}
|
||||
|
||||
const signUpForm = ref<InstanceType<typeof SignUpForm> | null>(null)
|
||||
|
||||
const signUpWithEmail = async (values: SignUpData, turnstileToken?: string) => {
|
||||
if (
|
||||
await authActions.signUpWithEmail(
|
||||
values.email,
|
||||
values.password,
|
||||
turnstileToken
|
||||
)
|
||||
) {
|
||||
const signUpWithEmail = async (values: SignUpData) => {
|
||||
if (await authActions.signUpWithEmail(values.email, values.password)) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
<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>
|
||||
195
src/components/dialog/content/setting/LegacyCreditsPanel.vue
Normal file
195
src/components/dialog/content/setting/LegacyCreditsPanel.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<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,13 +1,12 @@
|
||||
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 { defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
@@ -37,116 +36,34 @@ 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(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 })
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
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', () => {
|
||||
@@ -190,97 +107,4 @@ 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,34 +29,13 @@
|
||||
|
||||
<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 || submitBlockedByTurnstile"
|
||||
:aria-describedby="
|
||||
submitBlockedByTurnstile
|
||||
? 'comfy-org-sign-up-turnstile-hint'
|
||||
: undefined
|
||||
"
|
||||
:disabled="!$form.valid"
|
||||
>
|
||||
{{ t('auth.signup.signUpButton') }}
|
||||
</Button>
|
||||
@@ -70,58 +49,27 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { computed } 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, turnstileToken?: string]
|
||||
submit: [values: SignUpData]
|
||||
}>()
|
||||
|
||||
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
|
||||
if (event.valid && !submitBlockedByTurnstile.value) {
|
||||
emit(
|
||||
'submit',
|
||||
event.values as SignUpData,
|
||||
turnstileToken.value || undefined
|
||||
)
|
||||
if (event.valid) {
|
||||
emit('submit', event.values as SignUpData)
|
||||
}
|
||||
}, 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>
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
<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,7 +195,10 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
import { useUrlActionLoaders } from '@/composables/useUrlActionLoaders'
|
||||
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'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{
|
||||
@@ -454,7 +457,10 @@ useEventListener(
|
||||
|
||||
const comfyAppReady = ref(false)
|
||||
const workflowPersistence = useWorkflowPersistence()
|
||||
const { runUrlActionLoaders } = useUrlActionLoaders()
|
||||
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
|
||||
useCanvasDrop(canvasRef)
|
||||
useLitegraphSettings()
|
||||
useNodeBadge()
|
||||
@@ -563,8 +569,23 @@ onMounted(async () => {
|
||||
() => canvasStore.updateSelectedItems()
|
||||
)
|
||||
|
||||
// Run query-param deep-link loaders (?invite, ?create_workspace, ?pricing)
|
||||
await runUrlActionLoaders()
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
|
||||
const { useReleaseStore } =
|
||||
|
||||
@@ -14,6 +14,12 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
// startDrag/cancelDrag toggle the litegraph ghost-placement flag; stub the
|
||||
// store so the composable runs without an active Pinia in this component test.
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn(() => ({ isGhostPlacing: false, canvas: undefined }))
|
||||
}))
|
||||
|
||||
const nodeDef = fromPartial<ComfyNodeDefImpl>({ name: 'TestNode' })
|
||||
|
||||
function moveMouse(clientX: number, clientY: number) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
@@ -166,9 +165,7 @@ describe('WidgetRange', () => {
|
||||
outputsHolder.nodeOutputs = {
|
||||
loc1: { histogram_range_w: [1, 2, 3, 4] }
|
||||
}
|
||||
renderWidget(
|
||||
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
|
||||
)
|
||||
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
|
||||
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
|
||||
'true'
|
||||
)
|
||||
@@ -178,9 +175,7 @@ describe('WidgetRange', () => {
|
||||
outputsHolder.nodeOutputs = {
|
||||
loc1: { histogram_range_w: [] }
|
||||
}
|
||||
renderWidget(
|
||||
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
|
||||
)
|
||||
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
|
||||
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
|
||||
'false'
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import type { ErrorCardData } from './types'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
const meta: Meta<typeof ErrorNodeCard> = {
|
||||
title: 'RightSidePanel/Errors/ErrorNodeCard',
|
||||
@@ -24,8 +23,9 @@ type Story = StoryObj<typeof meta>
|
||||
const singleErrorCard: ErrorCardData = {
|
||||
id: 'node-10',
|
||||
title: 'CLIPTextEncode',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeId: '10',
|
||||
nodeTitle: 'CLIP Text Encode (Prompt)',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input "text" is missing.',
|
||||
@@ -37,8 +37,9 @@ const singleErrorCard: ErrorCardData = {
|
||||
const multipleErrorsCard: ErrorCardData = {
|
||||
id: 'node-24',
|
||||
title: 'VAEDecode',
|
||||
nodeId: createNodeExecutionId([24]),
|
||||
nodeId: '24',
|
||||
nodeTitle: 'VAE Decode',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input "samples" is missing.',
|
||||
@@ -54,8 +55,9 @@ const multipleErrorsCard: ErrorCardData = {
|
||||
const runtimeErrorCard: ErrorCardData = {
|
||||
id: 'exec-45',
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([45]),
|
||||
nodeId: '45',
|
||||
nodeTitle: 'KSampler',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'OutOfMemoryError: CUDA out of memory. Tried to allocate 1.2GB.',
|
||||
@@ -70,6 +72,20 @@ const runtimeErrorCard: ErrorCardData = {
|
||||
]
|
||||
}
|
||||
|
||||
const subgraphErrorCard: ErrorCardData = {
|
||||
id: 'node-3:15',
|
||||
title: 'KSampler',
|
||||
nodeId: '3:15',
|
||||
nodeTitle: 'Nested KSampler',
|
||||
isSubgraphNode: true,
|
||||
errors: [
|
||||
{
|
||||
message: 'Latent input is required.',
|
||||
details: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const promptOnlyCard: ErrorCardData = {
|
||||
id: '__prompt__',
|
||||
title: 'Prompt has no outputs.',
|
||||
@@ -87,6 +103,13 @@ 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: {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import type { ErrorCardData } from './types'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
const mockGetLogs = vi.fn(() => Promise.resolve('mock server logs'))
|
||||
const mockSerialize = vi.fn(() => ({ nodes: [] }))
|
||||
@@ -79,6 +78,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
},
|
||||
rightSidePanel: {
|
||||
locateNode: 'Locate Node',
|
||||
enterSubgraph: 'Enter Subgraph',
|
||||
errorLog: 'Error log',
|
||||
findOnGithubTooltip: 'Search GitHub issues for related problems',
|
||||
getHelpTooltip:
|
||||
@@ -156,7 +156,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
return {
|
||||
id: `exec-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeId: '10',
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
@@ -249,7 +249,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
renderCard({
|
||||
id: `node-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeId: '10',
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
@@ -387,7 +387,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
const card: ErrorCardData = {
|
||||
id: `exec-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeId: '10',
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
|
||||
@@ -21,6 +21,15 @@
|
||||
</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"
|
||||
@@ -193,6 +202,7 @@ const { card, compact = false } = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateNode: [nodeId: string]
|
||||
enterSubgraph: [nodeId: string]
|
||||
copyToClipboard: [text: string]
|
||||
}>()
|
||||
|
||||
@@ -223,6 +233,12 @@ 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,6 +11,7 @@ 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: {
|
||||
@@ -34,9 +35,16 @@ vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: vi.fn(() => ({
|
||||
fitView: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/canvas/useFocusNode', () => ({
|
||||
useFocusNode: vi.fn(() => ({
|
||||
focusNode: mockFocusNode
|
||||
focusNode: mockFocusNode,
|
||||
enterSubgraph: mockEnterSubgraph
|
||||
}))
|
||||
}))
|
||||
|
||||
|
||||
@@ -249,6 +249,7 @@
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
@@ -356,7 +357,7 @@ const ErrorPanelSurveyCta =
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode } = useFocusNode()
|
||||
const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { openGitHubIssues, contactSupport } = useErrorActions()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
@@ -522,4 +523,8 @@ function handleReplaceGroup(group: SwapNodeGroup) {
|
||||
function handleReplaceAll() {
|
||||
replaceAllGroups(swapNodeGroups.value)
|
||||
}
|
||||
|
||||
function handleEnterSubgraph(nodeId: string) {
|
||||
enterSubgraph(nodeId, errorNodeCache.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ResolvedErrorMessage } from '@/platform/errorCatalog/types'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
export interface ErrorItem extends ResolvedErrorMessage {
|
||||
/** Raw source/API-compatible message. */
|
||||
@@ -13,9 +12,10 @@ export interface ErrorItem extends ResolvedErrorMessage {
|
||||
export interface ErrorCardData {
|
||||
id: string
|
||||
title: string
|
||||
nodeId?: NodeExecutionId
|
||||
nodeId?: string
|
||||
nodeTitle?: string
|
||||
graphNodeId?: string
|
||||
isSubgraphNode?: boolean
|
||||
errors: ErrorItem[]
|
||||
}
|
||||
|
||||
|
||||
@@ -39,8 +39,8 @@ import {
|
||||
resolveRunErrorMessage
|
||||
} from '@/platform/errorCatalog/errorMessageResolver'
|
||||
import {
|
||||
compareExecutionId,
|
||||
tryNormalizeNodeExecutionId
|
||||
isNodeExecutionId,
|
||||
compareExecutionId
|
||||
} from '@/types/nodeIdentification'
|
||||
|
||||
const PROMPT_CARD_ID = '__prompt__'
|
||||
@@ -82,7 +82,7 @@ interface ErrorSearchItem {
|
||||
type CataloguedErrorItem = ErrorItem & ResolvedCatalogErrorMessage
|
||||
|
||||
/** Resolve display info for a node by its execution ID. */
|
||||
function resolveNodeInfo(nodeId: NodeExecutionId) {
|
||||
function resolveNodeInfo(nodeId: string) {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
|
||||
return {
|
||||
@@ -119,7 +119,7 @@ function getOrCreateGroup(
|
||||
}
|
||||
|
||||
function createErrorCard(
|
||||
nodeId: NodeExecutionId,
|
||||
nodeId: string,
|
||||
classType: string,
|
||||
idPrefix: string
|
||||
): ErrorCardData {
|
||||
@@ -130,6 +130,7 @@ function createErrorCard(
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId),
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
@@ -287,7 +288,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return map
|
||||
})
|
||||
|
||||
function isErrorInSelection(executionNodeId: NodeExecutionId): boolean {
|
||||
function isErrorInSelection(executionNodeId: string): boolean {
|
||||
const nodeIds = selectedNodeInfo.value.nodeIds
|
||||
if (!nodeIds) return true
|
||||
|
||||
@@ -304,7 +305,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
|
||||
function addNodeErrorToGroup(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
nodeId: NodeExecutionId,
|
||||
nodeId: string,
|
||||
classType: string,
|
||||
idPrefix: string,
|
||||
error: CataloguedErrorItem,
|
||||
@@ -370,11 +371,9 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
) {
|
||||
if (!executionErrorStore.lastNodeErrors) return
|
||||
|
||||
for (const [rawNodeId, nodeError] of Object.entries(
|
||||
for (const [nodeId, nodeError] of Object.entries(
|
||||
executionErrorStore.lastNodeErrors
|
||||
)) {
|
||||
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
|
||||
if (!nodeId) continue
|
||||
const nodeDisplayName =
|
||||
resolveNodeInfo(nodeId).title || nodeError.class_type
|
||||
for (const e of nodeError.errors) {
|
||||
@@ -405,12 +404,9 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
if (!executionErrorStore.lastExecutionError) return
|
||||
|
||||
const e = executionErrorStore.lastExecutionError
|
||||
const nodeId = tryNormalizeNodeExecutionId(e.node_id)
|
||||
if (!nodeId) return
|
||||
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
nodeId,
|
||||
String(e.node_id),
|
||||
e.node_type,
|
||||
'exec',
|
||||
{
|
||||
@@ -421,7 +417,8 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
...resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
error: e,
|
||||
nodeDisplayName: resolveNodeInfo(nodeId).title || e.node_type
|
||||
nodeDisplayName:
|
||||
resolveNodeInfo(String(e.node_id)).title || e.node_type
|
||||
})
|
||||
},
|
||||
filterBySelection
|
||||
@@ -672,7 +669,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
]
|
||||
}
|
||||
|
||||
function isAssetErrorInSelection(executionNodeId: NodeExecutionId): boolean {
|
||||
function isAssetErrorInSelection(executionNodeId: string): boolean {
|
||||
const nodeIds = selectedNodeInfo.value.nodeIds
|
||||
if (!nodeIds) return true
|
||||
|
||||
@@ -694,17 +691,12 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return false
|
||||
}
|
||||
|
||||
function isAssetCandidateInSelection(nodeId: string | number): boolean {
|
||||
const executionNodeId = tryNormalizeNodeExecutionId(nodeId)
|
||||
return executionNodeId ? isAssetErrorInSelection(executionNodeId) : false
|
||||
}
|
||||
|
||||
const filteredMissingModelGroups = computed(() => {
|
||||
if (!selectedNodeInfo.value.nodeIds) return missingModelGroups.value
|
||||
const candidates = missingModelStore.missingModelCandidates
|
||||
if (!candidates?.length) return []
|
||||
const filtered = candidates.filter(
|
||||
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
|
||||
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
return groupMissingModelCandidates(filtered, isCloud)
|
||||
@@ -715,7 +707,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
const candidates = missingMediaStore.missingMediaCandidates
|
||||
if (!candidates?.length) return []
|
||||
const filtered = candidates.filter(
|
||||
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
|
||||
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
return groupCandidatesByMediaType(filtered)
|
||||
|
||||
@@ -4,7 +4,6 @@ import { nextTick, ref } from 'vue'
|
||||
import type { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
import type { ErrorCardData } from './types'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { useErrorReport } from './useErrorReport'
|
||||
|
||||
async function flushPromises() {
|
||||
@@ -104,7 +103,7 @@ function makeCard(overrides: Partial<ErrorCardData> = {}): ErrorCardData {
|
||||
return {
|
||||
id: 'card-1',
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([42]),
|
||||
nodeId: '42',
|
||||
errors: [],
|
||||
...overrides
|
||||
}
|
||||
@@ -182,7 +181,7 @@ describe('useErrorReport', () => {
|
||||
exceptionType: 'RuntimeError',
|
||||
exceptionMessage: 'CUDA oom',
|
||||
traceback: 'trace-0',
|
||||
nodeId: createNodeExecutionId([42]),
|
||||
nodeId: '42',
|
||||
nodeType: 'KSampler',
|
||||
systemStats: sampleSystemStats,
|
||||
serverLogs: 'server logs',
|
||||
|
||||
@@ -13,15 +13,19 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const { mockStartDrag, mockHandleNativeDrop } = vi.hoisted(() => ({
|
||||
mockStartDrag: vi.fn(),
|
||||
mockHandleNativeDrop: vi.fn()
|
||||
}))
|
||||
const { mockStartDrag, mockHandleNativeDrop, mockIsPlacingNode } = vi.hoisted(
|
||||
() => ({
|
||||
mockStartDrag: vi.fn(),
|
||||
mockHandleNativeDrop: vi.fn(),
|
||||
mockIsPlacingNode: { value: false }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({
|
||||
startDrag: mockStartDrag,
|
||||
handleNativeDrop: mockHandleNativeDrop
|
||||
handleNativeDrop: mockHandleNativeDrop,
|
||||
isDragging: mockIsPlacingNode
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import { 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,
|
||||
@@ -21,6 +40,7 @@ vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock window.open
|
||||
const originalWindowOpen = window.open
|
||||
beforeEach(() => {
|
||||
window.open = vi.fn()
|
||||
@@ -30,6 +50,7 @@ afterAll(() => {
|
||||
window.open = originalWindowOpen
|
||||
})
|
||||
|
||||
// Mock the useCurrentUser composable
|
||||
const mockHandleSignOut = vi.fn()
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: vi.fn(() => ({
|
||||
@@ -40,50 +61,60 @@ 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
|
||||
}))
|
||||
}))
|
||||
|
||||
function makeSubscription(
|
||||
overrides: Partial<SubscriptionInfo> = {}
|
||||
): SubscriptionInfo {
|
||||
return {
|
||||
isActive: true,
|
||||
tier: 'CREATOR',
|
||||
duration: 'MONTHLY',
|
||||
planSlug: null,
|
||||
renewalDate: null,
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
hasFunds: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
// 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
|
||||
}))
|
||||
|
||||
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
|
||||
const mockFetchBalance = vi.fn().mockResolvedValue(undefined)
|
||||
const mockIsActiveSubscription = ref(true)
|
||||
const mockIsFreeTier = ref(false)
|
||||
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,
|
||||
tier: mockTier,
|
||||
subscription: mockSubscription,
|
||||
balance: mockBalance,
|
||||
isLoading: mockIsLoading,
|
||||
fetchStatus: mockFetchStatus,
|
||||
fetchBalance: mockFetchBalance
|
||||
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 mockIsFreeTier = ref(false)
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: vi.fn(() => ({
|
||||
isActiveSubscription: ref(true),
|
||||
isFreeTier: mockIsFreeTier,
|
||||
subscriptionTierName: ref('Creator'),
|
||||
subscriptionTier: ref('CREATOR'),
|
||||
fetchStatus: mockFetchStatus
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useSubscriptionDialog composable
|
||||
const mockShowPricingTable = vi.fn()
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
@@ -96,6 +127,7 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
// Mock UserAvatar component
|
||||
vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
default: {
|
||||
name: 'UserAvatarMock',
|
||||
@@ -105,10 +137,22 @@ 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}`),
|
||||
@@ -118,12 +162,14 @@ 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() {
|
||||
@@ -132,37 +178,25 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
|
||||
default: defineComponent({
|
||||
default: {
|
||||
name: 'SubscribeButtonMock',
|
||||
emits: ['subscribed'],
|
||||
setup(_, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
'data-testid': 'subscribe-button-mock',
|
||||
onClick: () => emit('subscribed')
|
||||
},
|
||||
'Subscribe Button'
|
||||
)
|
||||
render() {
|
||||
return h('div', 'Subscribe Button')
|
||||
}
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
describe('CurrentUserPopoverLegacy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockIsActiveSubscription.value = true
|
||||
mockIsFreeTier.value = false
|
||||
mockTier.value = 'CREATOR'
|
||||
mockSubscription.value = makeSubscription()
|
||||
mockBalance.value = {
|
||||
amountMicros: 100_000,
|
||||
effectiveBalanceMicros: 100_000,
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 100_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
mockIsLoading.value = false
|
||||
mockAuthStoreState.isFetchingBalance = false
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
@@ -196,47 +230,7 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
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', () => {
|
||||
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(formatCreditsFromCents).toHaveBeenCalledWith({
|
||||
@@ -251,14 +245,6 @@ 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()
|
||||
|
||||
@@ -338,11 +324,11 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
describe('facade balance handling', () => {
|
||||
it('uses effectiveBalanceMicros when present (positive balance)', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 200_000,
|
||||
effectiveBalanceMicros: 150_000,
|
||||
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,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -359,10 +345,10 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('1500')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses effectiveBalanceMicros when zero', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 100_000,
|
||||
effectiveBalanceMicros: 0,
|
||||
it('uses effective_balance_micros when zero', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 0,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -379,10 +365,10 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses effectiveBalanceMicros when negative', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 0,
|
||||
effectiveBalanceMicros: -50_000,
|
||||
it('uses effective_balance_micros when negative', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 0,
|
||||
effective_balance_micros: -50_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -399,9 +385,9 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('-500')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to amountMicros when effectiveBalanceMicros is missing', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 100_000,
|
||||
it('falls back to amount_micros when effective_balance_micros is missing', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -418,8 +404,10 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('1000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to 0 when the facade reports no balance', () => {
|
||||
mockBalance.value = null
|
||||
it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
renderComponent()
|
||||
|
||||
@@ -478,11 +466,8 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
})
|
||||
|
||||
it('hides subscribe button', () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByTestId('subscribe-button-mock')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still shows partner nodes menu item', () => {
|
||||
|
||||
@@ -32,7 +32,12 @@
|
||||
<!-- 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="isLoading" width="4rem" height="1.25rem" class="w-full" />
|
||||
<Skeleton
|
||||
v-if="authStore.isFetchingBalance"
|
||||
width="4rem"
|
||||
height="1.25rem"
|
||||
class="w-full"
|
||||
/>
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
}}</span>
|
||||
@@ -157,15 +162,16 @@ 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 { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
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: []
|
||||
@@ -175,29 +181,25 @@ 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,
|
||||
tier,
|
||||
subscription,
|
||||
balance,
|
||||
isLoading,
|
||||
fetchStatus,
|
||||
fetchBalance
|
||||
} = useBillingContext()
|
||||
const { formatTierName } = useWorkspaceTierLabel()
|
||||
subscriptionTierName,
|
||||
subscriptionTier,
|
||||
fetchStatus
|
||||
} = useSubscription()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
const { locale } = useI18n()
|
||||
|
||||
const subscriptionTierName = computed(() =>
|
||||
formatTierName(tier.value, subscription.value?.duration === 'ANNUAL')
|
||||
)
|
||||
|
||||
const formattedBalance = computed(() => {
|
||||
const cents =
|
||||
balance.value?.effectiveBalanceMicros ?? balance.value?.amountMicros ?? 0
|
||||
authStore.balance?.effective_balance_micros ??
|
||||
authStore.balance?.amount_micros ??
|
||||
0
|
||||
return formatCreditsFromCents({
|
||||
cents,
|
||||
locale: locale.value,
|
||||
@@ -209,12 +211,12 @@ const formattedBalance = computed(() => {
|
||||
})
|
||||
|
||||
const canUpgrade = computed(() => {
|
||||
const currentTier = tier.value
|
||||
const tier = subscriptionTier.value
|
||||
return (
|
||||
currentTier === 'FREE' ||
|
||||
currentTier === 'FOUNDERS_EDITION' ||
|
||||
currentTier === 'STANDARD' ||
|
||||
currentTier === 'CREATOR'
|
||||
tier === 'FREE' ||
|
||||
tier === 'FOUNDERS_EDITION' ||
|
||||
tier === 'STANDARD' ||
|
||||
tier === 'CREATOR'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -268,6 +270,6 @@ const handleSubscribed = async () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void fetchBalance()
|
||||
void authActions.fetchBalance()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -22,8 +22,6 @@ 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'
|
||||
},
|
||||
@@ -56,7 +54,6 @@ const variants = [
|
||||
'destructive-textonly',
|
||||
'link',
|
||||
'base',
|
||||
'tertiary',
|
||||
'overlay-white',
|
||||
'gradient'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
|
||||
@@ -13,8 +13,7 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import {
|
||||
DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
TEAM_PLAN_CREDIT_STOPS,
|
||||
getStopDiscountedMonthlyUsd
|
||||
TEAM_PLAN_CREDIT_STOPS
|
||||
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import type { CreditStop } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
|
||||
@@ -84,7 +83,7 @@ const effectiveDiscountPercent = computed(() =>
|
||||
: current.value.discountPercentYearly
|
||||
)
|
||||
const discountedMonthly = computed(() =>
|
||||
getStopDiscountedMonthlyUsd(current.value, cycle)
|
||||
Math.round(current.value.usd * (1 - effectiveDiscountPercent.value / 100))
|
||||
)
|
||||
const saveAmount = computed(() => current.value.usd - discountedMonthly.value)
|
||||
const hasDiscount = computed(() => effectiveDiscountPercent.value > 0)
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
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, turnstileToken?: string) => {
|
||||
return await authStore.register(email, password, turnstileToken)
|
||||
async (email: string, password: string) => {
|
||||
return await authStore.register(email, password)
|
||||
},
|
||||
reportError
|
||||
)
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
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,9 +7,7 @@ import type {
|
||||
CreateTopupResponse,
|
||||
CurrentTeamCreditStop,
|
||||
Plan,
|
||||
PreviewSubscribeOptions,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeOptions,
|
||||
SubscribeResponse,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier,
|
||||
@@ -23,9 +21,9 @@ export interface SubscriptionInfo {
|
||||
tier: SubscriptionTier | null
|
||||
duration: SubscriptionDuration | null
|
||||
planSlug: string | null
|
||||
/** ISO 8601; format at the display site. */
|
||||
/** ISO 8601 */
|
||||
renewalDate: string | null
|
||||
/** ISO 8601; format at the display site. */
|
||||
/** ISO 8601 */
|
||||
endDate: string | null
|
||||
isCancelled: boolean
|
||||
hasFunds: boolean
|
||||
@@ -45,27 +43,16 @@ export interface BillingActions {
|
||||
fetchBalance: () => Promise<void>
|
||||
subscribe: (
|
||||
planSlug: string,
|
||||
options?: SubscribeOptions
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
) => Promise<SubscribeResponse | void>
|
||||
previewSubscribe: (
|
||||
planSlug: string,
|
||||
options?: PreviewSubscribeOptions
|
||||
planSlug: string
|
||||
) => 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>
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** `amountCents` must be a whole-dollar multiple of 100. */
|
||||
topup: (amountCents: number) => Promise<CreateTopupResponse | void>
|
||||
fetchPlans: () => Promise<void>
|
||||
/**
|
||||
@@ -93,11 +80,8 @@ 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,8 +1,6 @@
|
||||
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
|
||||
@@ -22,14 +20,12 @@ 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,
|
||||
@@ -48,25 +44,15 @@ vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
return {
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
@@ -78,7 +64,7 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
? { id: 'personal-123', type: 'personal' }
|
||||
: { id: 'team-456', type: 'team' }
|
||||
},
|
||||
updateActiveWorkspace: mockUpdateActiveWorkspace
|
||||
updateActiveWorkspace: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -156,28 +142,11 @@ describe('useBillingContext', () => {
|
||||
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
|
||||
})
|
||||
|
||||
it('selects legacy type when team workspaces are disabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
it('returns legacy type for personal workspace', () => {
|
||||
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()
|
||||
|
||||
@@ -237,14 +206,6 @@ 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)
|
||||
@@ -260,42 +221,6 @@ 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,10 +7,6 @@ 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 {
|
||||
@@ -31,11 +27,11 @@ import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspa
|
||||
const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
|
||||
/**
|
||||
* Unified billing context that selects the billing implementation by build/flag.
|
||||
* Unified billing context that automatically switches between legacy (user-scoped)
|
||||
* and workspace billing based on the active workspace type.
|
||||
*
|
||||
* - 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
|
||||
* - Personal workspaces use legacy billing via /customers/* endpoints
|
||||
* - Team workspaces use workspace billing via /billing/* endpoints
|
||||
*
|
||||
* The context automatically initializes when the workspace changes and provides
|
||||
* a unified interface for subscription status, balance, and billing actions.
|
||||
@@ -96,14 +92,16 @@ function useBillingContextInternal(): BillingContext {
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Determines which billing type to use, keyed only on the build/flag:
|
||||
* - Team workspaces feature disabled (OSS/Desktop): legacy (/customers)
|
||||
* - Team workspaces feature enabled: workspace (/api/billing), for both
|
||||
* personal (single-seat workspace) and team workspaces
|
||||
* 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)
|
||||
*/
|
||||
const type = computed<BillingType>(() =>
|
||||
flags.teamWorkspacesEnabled ? 'workspace' : 'legacy'
|
||||
)
|
||||
const type = computed<BillingType>(() => {
|
||||
if (!flags.teamWorkspacesEnabled) return 'legacy'
|
||||
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
|
||||
})
|
||||
|
||||
const activeContext = computed(() =>
|
||||
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
|
||||
@@ -175,7 +173,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
watch(
|
||||
subscription,
|
||||
(sub) => {
|
||||
if (!sub) return
|
||||
if (!sub || store.isInPersonalWorkspace) return
|
||||
|
||||
store.updateActiveWorkspace({
|
||||
isSubscribed: sub.isActive && !sub.isCancelled,
|
||||
@@ -185,28 +183,26 @@ function useBillingContextInternal(): BillingContext {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
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.
|
||||
// Initialize billing when workspace changes
|
||||
watch(
|
||||
[() => store.activeWorkspace?.id, () => type.value],
|
||||
async ([newWorkspaceId]) => {
|
||||
() => store.activeWorkspace?.id,
|
||||
async (newWorkspaceId, oldWorkspaceId) => {
|
||||
if (!newWorkspaceId) {
|
||||
resetBillingState()
|
||||
// No workspace selected - reset state
|
||||
isInitialized.value = false
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
|
||||
isInitialized.value = false
|
||||
try {
|
||||
await initialize()
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize billing context:', err)
|
||||
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)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -237,15 +233,16 @@ function useBillingContextInternal(): BillingContext {
|
||||
return activeContext.value.fetchBalance()
|
||||
}
|
||||
|
||||
async function subscribe(planSlug: string, options?: SubscribeOptions) {
|
||||
return activeContext.value.subscribe(planSlug, options)
|
||||
async function subscribe(
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
) {
|
||||
return activeContext.value.subscribe(planSlug, returnUrl, cancelUrl)
|
||||
}
|
||||
|
||||
async function previewSubscribe(
|
||||
planSlug: string,
|
||||
options?: PreviewSubscribeOptions
|
||||
) {
|
||||
return activeContext.value.previewSubscribe(planSlug, options)
|
||||
async function previewSubscribe(planSlug: string) {
|
||||
return activeContext.value.previewSubscribe(planSlug)
|
||||
}
|
||||
|
||||
async function manageSubscription() {
|
||||
@@ -261,15 +258,6 @@ 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,9 +5,7 @@ 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'
|
||||
@@ -149,15 +147,15 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
|
||||
async function subscribe(
|
||||
_planSlug: string,
|
||||
_options?: SubscribeOptions
|
||||
_returnUrl?: string,
|
||||
_cancelUrl?: string
|
||||
): Promise<SubscribeResponse | void> {
|
||||
// Legacy billing uses Stripe checkout flow via useSubscription
|
||||
await legacySubscribe()
|
||||
}
|
||||
|
||||
async function previewSubscribe(
|
||||
_planSlug: string,
|
||||
_options?: PreviewSubscribeOptions
|
||||
_planSlug: string
|
||||
): Promise<PreviewSubscribeResponse | null> {
|
||||
// Legacy billing doesn't support preview - returns null
|
||||
return null
|
||||
|
||||
@@ -8,6 +8,7 @@ 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()
|
||||
@@ -48,7 +49,23 @@ 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
|
||||
focusNode,
|
||||
enterSubgraph
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
@@ -51,11 +50,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'clip'
|
||||
)
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
|
||||
|
||||
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
|
||||
|
||||
@@ -67,11 +62,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'clip'
|
||||
)
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
|
||||
|
||||
node.onConnectionsChange!(
|
||||
NodeSlotType.INPUT,
|
||||
@@ -90,11 +81,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'clip'
|
||||
)
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
|
||||
|
||||
node.onConnectionsChange!(
|
||||
NodeSlotType.OUTPUT,
|
||||
@@ -116,11 +103,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'model'
|
||||
)
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'model')
|
||||
|
||||
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
|
||||
|
||||
@@ -246,11 +229,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
const mediaStore = useMissingMediaStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'image'
|
||||
)
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'image')
|
||||
mediaStore.setMissingMedia([
|
||||
{
|
||||
nodeId: String(node.id),
|
||||
@@ -300,11 +279,7 @@ describe('installErrorClearingHooks lifecycle', () => {
|
||||
// Verify the hooks actually work
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([lateNode.id]),
|
||||
'value'
|
||||
)
|
||||
seedRequiredInputMissingNodeError(store, String(lateNode.id), 'value')
|
||||
|
||||
lateNode.onConnectionsChange!(
|
||||
NodeSlotType.INPUT,
|
||||
|
||||
@@ -34,7 +34,6 @@ import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacem
|
||||
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { appendNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import {
|
||||
collectAllNodes,
|
||||
@@ -84,7 +83,7 @@ function installNodeHooks(node: LGraphNode): void {
|
||||
|
||||
const promotedSource = widgetPromotedSource(node, widget)
|
||||
const executionId = promotedSource
|
||||
? appendNodeExecutionId(hostExecId, promotedSource.nodeId)
|
||||
? `${hostExecId}:${promotedSource.nodeId}`
|
||||
: hostExecId
|
||||
const widgetName = promotedSource?.widgetName ?? widget.name
|
||||
|
||||
|
||||
@@ -703,55 +703,3 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
|
||||
expect(subgraphNode.has_errors).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pre-remove vueNodeData drain', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('drops vueNodeData entry before node.onRemoved fires', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
expect(vueNodeData.has(String(node.id))).toBe(true)
|
||||
|
||||
let dataPresentInOnRemoved: boolean | undefined
|
||||
node.onRemoved = () => {
|
||||
dataPresentInOnRemoved = vueNodeData.has(String(node.id))
|
||||
}
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(
|
||||
dataPresentInOnRemoved,
|
||||
'vueNodeData entry must be cleared before node.onRemoved fires so reactive consumers cannot observe the detached node'
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('clears vueNodeData when LGraph.clear() dispatches node:before-removed for each node', () => {
|
||||
const graph = new LGraph()
|
||||
const nodeA = new LGraphNode('a')
|
||||
const nodeB = new LGraphNode('b')
|
||||
graph.add(nodeA)
|
||||
graph.add(nodeB)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
expect(vueNodeData.size).toBe(2)
|
||||
|
||||
const beforeRemovedSpy = vi.fn()
|
||||
graph.events.addEventListener('node:before-removed', beforeRemovedSpy)
|
||||
|
||||
graph.clear()
|
||||
|
||||
expect(
|
||||
beforeRemovedSpy,
|
||||
'clear() must dispatch node:before-removed so reactive consumers can drop refs before nodes detach'
|
||||
).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
vueNodeData.size,
|
||||
'node:before-removed listener must drain vueNodeData when clear() removes every node'
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,8 +30,6 @@ import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
|
||||
import { normalizeControlOption } from '@/types/simplifiedWidget'
|
||||
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
|
||||
import type { NodeId as WorkflowNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
import type {
|
||||
@@ -84,7 +82,6 @@ export interface SafeWidgetData {
|
||||
advanced?: boolean
|
||||
hidden?: boolean
|
||||
read_only?: boolean
|
||||
removable?: boolean
|
||||
values?: unknown
|
||||
}
|
||||
/** Input specification from node definition */
|
||||
@@ -97,7 +94,7 @@ export interface SafeWidgetData {
|
||||
* host subgraph node. Used for missing-model lookups that key by
|
||||
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
|
||||
*/
|
||||
sourceExecutionId?: NodeExecutionId
|
||||
sourceExecutionId?: string
|
||||
/**
|
||||
* Interior source widget name. Only set for promoted widgets, where `name`
|
||||
* is the host input slot name; missing-model lookups key by the interior
|
||||
@@ -140,7 +137,7 @@ export interface GraphNodeManager {
|
||||
vueNodeData: ReadonlyMap<string, VueNodeData>
|
||||
|
||||
// Access to original LiteGraph nodes (non-reactive)
|
||||
getNode(id: WorkflowNodeId): LGraphNode | undefined
|
||||
getNode(id: string): LGraphNode | undefined
|
||||
|
||||
// Lifecycle methods
|
||||
cleanup(): void
|
||||
@@ -214,8 +211,7 @@ function extractWidgetDisplayOptions(
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only,
|
||||
removable: widget.options.removable
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +225,7 @@ function isDOMBackedWidget(widget: IBaseWidget): boolean {
|
||||
interface PromotedWidgetMetadata {
|
||||
controlWidget?: SafeControlWidget
|
||||
isDOMWidget: boolean
|
||||
sourceExecutionId?: NodeExecutionId
|
||||
sourceExecutionId?: string
|
||||
sourceWidgetName?: string
|
||||
}
|
||||
|
||||
@@ -520,8 +516,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
}
|
||||
|
||||
// Get access to original LiteGraph node (non-reactive)
|
||||
const getNode = (id: WorkflowNodeId): LGraphNode | undefined => {
|
||||
return nodeRefs.get(String(id))
|
||||
const getNode = (id: string): LGraphNode | undefined => {
|
||||
return nodeRefs.get(id)
|
||||
}
|
||||
|
||||
const syncWithGraph = () => {
|
||||
@@ -612,20 +608,27 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
}
|
||||
}
|
||||
|
||||
const dropNodeReferences = (node: LGraphNode) => {
|
||||
const id = String(node.id)
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles node removal from the graph - cleans up all references
|
||||
*/
|
||||
const handleNodeRemoved = (
|
||||
node: LGraphNode,
|
||||
originalCallback?: (node: LGraphNode) => void
|
||||
) => {
|
||||
const id = String(node.id)
|
||||
|
||||
// Remove node from layout store
|
||||
setSource(LayoutSource.Canvas)
|
||||
void deleteNode(id)
|
||||
originalCallback?.(node)
|
||||
|
||||
// Clean up all tracking references
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
|
||||
// Call original callback if provided
|
||||
if (originalCallback) {
|
||||
originalCallback(node)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -634,8 +637,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
const createCleanupFunction = (
|
||||
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
|
||||
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
|
||||
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined,
|
||||
beforeNodeRemovedListener: (e: CustomEvent<{ node: LGraphNode }>) => void
|
||||
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined
|
||||
) => {
|
||||
return () => {
|
||||
// Restore original callbacks
|
||||
@@ -643,17 +645,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
graph.onNodeRemoved = originalOnNodeRemoved || undefined
|
||||
graph.onTrigger = originalOnTrigger || undefined
|
||||
|
||||
graph.events.removeEventListener(
|
||||
'node:before-removed',
|
||||
beforeNodeRemovedListener
|
||||
)
|
||||
|
||||
// Clear all state maps
|
||||
nodeRefs.clear()
|
||||
vueNodeData.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event listeners - now simplified with extracted handlers
|
||||
*/
|
||||
const setupEventListeners = (): (() => void) => {
|
||||
// Store original callbacks
|
||||
const originalOnNodeAdded = graph.onNodeAdded
|
||||
@@ -669,16 +669,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
handleNodeRemoved(node, originalOnNodeRemoved)
|
||||
}
|
||||
|
||||
const beforeNodeRemovedListener = (
|
||||
e: CustomEvent<{ node: LGraphNode }>
|
||||
) => {
|
||||
dropNodeReferences(e.detail.node)
|
||||
}
|
||||
graph.events.addEventListener(
|
||||
'node:before-removed',
|
||||
beforeNodeRemovedListener
|
||||
)
|
||||
|
||||
const triggerHandlers: {
|
||||
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
|
||||
} = {
|
||||
@@ -827,11 +817,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Initialize state
|
||||
syncWithGraph()
|
||||
|
||||
// Return cleanup function
|
||||
return createCleanupFunction(
|
||||
originalOnNodeAdded || undefined,
|
||||
originalOnNodeRemoved || undefined,
|
||||
originalOnTrigger || undefined,
|
||||
beforeNodeRemovedListener
|
||||
originalOnTrigger || undefined
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Mock } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type { useNodeDragToCanvas as UseNodeDragToCanvasType } from './useNodeDragToCanvas'
|
||||
@@ -7,30 +8,33 @@ const {
|
||||
mockAddNodeOnGraph,
|
||||
mockConvertEventToCanvasOffset,
|
||||
mockSelectItems,
|
||||
mockCanvas,
|
||||
mockToastAdd
|
||||
mockCanvasStore,
|
||||
mockToastAdd,
|
||||
canvasElement
|
||||
} = vi.hoisted(() => {
|
||||
const canvasElement = document.createElement(
|
||||
'canvas'
|
||||
) as HTMLCanvasElement & { getBoundingClientRect: Mock }
|
||||
canvasElement.getBoundingClientRect = vi.fn()
|
||||
const mockConvertEventToCanvasOffset = vi.fn()
|
||||
const mockSelectItems = vi.fn()
|
||||
const mockCanvas = {
|
||||
canvas: canvasElement,
|
||||
convertEventToCanvasOffset: mockConvertEventToCanvasOffset,
|
||||
selectItems: mockSelectItems
|
||||
}
|
||||
return {
|
||||
mockAddNodeOnGraph: vi.fn(),
|
||||
mockConvertEventToCanvasOffset,
|
||||
mockSelectItems,
|
||||
mockToastAdd: vi.fn(),
|
||||
mockCanvas: {
|
||||
canvas: {
|
||||
getBoundingClientRect: vi.fn()
|
||||
},
|
||||
convertEventToCanvasOffset: mockConvertEventToCanvasOffset,
|
||||
selectItems: mockSelectItems
|
||||
}
|
||||
canvasElement,
|
||||
mockCanvasStore: { canvas: mockCanvas, isGhostPlacing: false }
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn(() => ({
|
||||
canvas: mockCanvas
|
||||
}))
|
||||
useCanvasStore: vi.fn(() => mockCanvasStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
@@ -45,8 +49,11 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
|
||||
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
|
||||
|
||||
const CANVAS_RECT = { left: 0, right: 500, top: 0, bottom: 500 }
|
||||
|
||||
describe('useNodeDragToCanvas', () => {
|
||||
let useNodeDragToCanvas: typeof UseNodeDragToCanvasType
|
||||
let panelElement: HTMLElement
|
||||
|
||||
const mockNodeDef = {
|
||||
name: 'TestNode',
|
||||
@@ -57,6 +64,11 @@ describe('useNodeDragToCanvas', () => {
|
||||
vi.resetModules()
|
||||
vi.resetAllMocks()
|
||||
|
||||
document.body.appendChild(canvasElement)
|
||||
panelElement = document.createElement('div')
|
||||
document.body.appendChild(panelElement)
|
||||
mockCanvasStore.isGhostPlacing = false
|
||||
|
||||
const module = await import('./useNodeDragToCanvas')
|
||||
useNodeDragToCanvas = module.useNodeDragToCanvas
|
||||
})
|
||||
@@ -64,9 +76,24 @@ describe('useNodeDragToCanvas', () => {
|
||||
afterEach(() => {
|
||||
const { cancelDrag } = useNodeDragToCanvas()
|
||||
cancelDrag()
|
||||
canvasElement.remove()
|
||||
panelElement.remove()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// The canvas is full-bleed under the sidebar/properties panels, so the click
|
||||
// path commits based on the event target rather than geometry. Dispatch on the
|
||||
// real element so `isCanvasTarget` (canvas.contains(target)) behaves as in the app.
|
||||
function dispatchPointerUp(
|
||||
x: number,
|
||||
y: number,
|
||||
target: EventTarget = canvasElement
|
||||
) {
|
||||
target.dispatchEvent(
|
||||
new PointerEvent('pointerup', { clientX: x, clientY: y, bubbles: true })
|
||||
)
|
||||
}
|
||||
|
||||
describe('startDrag', () => {
|
||||
it('should set isDragging to true and store the node definition', () => {
|
||||
const { isDragging, draggedNode, startDrag } = useNodeDragToCanvas()
|
||||
@@ -96,6 +123,27 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ghost placement flag', () => {
|
||||
it('should mark ghost placement active for the duration of the drag', () => {
|
||||
const { startDrag, cancelDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
expect(mockCanvasStore.isGhostPlacing).toBe(true)
|
||||
|
||||
cancelDrag()
|
||||
expect(mockCanvasStore.isGhostPlacing).toBe(false)
|
||||
})
|
||||
|
||||
it('should not clear ghost placement when cancelling without a drag', () => {
|
||||
mockCanvasStore.isGhostPlacing = true
|
||||
const { cancelDrag } = useNodeDragToCanvas()
|
||||
|
||||
cancelDrag()
|
||||
|
||||
expect(mockCanvasStore.isGhostPlacing).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('drag listener lifecycle', () => {
|
||||
it('should attach document listeners on startDrag', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
|
||||
@@ -167,47 +215,42 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
describe('endDrag behavior', () => {
|
||||
it('should add node when pointer is over canvas', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
it('should add node when released over the canvas', () => {
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
document.dispatchEvent(pointerEvent)
|
||||
dispatchPointerUp(250, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
|
||||
pos: [150, 150]
|
||||
})
|
||||
})
|
||||
|
||||
it('should not add node when pointer is outside canvas', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
it('should not add node when released outside the canvas', () => {
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
clientX: 600,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
document.dispatchEvent(pointerEvent)
|
||||
dispatchPointerUp(600, 250, panelElement)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
expect(isDragging.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should not add node when released over a panel within canvas bounds', () => {
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
// FE-688: the panel overlays the full-bleed canvas, so a release at a
|
||||
// point inside the canvas rect but on the panel must not place a hidden
|
||||
// node behind the panel.
|
||||
dispatchPointerUp(250, 250, panelElement)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
expect(isDragging.value).toBe(false)
|
||||
@@ -236,12 +279,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should select the placed node when one is returned from the graph', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
const placedNode = { id: 1 }
|
||||
mockAddNodeOnGraph.mockReturnValue(placedNode)
|
||||
@@ -249,24 +287,13 @@ describe('useNodeDragToCanvas', () => {
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
dispatchPointerUp(250, 250)
|
||||
|
||||
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
|
||||
})
|
||||
|
||||
it('should apply the requested widget values to the placed node', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
const widget = { name: 'ckpt_name', value: '' }
|
||||
mockAddNodeOnGraph.mockReturnValue({ id: 1, widgets: [widget] })
|
||||
@@ -276,24 +303,13 @@ describe('useNodeDragToCanvas', () => {
|
||||
widgetValues: { ckpt_name: 'model.safetensors' }
|
||||
})
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
dispatchPointerUp(250, 250)
|
||||
|
||||
expect(widget.value).toBe('model.safetensors')
|
||||
})
|
||||
|
||||
it('should warn but still place the node when a requested widget is missing', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
const placedNode = { id: 1, widgets: [] }
|
||||
mockAddNodeOnGraph.mockReturnValue(placedNode)
|
||||
@@ -306,13 +322,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
widgetValues: { ckpt_name: 'model.safetensors' }
|
||||
})
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
dispatchPointerUp(250, 250)
|
||||
|
||||
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
@@ -327,12 +337,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should show an error toast when the graph fails to add the node', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
mockAddNodeOnGraph.mockReturnValue(null)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
@@ -340,13 +345,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
dispatchPointerUp(250, 250)
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -357,12 +356,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should not call selectItems when graph returns no node', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
mockAddNodeOnGraph.mockReturnValue(null)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
@@ -370,35 +364,19 @@ describe('useNodeDragToCanvas', () => {
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
dispatchPointerUp(250, 250)
|
||||
|
||||
expect(mockSelectItems).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not add node on pointerup when in native drag mode', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
document.dispatchEvent(pointerEvent)
|
||||
dispatchPointerUp(250, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
expect(isDragging.value).toBe(true)
|
||||
@@ -407,12 +385,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
|
||||
describe('handleNativeDrop', () => {
|
||||
it('should add node when drop position is over canvas', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
|
||||
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
@@ -426,12 +399,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should not add node when drop position is outside canvas', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
|
||||
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
|
||||
|
||||
@@ -443,12 +411,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should not add node when dragMode is click', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
|
||||
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
@@ -460,12 +423,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should reset drag state after drop', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
|
||||
|
||||
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
|
||||
@@ -478,7 +436,11 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
describe('blockCommitPointerDown', () => {
|
||||
function dispatchPointerDown(x: number, y: number) {
|
||||
function dispatchPointerDown(
|
||||
x: number,
|
||||
y: number,
|
||||
target: EventTarget = canvasElement
|
||||
) {
|
||||
const event = new PointerEvent('pointerdown', {
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
@@ -486,17 +448,12 @@ describe('useNodeDragToCanvas', () => {
|
||||
cancelable: true
|
||||
})
|
||||
const stopSpy = vi.spyOn(event, 'stopImmediatePropagation')
|
||||
document.dispatchEvent(event)
|
||||
target.dispatchEvent(event)
|
||||
return stopSpy
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
})
|
||||
|
||||
it('should stop propagation when in click-drag mode over canvas', () => {
|
||||
@@ -521,22 +478,17 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not stop propagation when pointer is outside canvas', () => {
|
||||
it('should not stop propagation when pointer is over a panel', () => {
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(dispatchPointerDown(600, 250)).not.toHaveBeenCalled()
|
||||
expect(dispatchPointerDown(250, 250, panelElement)).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('native drag position tracking', () => {
|
||||
beforeEach(() => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([300, 300])
|
||||
})
|
||||
|
||||
|
||||
@@ -66,6 +66,17 @@ function isOverCanvas(clientX: number, clientY: number): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
// The canvas is full-bleed and sidebar/properties panels are pointer-events-auto
|
||||
// overlays painted on top of it, so a point inside the canvas rect can still be
|
||||
// over a panel. Hit-test the actual event target instead, mirroring how native
|
||||
// drag treats the canvas as its only drop target: releasing over a panel cancels.
|
||||
function isCanvasTarget(target: EventTarget | null): boolean {
|
||||
const canvasElement = useCanvasStore().canvas?.canvas
|
||||
return (
|
||||
!!canvasElement && target instanceof Node && canvasElement.contains(target)
|
||||
)
|
||||
}
|
||||
|
||||
function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
const nodeDef = draggedNode.value
|
||||
if (!nodeDef) return false
|
||||
@@ -101,7 +112,7 @@ function endDrag(e: PointerEvent) {
|
||||
if (dragMode.value !== 'click') return
|
||||
|
||||
try {
|
||||
addNodeAtPosition(e.clientX, e.clientY)
|
||||
if (isCanvasTarget(e.target)) addNodeAtPosition(e.clientX, e.clientY)
|
||||
} finally {
|
||||
cancelDrag()
|
||||
}
|
||||
@@ -114,7 +125,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
// Prevent LiteGraph's empty-canvas hit-test from deselecting the placed node on pointerup.
|
||||
function blockCommitPointerDown(e: PointerEvent) {
|
||||
if (!isDragging.value || dragMode.value !== 'click') return
|
||||
if (!isOverCanvas(e.clientX, e.clientY)) return
|
||||
if (!isCanvasTarget(e.target)) return
|
||||
e.stopImmediatePropagation()
|
||||
}
|
||||
|
||||
@@ -139,6 +150,7 @@ function cleanupGlobalListeners() {
|
||||
}
|
||||
|
||||
function cancelDrag() {
|
||||
if (isDragging.value) useCanvasStore().isGhostPlacing = false
|
||||
isDragging.value = false
|
||||
draggedNode.value = null
|
||||
dragMode.value = 'click'
|
||||
@@ -162,6 +174,10 @@ export function useNodeDragToCanvas() {
|
||||
dragMode.value = mode
|
||||
pendingWidgetValues.value = widgetValues
|
||||
pendingSource.value = source
|
||||
// Reuse the litegraph ghost-placement flag: Vue nodes render inert while
|
||||
// it is set, so the release hit-tests the canvas instead of an existing
|
||||
// node's DOM and placement over occupied areas isn't silently cancelled.
|
||||
useCanvasStore().isGhostPlacing = true
|
||||
setupGlobalListeners()
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ import { useNodePreviewAndDrag } from './useNodePreviewAndDrag'
|
||||
|
||||
const mockStartDrag = vi.fn()
|
||||
const mockHandleNativeDrop = vi.fn()
|
||||
const mockIsPlacingNode = ref(false)
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({
|
||||
startDrag: mockStartDrag,
|
||||
handleNativeDrop: mockHandleNativeDrop
|
||||
handleNativeDrop: mockHandleNativeDrop,
|
||||
isDragging: mockIsPlacingNode
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -29,6 +31,7 @@ describe('useNodePreviewAndDrag', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsPlacingNode.value = false
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
@@ -52,6 +55,17 @@ describe('useNodePreviewAndDrag', () => {
|
||||
result.isDragging.value = true
|
||||
expect(result.showPreview.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should hide preview while a node is being placed elsewhere', () => {
|
||||
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
|
||||
const result = useNodePreviewAndDrag(nodeDef)
|
||||
|
||||
result.isHovered.value = true
|
||||
expect(result.showPreview.value).toBe(true)
|
||||
|
||||
mockIsPlacingNode.value = true
|
||||
expect(result.showPreview.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleMouseEnter', () => {
|
||||
|
||||
@@ -25,7 +25,11 @@ export function useNodePreviewAndDrag(
|
||||
nodeDef: Ref<ComfyNodeDefImpl | undefined>,
|
||||
panelRef?: Ref<HTMLElement | null>
|
||||
): UseNodePreviewAndDragReturn {
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
const {
|
||||
startDrag,
|
||||
handleNativeDrop,
|
||||
isDragging: isPlacingNode
|
||||
} = useNodeDragToCanvas()
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
@@ -34,7 +38,11 @@ export function useNodePreviewAndDrag(
|
||||
const previewRef = ref<HTMLElement | null>(null)
|
||||
const isHovered = ref(false)
|
||||
const isDragging = ref(false)
|
||||
const showPreview = computed(() => isHovered.value && !isDragging.value)
|
||||
// Hide the hover preview while a node is being placed (click or drag) so it
|
||||
// doesn't compete with the cursor-following placement preview.
|
||||
const showPreview = computed(
|
||||
() => isHovered.value && !isDragging.value && !isPlacingNode.value
|
||||
)
|
||||
|
||||
const nodePreviewStyle = ref<CSSProperties>({
|
||||
position: 'fixed',
|
||||
|
||||
@@ -137,18 +137,6 @@ describe(usePromotedPreviews, () => {
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array (does not throw) when SubgraphNode is detached', () => {
|
||||
const setup = createSetup()
|
||||
const parentGraph = setup.subgraphNode.graph!
|
||||
parentGraph.add(setup.subgraphNode)
|
||||
parentGraph.remove(setup.subgraphNode)
|
||||
|
||||
expect(setup.subgraphNode.graph).toBeNull()
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(() => promotedPreviews.value).not.toThrow()
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when no $$ promotions exist', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10 })
|
||||
|
||||
@@ -6,11 +6,7 @@ import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import {
|
||||
appendNodeExecutionId,
|
||||
createNodeLocatorId
|
||||
} from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
interface PromotedPreview {
|
||||
sourceNodeId: string
|
||||
@@ -42,7 +38,7 @@ export function usePromotedPreviews(
|
||||
function readReactivePreviewUrls(
|
||||
leafHost: SubgraphNode,
|
||||
leafSourceNodeId: string,
|
||||
leafExecutionId: NodeExecutionId,
|
||||
leafExecutionId: string,
|
||||
interiorNode: LGraphNode
|
||||
): string[] | undefined {
|
||||
const locatorId = createNodeLocatorId(
|
||||
@@ -72,7 +68,6 @@ export function usePromotedPreviews(
|
||||
const promotedPreviews = computed((): PromotedPreview[] => {
|
||||
const node = toValue(lgraphNode)
|
||||
if (!(node instanceof SubgraphNode)) return []
|
||||
if (node.isDetached) return []
|
||||
|
||||
const rootGraphId = node.rootGraph.id
|
||||
const hostLocator = String(node.id)
|
||||
@@ -126,7 +121,7 @@ export function usePromotedPreviews(
|
||||
const urls = readReactivePreviewUrls(
|
||||
leafHost,
|
||||
leaf.sourceNodeId,
|
||||
appendNodeExecutionId(leafHostLocator, leaf.sourceNodeId),
|
||||
`${leafHostLocator}:${leaf.sourceNodeId}`,
|
||||
interiorNode
|
||||
)
|
||||
if (!urls?.length) return []
|
||||
|
||||
@@ -221,39 +221,6 @@ 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,8 +29,7 @@ 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',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile'
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,13 +173,6 @@ export function useFeatureFlags() {
|
||||
remoteConfig.value.unified_cloud_auth,
|
||||
false
|
||||
)
|
||||
},
|
||||
get signupTurnstileMode() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.SIGNUP_TURNSTILE,
|
||||
remoteConfig.value.signup_turnstile,
|
||||
'off'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -8,12 +8,12 @@ export function useWorkflowStatusDismissal() {
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
watch(
|
||||
() => {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
return [workflow, executionStore.getWorkflowStatus(workflow)] as const
|
||||
},
|
||||
([workflow, status]) => {
|
||||
if (workflow && status !== undefined && status !== 'running') {
|
||||
() => workflowStore.activeWorkflow,
|
||||
(workflow) => {
|
||||
if (
|
||||
workflow &&
|
||||
executionStore.getWorkflowStatus(workflow) !== 'running'
|
||||
) {
|
||||
executionStore.clearWorkflowStatus(workflow)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -522,22 +522,6 @@ describe('hasUnpromotedWidgets', () => {
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false (does not throw) when SubgraphNode is detached', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const parentGraph = subgraphNode.graph!
|
||||
parentGraph.add(subgraphNode)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
|
||||
parentGraph.remove(subgraphNode)
|
||||
|
||||
expect(subgraphNode.graph).toBeNull()
|
||||
expect(() => hasUnpromotedWidgets(subgraphNode)).not.toThrow()
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLinkedPromotion', () => {
|
||||
|
||||
@@ -633,7 +633,6 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
}
|
||||
|
||||
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
|
||||
if (subgraphNode.isDetached) return false
|
||||
const { subgraph } = subgraphNode
|
||||
|
||||
return subgraph.nodes.some((interiorNode) =>
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import {
|
||||
zAutogrowOptions,
|
||||
zDynamicGroupInputSpec,
|
||||
zMatchTypeOptions
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { zAutogrowOptions, zMatchTypeOptions } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
@@ -12,7 +8,6 @@ const dynamicTypeResolvers: Record<
|
||||
(inputSpec: InputSpecV2) => string[]
|
||||
> = {
|
||||
COMFY_AUTOGROW_V3: resolveAutogrowType,
|
||||
COMFY_DYNAMICGROUP_V3: resolveDynamicGroupType,
|
||||
COMFY_MATCHTYPE_V3: (input) =>
|
||||
zMatchTypeOptions
|
||||
.safeParse(input)
|
||||
@@ -25,21 +20,6 @@ export function resolveInputType(input: InputSpecV2): string[] {
|
||||
: input.type.split(',')
|
||||
}
|
||||
|
||||
function resolveDynamicGroupType(rawSpec: InputSpecV2): string[] {
|
||||
const parsed = zDynamicGroupInputSpec.safeParse([rawSpec.type, rawSpec])
|
||||
const template = parsed.data?.[1]?.template
|
||||
if (!template) return []
|
||||
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
|
||||
template.required,
|
||||
template.optional
|
||||
]
|
||||
return inputTypes.flatMap((inputType) =>
|
||||
Object.entries(inputType ?? {}).flatMap(([name, v]) =>
|
||||
resolveInputType(transformInputSpecV1ToV2(v, { name }))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveAutogrowType(rawSpec: InputSpecV2): string[] {
|
||||
const { input } = zAutogrowOptions.safeParse(rawSpec).data?.template ?? {}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -49,22 +47,6 @@ function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
|
||||
transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false })
|
||||
)
|
||||
}
|
||||
function addDynamicGroup(
|
||||
node: LGraphNode,
|
||||
template: object,
|
||||
{ min, max, name = 'g' }: { min?: number; max?: number; name?: string } = {}
|
||||
) {
|
||||
const options: Record<string, unknown> = { template }
|
||||
if (min !== undefined) options.min = min
|
||||
if (max !== undefined) options.max = max
|
||||
addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(['COMFY_DYNAMICGROUP_V3', options] as InputSpec, {
|
||||
name,
|
||||
isOptional: false
|
||||
})
|
||||
)
|
||||
}
|
||||
function addAutogrow(node: LGraphNode, template: unknown) {
|
||||
addNodeInput(
|
||||
node,
|
||||
@@ -305,101 +287,3 @@ describe('Autogrow', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
describe('Dynamic Groups', () => {
|
||||
const stringTemplate = { required: { a: ['STRING', {}] } }
|
||||
const widgetNames = (node: LGraphNode) => node.widgets!.map((w) => w.name)
|
||||
const inputNames = (node: LGraphNode) => node.inputs.map((i) => i.name)
|
||||
const widgetNamed = (node: LGraphNode, name: string) =>
|
||||
node.widgets!.find((w) => w.name === name)!
|
||||
|
||||
test('renders min rows on creation', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 2, max: 5 })
|
||||
expect(widgetNames(node)).toStrictEqual([
|
||||
'g',
|
||||
'g.__row__0',
|
||||
'g.0.a',
|
||||
'g.__row__1',
|
||||
'g.1.a'
|
||||
])
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('add row appends a new row up to max', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 2 })
|
||||
expect(widgetNames(node)).toStrictEqual(['g'])
|
||||
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a'])
|
||||
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
|
||||
// At max, further adds are ignored.
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('remove row renumbers later rows', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
|
||||
const row0Field = widgetNamed(node, 'g.0.a')
|
||||
const row2Field = widgetNamed(node, 'g.2.a')
|
||||
|
||||
widgetNamed(node, 'g.__row__1').callback?.(undefined)
|
||||
|
||||
expect(widgetNames(node)).toStrictEqual([
|
||||
'g',
|
||||
'g.__row__0',
|
||||
'g.0.a',
|
||||
'g.__row__1',
|
||||
'g.1.a'
|
||||
])
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
// Row 0 is untouched; the former row 2 shifts down into row 1.
|
||||
expect(widgetNamed(node, 'g.0.a')).toBe(row0Field)
|
||||
expect(widgetNamed(node, 'g.1.a')).toBe(row2Field)
|
||||
})
|
||||
|
||||
test('rows below min are not removable', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 1, max: 5 })
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
|
||||
expect(widgetNamed(node, 'g.__row__0').options?.removable).toBe(false)
|
||||
expect(widgetNamed(node, 'g.__row__1').options?.removable).toBe(true)
|
||||
|
||||
// Attempting to remove a protected row is a no-op.
|
||||
widgetNamed(node, 'g.__row__0').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('canvas click removes a row only on the remove hit target', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
|
||||
const header = widgetNamed(node, 'g.__row__1')
|
||||
const up = { type: 'pointerup' } as CanvasPointerEvent
|
||||
const down = { type: 'pointerdown' } as CanvasPointerEvent
|
||||
const xCenter = node.size[0] - 15 - LiteGraph.NODE_WIDGET_HEIGHT * 0.5
|
||||
|
||||
// Releasing away from the remove target does nothing.
|
||||
header.mouse?.(up, [0, 0] as Point, node)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
|
||||
// A pointerdown on the target does nothing (only release acts).
|
||||
header.mouse?.(down, [xCenter, 0] as Point, node)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
|
||||
// Releasing on the target removes the row.
|
||||
header.mouse?.(up, [xCenter, 0] as Point, node)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,12 +2,10 @@ import { remove } from 'es-toolkit'
|
||||
import { shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
ISlotType,
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
Point
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -15,14 +13,11 @@ import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import { commonType } from '@/lib/litegraph/src/utils/type'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
zAutogrowOptions,
|
||||
zDynamicComboInputSpec,
|
||||
zDynamicGroupInputSpec,
|
||||
zMatchTypeOptions
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -33,15 +28,6 @@ import { widgetId } from '@/types/widgetId'
|
||||
|
||||
const INLINE_INPUTS = false
|
||||
|
||||
type DynamicGroupState = {
|
||||
min: number
|
||||
max: number
|
||||
inputSpecs: InputSpecV2[]
|
||||
}
|
||||
type DynamicGroupNode = LGraphNode & {
|
||||
comfyDynamic: { dynamicGroup: Record<string, DynamicGroupState> }
|
||||
}
|
||||
|
||||
type MatchTypeNode = LGraphNode &
|
||||
Pick<Required<LGraphNode>, 'onConnectionsChange'> & {
|
||||
comfyDynamic: { matchType: Record<string, Record<string, string>> }
|
||||
@@ -91,7 +77,6 @@ function dynamicComboWidget(
|
||||
widgetName?: string
|
||||
) {
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
const { deleteWidget } = useWidgetValueStore()
|
||||
const parseResult = zDynamicComboInputSpec.safeParse(untypedInputData)
|
||||
if (!parseResult.success) throw new Error('invalid DynamicCombo spec')
|
||||
const inputData = parseResult.data
|
||||
@@ -114,10 +99,7 @@ function dynamicComboWidget(
|
||||
const newSpec = value ? options[value] : undefined
|
||||
|
||||
const removedInputs = remove(node.inputs, isInGroup)
|
||||
for (const widget of remove(node.widgets, isInGroup)) {
|
||||
widget.onRemove?.()
|
||||
if (widget.widgetId) deleteWidget(widget.widgetId)
|
||||
}
|
||||
for (const widget of remove(node.widgets, isInGroup)) widget.onRemove?.()
|
||||
|
||||
if (!newSpec) return
|
||||
|
||||
@@ -228,321 +210,7 @@ function dynamicComboWidget(
|
||||
return { widget, minWidth, minHeight }
|
||||
}
|
||||
|
||||
function withComfyDynamicGroup(
|
||||
node: LGraphNode
|
||||
): asserts node is DynamicGroupNode {
|
||||
if (node.comfyDynamic?.dynamicGroup) return
|
||||
node.comfyDynamic ??= {}
|
||||
node.comfyDynamic.dynamicGroup = {}
|
||||
}
|
||||
|
||||
const ROW_MARKER = '__row__'
|
||||
const rowHeaderName = (group: string, row: number) =>
|
||||
`${group}.${ROW_MARKER}${row}`
|
||||
const fieldName = (group: string, row: number, field: string) =>
|
||||
`${group}.${row}.${field}`
|
||||
|
||||
/** Extract the row index from a header widget name, or `undefined`. */
|
||||
function headerRowIndex(group: string, name: string): number | undefined {
|
||||
const prefix = `${group}.${ROW_MARKER}`
|
||||
if (!name.startsWith(prefix)) return undefined
|
||||
const row = Number(name.slice(prefix.length))
|
||||
return Number.isInteger(row) ? row : undefined
|
||||
}
|
||||
|
||||
/** Rename a field that sits above the removed row, shifting its index down. */
|
||||
function shiftedFieldName(
|
||||
group: string,
|
||||
name: string,
|
||||
removedRow: number
|
||||
): string | undefined {
|
||||
const prefix = `${group}.`
|
||||
if (!name.startsWith(prefix)) return undefined
|
||||
const rest = name.slice(prefix.length)
|
||||
const dot = rest.indexOf('.')
|
||||
if (dot === -1) return undefined
|
||||
const row = Number(rest.slice(0, dot))
|
||||
if (!Number.isInteger(row) || row <= removedRow) return undefined
|
||||
return fieldName(group, row - 1, rest.slice(dot + 1))
|
||||
}
|
||||
|
||||
const belongsToRow = (group: string, name: string, row: number): boolean =>
|
||||
name === rowHeaderName(group, row) || name.startsWith(`${group}.${row}.`)
|
||||
|
||||
const CANVAS_MARGIN = 15
|
||||
|
||||
/** Draw the "Add row" capsule button on the LiteGraph canvas. */
|
||||
function drawGroupButton(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
y: number,
|
||||
label: string,
|
||||
disabled: boolean
|
||||
): void {
|
||||
const height = LiteGraph.NODE_WIDGET_HEIGHT
|
||||
ctx.save()
|
||||
if (disabled) ctx.globalAlpha *= 0.5
|
||||
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR
|
||||
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(CANVAS_MARGIN, y, width - CANVAS_MARGIN * 2, height, [
|
||||
height * 0.5
|
||||
])
|
||||
ctx.fill()
|
||||
if (!disabled) ctx.stroke()
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR
|
||||
ctx.font = `${LiteGraph.NODE_TEXT_SIZE}px ${LiteGraph.NODE_FONT}`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(label, width * 0.5, y + height * 0.7)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/** Horizontal centre of a row header's remove (✕) hit target. */
|
||||
const removeButtonCenterX = (width: number) =>
|
||||
width - CANVAS_MARGIN - LiteGraph.NODE_WIDGET_HEIGHT * 0.5
|
||||
|
||||
/** Draw a row header (label on the left, ✕ on the right) on the canvas. */
|
||||
function drawGroupRowHeader(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
y: number,
|
||||
label: string,
|
||||
removable: boolean
|
||||
): void {
|
||||
const height = LiteGraph.NODE_WIDGET_HEIGHT
|
||||
ctx.save()
|
||||
ctx.font = `${LiteGraph.NODE_TEXT_SIZE}px ${LiteGraph.NODE_FONT}`
|
||||
ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(label, CANVAS_MARGIN, y + height * 0.7)
|
||||
if (removable) {
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('\u2715', removeButtonCenterX(width), y + height * 0.7)
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
const countGroupRows = (group: string, node: LGraphNode): number =>
|
||||
(node.widgets ?? []).reduce(
|
||||
(count, w) =>
|
||||
headerRowIndex(group, w.name) !== undefined ? count + 1 : count,
|
||||
0
|
||||
)
|
||||
|
||||
/** Build a row's header + field widgets, returning them detached from the node. */
|
||||
function createRow(
|
||||
group: string,
|
||||
row: number,
|
||||
state: DynamicGroupState,
|
||||
node: DynamicGroupNode
|
||||
): IBaseWidget[] {
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
const startLen = node.widgets!.length
|
||||
|
||||
const header = node.addCustomWidget({
|
||||
name: rowHeaderName(group, row),
|
||||
type: 'dynamic_group_row',
|
||||
value: row,
|
||||
y: 0,
|
||||
serialize: false,
|
||||
callback: undefined as IBaseWidget['callback'],
|
||||
draw(
|
||||
this: IBaseWidget,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_node: LGraphNode,
|
||||
width: number,
|
||||
y: number
|
||||
) {
|
||||
const idx = headerRowIndex(group, this.name) ?? 0
|
||||
const label = t('dynamicGroup.row', { index: idx + 1 })
|
||||
drawGroupRowHeader(ctx, width, y, label, !!this.options?.removable)
|
||||
},
|
||||
mouse(this: IBaseWidget, event: CanvasPointerEvent, pos: Point) {
|
||||
if (event.type !== 'pointerup' || !this.options?.removable) return false
|
||||
const half = LiteGraph.NODE_WIDGET_HEIGHT * 0.5
|
||||
if (Math.abs(pos[0] - removeButtonCenterX(node.size[0])) > half)
|
||||
return false
|
||||
const idx = headerRowIndex(group, this.name)
|
||||
if (idx !== undefined) removeRow(group, idx, node)
|
||||
return true
|
||||
},
|
||||
options: { serialize: false, socketless: true, removable: row >= state.min }
|
||||
})
|
||||
header.callback = function (this: IBaseWidget) {
|
||||
const idx = headerRowIndex(group, this.name)
|
||||
if (idx !== undefined) removeRow(group, idx, node)
|
||||
}
|
||||
|
||||
for (const spec of state.inputSpecs)
|
||||
addNodeInput(node, {
|
||||
...spec,
|
||||
name: fieldName(group, row, spec.name),
|
||||
display_name: spec.display_name ?? spec.name
|
||||
})
|
||||
|
||||
return node.widgets!.splice(startLen)
|
||||
}
|
||||
|
||||
function insertRowAfterGroup(
|
||||
group: string,
|
||||
node: LGraphNode,
|
||||
rowWidgets: IBaseWidget[]
|
||||
): void {
|
||||
const lastIdx = node.widgets!.findLastIndex(
|
||||
(w) => w.name === group || w.name.startsWith(`${group}.`)
|
||||
)
|
||||
node.widgets!.splice(lastIdx + 1, 0, ...rowWidgets)
|
||||
}
|
||||
|
||||
function syncController(group: string, node: DynamicGroupNode): void {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
const controller = node.widgets?.find((w) => w.name === group)
|
||||
if (!state || !controller) return
|
||||
controller.options ??= {}
|
||||
controller.options.disabled = countGroupRows(group, node) >= state.max
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
}
|
||||
|
||||
function addRow(group: string, node: DynamicGroupNode): void {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
if (!state) return
|
||||
node.widgets ??= []
|
||||
const row = countGroupRows(group, node)
|
||||
if (row >= state.max) return
|
||||
insertRowAfterGroup(group, node, createRow(group, row, state, node))
|
||||
syncController(group, node)
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function removeRow(group: string, row: number, node: DynamicGroupNode): void {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
if (!state || row < state.min) return
|
||||
|
||||
for (const w of remove(node.widgets!, (w) =>
|
||||
belongsToRow(group, w.name, row)
|
||||
))
|
||||
w.onRemove?.()
|
||||
remove(node.inputs, (inp) => belongsToRow(group, inp.name, row))
|
||||
|
||||
for (const w of node.widgets ?? []) {
|
||||
const headerRow = headerRowIndex(group, w.name)
|
||||
if (headerRow !== undefined && headerRow > row) {
|
||||
w.name = rowHeaderName(group, headerRow - 1)
|
||||
w.options ??= {}
|
||||
w.options.removable = headerRow - 1 >= state.min
|
||||
continue
|
||||
}
|
||||
const shifted = shiftedFieldName(group, w.name, row)
|
||||
if (shifted !== undefined) w.name = shifted
|
||||
}
|
||||
for (const inp of node.inputs) {
|
||||
const shifted = shiftedFieldName(group, inp.name, row)
|
||||
if (shifted === undefined) continue
|
||||
inp.name = shifted
|
||||
if (inp.widget) inp.widget.name = shifted
|
||||
}
|
||||
|
||||
syncController(group, node)
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
/** Rebuild the group from scratch to hold exactly `count` rows. */
|
||||
function rebuildRows(group: string, count: number, node: DynamicGroupNode) {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
if (!state) return
|
||||
node.widgets ??= []
|
||||
|
||||
const isRowMember = (name: string) => name.startsWith(`${group}.`)
|
||||
for (const w of remove(node.widgets, (w) => isRowMember(w.name)))
|
||||
w.onRemove?.()
|
||||
remove(node.inputs, (inp) => isRowMember(inp.name))
|
||||
|
||||
const insertAt = node.widgets.findIndex((w) => w.name === group) + 1
|
||||
const rowWidgets: IBaseWidget[] = []
|
||||
for (let row = 0; row < count; row++)
|
||||
rowWidgets.push(...createRow(group, row, state, node))
|
||||
node.widgets.splice(insertAt, 0, ...rowWidgets)
|
||||
}
|
||||
|
||||
function dynamicGroupWidget(
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
untypedInputData: InputSpec,
|
||||
_appArg: ComfyApp
|
||||
) {
|
||||
const parseResult = zDynamicGroupInputSpec.safeParse(untypedInputData)
|
||||
if (!parseResult.success) throw new Error('invalid DynamicGroup spec')
|
||||
const [, { template, min, max }] = parseResult.data
|
||||
|
||||
const toSpecs = (
|
||||
inputs: Record<string, InputSpec> | undefined,
|
||||
isOptional: boolean
|
||||
) =>
|
||||
Object.entries(inputs ?? {}).map(([name, spec]) =>
|
||||
transformInputSpecV1ToV2(spec, { name, isOptional })
|
||||
)
|
||||
const inputSpecs = [
|
||||
...toSpecs(template.required, false),
|
||||
...toSpecs(template.optional, true)
|
||||
]
|
||||
|
||||
withComfyDynamicGroup(node)
|
||||
const typedNode = node as DynamicGroupNode
|
||||
typedNode.comfyDynamic.dynamicGroup[inputName] = { min, max, inputSpecs }
|
||||
|
||||
node.widgets ??= []
|
||||
const controller = node.addCustomWidget({
|
||||
name: inputName,
|
||||
type: 'dynamic_group_add',
|
||||
value: min,
|
||||
y: 0,
|
||||
serialize: true,
|
||||
callback: () => addRow(inputName, typedNode),
|
||||
draw(
|
||||
this: IBaseWidget,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_node: LGraphNode,
|
||||
width: number,
|
||||
y: number
|
||||
) {
|
||||
drawGroupButton(
|
||||
ctx,
|
||||
width,
|
||||
y,
|
||||
t('dynamicGroup.addRow'),
|
||||
!!this.options?.disabled
|
||||
)
|
||||
},
|
||||
mouse(this: IBaseWidget, event: CanvasPointerEvent) {
|
||||
if (event.type !== 'pointerup' || this.options?.disabled) return false
|
||||
addRow(inputName, typedNode)
|
||||
return true
|
||||
},
|
||||
options: { serialize: false, socketless: true, disabled: false }
|
||||
})
|
||||
|
||||
Object.defineProperty(controller, 'value', {
|
||||
get() {
|
||||
return countGroupRows(inputName, typedNode)
|
||||
},
|
||||
set(count: unknown) {
|
||||
if (typeof count !== 'number') return
|
||||
rebuildRows(inputName, count, typedNode)
|
||||
syncController(inputName, typedNode)
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
controller.value = min
|
||||
|
||||
return { widget: controller }
|
||||
}
|
||||
|
||||
export const dynamicWidgets = {
|
||||
COMFY_DYNAMICCOMBO_V3: dynamicComboWidget,
|
||||
COMFY_DYNAMICGROUP_V3: dynamicGroupWidget
|
||||
}
|
||||
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }
|
||||
const dynamicInputs: Record<
|
||||
string,
|
||||
(node: LGraphNode, inputSpec: InputSpecV2) => void
|
||||
|
||||
@@ -26,7 +26,6 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d', () => ({}))
|
||||
vi.mock('@/extensions/core/load3dAdvanced', () => ({}))
|
||||
vi.mock('@/extensions/core/load3dPreviewExtensions', () => ({}))
|
||||
vi.mock('@/extensions/core/saveMesh', () => ({}))
|
||||
|
||||
|
||||
@@ -246,37 +246,3 @@ describe('Comfy.UploadAudio AUDIOUPLOAD widget', () => {
|
||||
expect(mockFetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
type AudioUIWidget = (node: LGraphNode, inputName: string) => unknown
|
||||
|
||||
async function loadAudioUIWidget() {
|
||||
vi.resetModules()
|
||||
mockRegisterExtension.mockClear()
|
||||
await import('./uploadAudio')
|
||||
const extension = mockRegisterExtension.mock.calls
|
||||
.map(([extension]) => extension as ComfyExtension)
|
||||
.find((extension) => extension.name === 'Comfy.AudioWidget')
|
||||
if (!extension)
|
||||
throw new Error('Comfy.AudioWidget extension was not registered')
|
||||
const widgets = await extension.getCustomWidgets!(fromAny({}))
|
||||
return (widgets as Record<string, AudioUIWidget>).AUDIO_UI
|
||||
}
|
||||
|
||||
describe('Comfy.AudioWidget AUDIO_UI widget', () => {
|
||||
it('excludes the audio player from workflow and prompt serialization', async () => {
|
||||
const AUDIO_UI = await loadAudioUIWidget()
|
||||
const domWidget = {
|
||||
serialize: true,
|
||||
options: {} as Record<string, unknown>
|
||||
}
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
addDOMWidget: vi.fn(() => domWidget),
|
||||
constructor: { nodeData: { output_node: false } }
|
||||
})
|
||||
|
||||
AUDIO_UI(node, 'audioUI')
|
||||
|
||||
expect(domWidget.serialize).toBe(false)
|
||||
expect(domWidget.options.serialize).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -128,7 +128,6 @@ app.registerExtension({
|
||||
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
|
||||
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
|
||||
audioUIWidget.serialize = false
|
||||
audioUIWidget.options.serialize = false
|
||||
const { nodeData } = node.constructor
|
||||
if (nodeData == null) throw new TypeError('nodeData is null')
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodeId, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraph,
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
LLink,
|
||||
@@ -324,96 +323,6 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('node:before-removed event', () => {
|
||||
it('fires node:before-removed for a successful node removal', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
|
||||
const events: { node: LGraphNode; graphAtDispatch: unknown }[] = []
|
||||
graph.events.addEventListener('node:before-removed', (e) => {
|
||||
events.push({
|
||||
node: e.detail.node,
|
||||
graphAtDispatch: e.detail.node.graph
|
||||
})
|
||||
})
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].node).toBe(node)
|
||||
expect(events[0].graphAtDispatch).toBe(graph)
|
||||
expect(node.graph).toBeNull()
|
||||
})
|
||||
|
||||
it('does not fire node:before-removed for a node not in the graph', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
|
||||
const fired = vi.fn()
|
||||
graph.events.addEventListener('node:before-removed', fired)
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(fired).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fire node:before-removed when removing an LGraphGroup', () => {
|
||||
const graph = new LGraph()
|
||||
const group = new LGraphGroup('test-group')
|
||||
graph.add(group)
|
||||
|
||||
const fired = vi.fn()
|
||||
graph.events.addEventListener('node:before-removed', fired)
|
||||
|
||||
graph.remove(group)
|
||||
|
||||
expect(fired).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fire node:before-removed when ignore_remove is set', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
node.ignore_remove = true
|
||||
|
||||
const fired = vi.fn()
|
||||
graph.events.addEventListener('node:before-removed', fired)
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(fired).not.toHaveBeenCalled()
|
||||
expect(graph.nodes).toContain(node)
|
||||
})
|
||||
|
||||
it('fires node:before-removed before node.onRemoved and detach', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
|
||||
const order: string[] = []
|
||||
graph.events.addEventListener('node:before-removed', () => {
|
||||
order.push(
|
||||
`before-removed(graph=${node.graph === graph ? 'set' : 'null'})`
|
||||
)
|
||||
})
|
||||
node.onRemoved = () => {
|
||||
order.push(`onRemoved(graph=${node.graph === graph ? 'set' : 'null'})`)
|
||||
}
|
||||
graph.onNodeRemoved = (n) => {
|
||||
order.push(`onNodeRemoved(graph=${n.graph === null ? 'null' : 'set'})`)
|
||||
}
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(order).toEqual([
|
||||
'before-removed(graph=set)',
|
||||
'onRemoved(graph=set)',
|
||||
'onNodeRemoved(graph=null)'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Definition Garbage Collection', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -466,53 +375,6 @@ describe('Subgraph Definition Garbage Collection', () => {
|
||||
expect(graphRemovedNodeIds.size).toBe(2)
|
||||
})
|
||||
|
||||
it('subgraph-definition GC dispatches node:before-removed on the inner subgraph for each inner node', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const { subgraph, innerNodes } = createSubgraphWithNodes(rootGraph, 2)
|
||||
|
||||
const dispatched: { node: LGraphNode; graphAtDispatch: unknown }[] = []
|
||||
subgraph.events.addEventListener('node:before-removed', (e) => {
|
||||
dispatched.push({
|
||||
node: e.detail.node,
|
||||
graphAtDispatch: e.detail.node.graph
|
||||
})
|
||||
})
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
rootGraph.remove(subgraphNode)
|
||||
|
||||
expect(dispatched.map((e) => e.node)).toEqual(innerNodes)
|
||||
for (const entry of dispatched) {
|
||||
expect(entry.graphAtDispatch).toBe(subgraph)
|
||||
}
|
||||
})
|
||||
|
||||
it('subgraph-definition GC dispatches node:before-removed before each inner node onRemoved', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const { subgraph, innerNodes } = createSubgraphWithNodes(rootGraph, 1)
|
||||
const innerNode = innerNodes[0]
|
||||
|
||||
const order: string[] = []
|
||||
subgraph.events.addEventListener('node:before-removed', () => {
|
||||
order.push('before-removed')
|
||||
})
|
||||
innerNode.onRemoved = () => {
|
||||
order.push('onRemoved')
|
||||
}
|
||||
subgraph.onNodeRemoved = () => {
|
||||
order.push('onNodeRemoved')
|
||||
}
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
rootGraph.remove(subgraphNode)
|
||||
|
||||
expect(order).toEqual(['before-removed', 'onRemoved', 'onNodeRemoved'])
|
||||
})
|
||||
|
||||
it('subgraph definition is removed when SubgraphNode is removed', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const { subgraph } = createSubgraphWithNodes(rootGraph, 1)
|
||||
@@ -529,52 +391,6 @@ describe('Subgraph Definition Garbage Collection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('beforeChange deprecated onBeforeChange shim', () => {
|
||||
beforeEach(() => {
|
||||
LiteGraph.onDeprecationWarning = []
|
||||
LiteGraph.alwaysRepeatWarnings = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LiteGraph.alwaysRepeatWarnings = false
|
||||
})
|
||||
|
||||
it('still invokes a listener assigned to onBeforeChange', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
const onBeforeChange = vi.fn()
|
||||
graph.onBeforeChange = onBeforeChange
|
||||
|
||||
graph.beforeChange(node)
|
||||
|
||||
expect(onBeforeChange).toHaveBeenCalledWith(graph, node)
|
||||
})
|
||||
|
||||
it('warns that onBeforeChange is deprecated when used', () => {
|
||||
const graph = new LGraph()
|
||||
const deprecationCallback = vi.fn()
|
||||
LiteGraph.onDeprecationWarning = [deprecationCallback]
|
||||
graph.onBeforeChange = vi.fn()
|
||||
|
||||
graph.beforeChange()
|
||||
|
||||
expect(deprecationCallback).toHaveBeenCalledWith(
|
||||
expect.stringContaining('LGraph.onBeforeChange is deprecated'),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('does not warn when no listener is assigned', () => {
|
||||
const graph = new LGraph()
|
||||
const deprecationCallback = vi.fn()
|
||||
LiteGraph.onDeprecationWarning = [deprecationCallback]
|
||||
|
||||
graph.beforeChange()
|
||||
|
||||
expect(deprecationCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Legacy LGraph Compatibility Layer', () => {
|
||||
test('can be extended via prototype', ({ expect, minimalGraph }) => {
|
||||
// @ts-expect-error Should always be an error.
|
||||
|
||||
@@ -38,6 +38,7 @@ import type {
|
||||
DefaultConnectionColors,
|
||||
Dictionary,
|
||||
HasBoundingRect,
|
||||
IContextMenuValue,
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
LinkNetwork,
|
||||
@@ -55,7 +56,6 @@ import {
|
||||
createBounds,
|
||||
snapPoint
|
||||
} from './measure'
|
||||
import { warnDeprecated } from './utils/feedback'
|
||||
import { SubgraphInput } from './subgraph/SubgraphInput'
|
||||
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
|
||||
import { SubgraphOutput } from './subgraph/SubgraphOutput'
|
||||
@@ -155,13 +155,6 @@ export interface BaseLGraph {
|
||||
readonly rootGraph: LGraph
|
||||
}
|
||||
|
||||
function fireNodeRemovalLifecycle(node: LGraphNode): void {
|
||||
const graph: LGraph | null = node.graph
|
||||
graph?.events.dispatch('node:before-removed', { node })
|
||||
node.onRemoved?.()
|
||||
graph?.onNodeRemoved?.(node)
|
||||
}
|
||||
|
||||
/**
|
||||
* LGraph is the class that contain a full graph. We instantiate one and add nodes to it, and then we can run the execution loop.
|
||||
* supported callbacks:
|
||||
@@ -331,16 +324,16 @@ export class LGraph
|
||||
onNodeAdded?(node: LGraphNode): void
|
||||
onNodeRemoved?(node: LGraphNode): void
|
||||
onTrigger?: LGraphTriggerHandler
|
||||
/**
|
||||
* @deprecated Assign a listener to {@link LGraphCanvas.onBeforeChange} instead.
|
||||
* This graph-level hook will be removed in a future version.
|
||||
*/
|
||||
onBeforeChange?(graph: LGraph, info?: LGraphNode): void
|
||||
onAfterChange?(graph: LGraph, info?: LGraphNode | null): void
|
||||
onConnectionChange?(node: LGraphNode): void
|
||||
on_change?(graph: LGraph): void
|
||||
onSerialize?(data: ISerialisedGraph | SerialisableGraph): void
|
||||
onConfigure?(data: ISerialisedGraph | SerialisableGraph): void
|
||||
onGetNodeMenuOptions?(
|
||||
options: (IContextMenuValue<unknown> | null)[],
|
||||
node: LGraphNode
|
||||
): void
|
||||
|
||||
// @ts-expect-error - Private property type needs fixing
|
||||
private _input_nodes?: LGraphNode[]
|
||||
@@ -393,7 +386,8 @@ export class LGraph
|
||||
// safe clear
|
||||
if (this._nodes) {
|
||||
for (const _node of this._nodes) {
|
||||
fireNodeRemovalLifecycle(_node)
|
||||
_node.onRemoved?.()
|
||||
this.onNodeRemoved?.(_node)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1052,8 +1046,6 @@ export class LGraph
|
||||
// sure? - almost sure is wrong
|
||||
this.beforeChange()
|
||||
|
||||
this.events.dispatch('node:before-removed', { node })
|
||||
|
||||
const { inputs, outputs } = node
|
||||
|
||||
// disconnect inputs
|
||||
@@ -1089,7 +1081,10 @@ export class LGraph
|
||||
)
|
||||
|
||||
if (!hasRemainingReferences) {
|
||||
forEachNode(node.subgraph, fireNodeRemovalLifecycle)
|
||||
forEachNode(node.subgraph, (innerNode) => {
|
||||
innerNode.onRemoved?.()
|
||||
innerNode.graph?.onNodeRemoved?.(innerNode)
|
||||
})
|
||||
this.rootGraph.subgraphs.delete(node.subgraph.id)
|
||||
}
|
||||
}
|
||||
@@ -1357,12 +1352,7 @@ export class LGraph
|
||||
|
||||
// used for undo, called before any change is made to the graph
|
||||
beforeChange(info?: LGraphNode): void {
|
||||
if (this.onBeforeChange) {
|
||||
warnDeprecated(
|
||||
'LGraph.onBeforeChange is deprecated and will be removed in a future version. Assign a listener to LGraphCanvas.onBeforeChange instead.'
|
||||
)
|
||||
this.onBeforeChange(this, info)
|
||||
}
|
||||
this.onBeforeChange?.(this, info)
|
||||
this.canvasAction((c) => c.onBeforeChange?.(this))
|
||||
}
|
||||
|
||||
|
||||
@@ -829,7 +829,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (this._lowQualityZoomThreshold > 0) {
|
||||
this._isLowQuality = scale < this._lowQualityZoomThreshold
|
||||
}
|
||||
this.setDirty(true, true)
|
||||
}
|
||||
|
||||
// Initialize link renderer if graph is available
|
||||
@@ -8642,6 +8641,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
callback: LGraphCanvas.onMenuNodeRemove
|
||||
})
|
||||
|
||||
node.graph?.onGetNodeMenuOptions?.(options, node)
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LLink, ResolvedConnection } from '@/lib/litegraph/src/LLink'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
@@ -51,13 +51,6 @@ export interface LGraphEventMap {
|
||||
closingGraph: LGraph | Subgraph
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires on the owning graph before per-node teardown begins
|
||||
*/
|
||||
'node:before-removed': {
|
||||
node: LGraphNode
|
||||
}
|
||||
|
||||
'node:property:changed': {
|
||||
nodeId: NodeId
|
||||
property: string
|
||||
|
||||
@@ -85,19 +85,6 @@ describe('SubgraphNode Construction', () => {
|
||||
expect(subgraphNode.graph).toBeNull()
|
||||
})
|
||||
|
||||
it('should return empty widgets array (not throw) after removal', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const parentGraph = subgraphNode.graph!
|
||||
parentGraph.add(subgraphNode)
|
||||
|
||||
parentGraph.remove(subgraphNode)
|
||||
|
||||
expect(subgraphNode.graph).toBeNull()
|
||||
expect(() => subgraphNode.widgets).not.toThrow()
|
||||
expect(subgraphNode.widgets).toEqual([])
|
||||
})
|
||||
|
||||
subgraphTest(
|
||||
'should synchronize slots with subgraph definition',
|
||||
({ subgraphWithNode }) => {
|
||||
|
||||
@@ -68,10 +68,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
return this.graph.rootGraph
|
||||
}
|
||||
|
||||
get isDetached(): boolean {
|
||||
return !this.graph
|
||||
}
|
||||
|
||||
override get displayType(): string {
|
||||
return 'Subgraph node'
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ export interface IWidgetOptions<TValues = unknown> {
|
||||
|
||||
// Vue widget options
|
||||
disabled?: boolean
|
||||
removable?: boolean
|
||||
useGrouping?: boolean
|
||||
placeholder?: string
|
||||
showThumbnails?: boolean
|
||||
|
||||
@@ -92,7 +92,6 @@
|
||||
"errorUserTokenAccessDenied": "رمز API الخاص بك لا يملك صلاحية الوصول إلى هذا المورد. يرجى التحقق من أذونات الرمز.",
|
||||
"errorUserTokenInvalid": "رمز API المخزن غير صالح أو منتهي الصلاحية. يرجى تحديث الرمز في الإعدادات.",
|
||||
"failedToCreateNode": "فشل إنشاء العقدة. يرجى المحاولة مرة أخرى أو التحقق من وحدة التحكم للحصول على التفاصيل.",
|
||||
"failedToSetModelValue": "تمت إضافة العقدة، لكن لم يتم تعيين النموذج تلقائيًا. تحقق من وحدة التحكم لمزيد من التفاصيل.",
|
||||
"fileFormats": "تنسيقات الملفات",
|
||||
"fileName": "اسم الملف",
|
||||
"fileSize": "حجم الملف",
|
||||
@@ -242,8 +241,7 @@
|
||||
"auth/user-not-found": "لم يتم العثور على حساب بهذا البريد الإلكتروني. هل ترغب في إنشاء حساب جديد؟",
|
||||
"auth/weak-password": "كلمة المرور ضعيفة جداً. يرجى استخدام كلمة مرور أقوى تحتوي على 6 أحرف على الأقل.",
|
||||
"auth/wrong-password": "كلمة المرور التي أدخلتها غير صحيحة. يرجى المحاولة مرة أخرى.",
|
||||
"generic": "حدث خطأ أثناء تسجيل الدخول. يرجى المحاولة مرة أخرى.",
|
||||
"signupBlocked": "تعذر إنشاء حسابك الآن. يرجى المحاولة لاحقًا. إذا استمرت المشكلة، راسل support@comfy.org."
|
||||
"generic": "حدث خطأ أثناء تسجيل الدخول. يرجى المحاولة مرة أخرى."
|
||||
},
|
||||
"login": {
|
||||
"andText": "و",
|
||||
@@ -325,11 +323,6 @@
|
||||
"signUpWithGithub": "إنشاء حساب باستخدام Github",
|
||||
"signUpWithGoogle": "إنشاء حساب باستخدام Google",
|
||||
"title": "إنشاء حساب"
|
||||
},
|
||||
"turnstile": {
|
||||
"expired": "انتهت صلاحية التحقق. يرجى إكمال التحقق مرة أخرى.",
|
||||
"failed": "فشل التحقق. يرجى المحاولة مرة أخرى.",
|
||||
"submitBlockedHint": "يرجى إكمال التحقق أعلاه لتفعيل التسجيل."
|
||||
}
|
||||
},
|
||||
"batch": {
|
||||
@@ -353,17 +346,6 @@
|
||||
"x": "س",
|
||||
"y": "ص"
|
||||
},
|
||||
"boundingBoxes": {
|
||||
"clearAll": "مسح الكل",
|
||||
"clickRegionToEdit": "انقر على منطقة لتعديلها.",
|
||||
"colors": "لوحة الألوان",
|
||||
"descLabel": "الوصف",
|
||||
"descPlaceholder": "وصف هذه المنطقة",
|
||||
"textLabel": "نص",
|
||||
"textPlaceholder": "النص المراد عرضه (كما هو)",
|
||||
"typeObj": "كائن",
|
||||
"typeText": "نص"
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "التطبيق",
|
||||
"blueprint": "المخطط",
|
||||
@@ -773,13 +755,6 @@
|
||||
"creditsAvailable": "الرصيد المتاح",
|
||||
"details": "التفاصيل",
|
||||
"eventType": "نوع الحدث",
|
||||
"eventTypes": {
|
||||
"accountCreated": "تم إنشاء الحساب",
|
||||
"apiNodeUsage": "استخدام عقدة الشريك",
|
||||
"apiUsage": "استخدام API",
|
||||
"creditAdded": "تمت إضافة أرصدة",
|
||||
"gpuUsage": "استخدام GPU"
|
||||
},
|
||||
"faqs": "الأسئلة المتكررة",
|
||||
"invoiceHistory": "تاريخ الفواتير",
|
||||
"lastUpdated": "آخر تحديث",
|
||||
@@ -836,7 +811,6 @@
|
||||
},
|
||||
"dataTypes": {
|
||||
"*": "*",
|
||||
"ARRAY": "مصفوفة",
|
||||
"AUDIO": "صوت",
|
||||
"AUDIO_ENCODER": "مُشَفِّر الصوت",
|
||||
"AUDIO_ENCODER_OUTPUT": "مخرجات مُشَفِّر الصوت",
|
||||
@@ -844,13 +818,11 @@
|
||||
"BACKGROUND_REMOVAL": "إزالة الخلفية",
|
||||
"BOOLEAN": "منطقي",
|
||||
"BOUNDING_BOX": "مربع التحديد",
|
||||
"BOUNDING_BOXES": "مربعات التحديد",
|
||||
"CAMERA_CONTROL": "تحكم الكاميرا",
|
||||
"CLIP": "CLIP",
|
||||
"CLIP_VISION": "رؤية CLIP",
|
||||
"CLIP_VISION_OUTPUT": "خرج رؤية CLIP",
|
||||
"COLOR": "لون",
|
||||
"COLORS": "ألوان",
|
||||
"COMBO": "تركيب",
|
||||
"COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3",
|
||||
"COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3",
|
||||
@@ -860,7 +832,6 @@
|
||||
"CURVE": "منحنى",
|
||||
"DA3_GEOMETRY": "DA3_GEOMETRY",
|
||||
"DA3_MODEL": "DA3_MODEL",
|
||||
"DICT": "قاموس",
|
||||
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
|
||||
"FACE_DETECTION_MODEL": "نموذج كشف الوجه",
|
||||
"FACE_LANDMARKS": "معالم الوجه",
|
||||
@@ -1859,35 +1830,6 @@
|
||||
"zoomOptions": "خيارات التكبير",
|
||||
"zoomOut": "تصغير"
|
||||
},
|
||||
"hdrViewer": {
|
||||
"channel": "القناة",
|
||||
"channels": {
|
||||
"a": "ألفا",
|
||||
"b": "B",
|
||||
"g": "G",
|
||||
"luminance": "السطوع",
|
||||
"r": "R",
|
||||
"rgb": "RGB"
|
||||
},
|
||||
"clipWarnings": "تحذيرات القص",
|
||||
"dither": "تنعيم",
|
||||
"exposure": "التعريض",
|
||||
"failedToLoad": "فشل في تحميل صورة HDR",
|
||||
"fitView": "ملاءمة",
|
||||
"hdrImage": "صورة HDR",
|
||||
"histogram": "مخطط بياني",
|
||||
"inf": "لانهاية",
|
||||
"max": "الحد الأقصى",
|
||||
"mean": "المتوسط",
|
||||
"min": "الحد الأدنى",
|
||||
"nan": "غير رقم",
|
||||
"normalizeExposure": "التعريض التلقائي",
|
||||
"openInHdrViewer": "افتح في عارض HDR",
|
||||
"resolution": "الدقة",
|
||||
"sourceGamut": "مجال الألوان الأصلي",
|
||||
"stdDev": "الانحراف المعياري",
|
||||
"title": "عارض HDR"
|
||||
},
|
||||
"help": {
|
||||
"helpCenterMenu": "قائمة مركز المساعدة",
|
||||
"recentReleases": "الإصدارات الأخيرة"
|
||||
@@ -3015,10 +2957,6 @@
|
||||
"uploadError": "فشل في رفع صورة الرسام: {status} - {statusText}",
|
||||
"width": "العرض"
|
||||
},
|
||||
"palette": {
|
||||
"addColor": "إضافة لون",
|
||||
"swatchTitle": "انقر للتعديل · اسحب لإعادة الترتيب · انقر بزر الفأرة الأيمن للإزالة"
|
||||
},
|
||||
"progressToast": {
|
||||
"allDownloadsCompleted": "اكتملت جميع التنزيلات",
|
||||
"downloadingModel": "جاري تنزيل النموذج...",
|
||||
@@ -3103,6 +3041,7 @@
|
||||
"color": "لون العقدة",
|
||||
"editSubgraph": "تعديل الرسم البياني الفرعي",
|
||||
"editTitle": "تعديل العنوان",
|
||||
"enterSubgraph": "دخول الرسم الفرعي",
|
||||
"errorHelp": "للمزيد من المساعدة، {github} أو {support}",
|
||||
"errorHelpGithub": "إرسال مشكلة على GitHub",
|
||||
"errorHelpSupport": "تواصل مع الدعم الفني",
|
||||
@@ -3722,10 +3661,6 @@
|
||||
"addApiCredits": "إضافة رصيد API",
|
||||
"addCredits": "إضافة رصيد",
|
||||
"addCreditsLabel": "أضف المزيد من الرصيد في أي وقت",
|
||||
"additionalCredits": "رصيد إضافي",
|
||||
"additionalCreditsInUse": "قيد الاستخدام",
|
||||
"additionalCreditsInfo": "حول الرصيد الإضافي",
|
||||
"additionalCreditsTooltip": "الرصيد الذي تضيفه فوق خطتك. يُستخدم بعد نفاد الرصيد الشهري. كل رصيد ينتهي بعد سنة من الشراء.",
|
||||
"benefits": {
|
||||
"benefit1": "رصيد شهري للعقد الشريكة - تجديد عند الحاجة",
|
||||
"benefit1FreeTier": "رصيد شهري أكثر، مع إمكانية الشحن في أي وقت",
|
||||
@@ -3747,55 +3682,27 @@
|
||||
"keepSubscription": "الاحتفاظ بالاشتراك",
|
||||
"title": "إلغاء الاشتراك"
|
||||
},
|
||||
"cancelPlan": "إلغاء الخطة",
|
||||
"cancelSubscription": "إلغاء الاشتراك",
|
||||
"cancelSuccess": "تم إلغاء الاشتراك بنجاح",
|
||||
"canceled": "تم الإلغاء",
|
||||
"canceledCard": {
|
||||
"description": "لن يتم خصم أي رسوم أخرى منك. ستبقى ميزاتك نشطة حتى {date}.",
|
||||
"title": "تم إلغاء اشتراكك"
|
||||
},
|
||||
"changePlan": "تغيير الخطة",
|
||||
"changeTo": "تغيير إلى {plan}",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyCloudLogo": "شعار Comfy Cloud",
|
||||
"contactOwnerToSubscribe": "يرجى التواصل مع مالك مساحة العمل للاشتراك",
|
||||
"contactUs": "تواصل معنا",
|
||||
"creditSliderSave": "وفر {percent}% ({amount})",
|
||||
"creditsLeftOfTotal": "{remaining} متبقية من أصل {total}",
|
||||
"creditsRemainingThisMonth": "الرصيد المتبقي لهذا الشهر",
|
||||
"creditsRemainingThisYear": "الرصيد المتبقي لهذا العام",
|
||||
"creditsUsed": "{used} مستخدمة",
|
||||
"creditsYouveAdded": "الرصيد الذي أضفته",
|
||||
"currentPlan": "الخطة الحالية",
|
||||
"customLoRAsLabel": "استيراد LoRAs الخاصة بك",
|
||||
"description": "اختر الخطة الأنسب لك",
|
||||
"descriptionWorkspace": "اختر أفضل خطة لمساحة العمل الخاصة بك",
|
||||
"downgrade": {
|
||||
"body": "سيتم إزالة جميع الأعضاء الآخرين من مساحة العمل هذه فورًا.",
|
||||
"confirm": "تغيير الخطة",
|
||||
"confirmationPhrase": "أنا أفهم",
|
||||
"confirmationPrompt": "اكتب \"{phrase}\" للتأكيد.",
|
||||
"failed": "فشل في تغيير الخطة",
|
||||
"failedAfterMemberRemoval": "تمت إزالة أعضاء الفريق، لكن لم يكتمل تغيير الخطة — يرجى المحاولة مرة أخرى أو التواصل مع الدعم",
|
||||
"memberRemovalFailed": "تعذر إزالة {email} من الفريق — قد يكون بعض الأعضاء قد أُزيلوا بالفعل ولم يتم تغيير خطتك",
|
||||
"notAllowed": "تغيير الخطة غير متاح",
|
||||
"paymentMethodRequired": "مطلوب وسيلة دفع لتغيير الخطط",
|
||||
"paymentPageBlocked": "تعذر فتح صفحة الدفع — يرجى المحاولة مرة أخرى",
|
||||
"title": "تغيير إلى خطة {plan}؟"
|
||||
},
|
||||
"endsOnDate": "ينتهي في {date}",
|
||||
"enterprise": {
|
||||
"cta": "اعرف المزيد",
|
||||
"flexibility": "تبحث عن مزيد من المرونة أو ميزات مخصصة؟",
|
||||
"name": "المؤسسات",
|
||||
"needMoreMembers": "تحتاج إلى المزيد من الأعضاء؟",
|
||||
"reachOut": "تواصل معنا ولنحدد موعدًا للحديث."
|
||||
},
|
||||
"everythingInPlus": "كل ما في {plan}، بالإضافة إلى:",
|
||||
"expiresDate": "ينتهي في {date}",
|
||||
"freePerks": {
|
||||
"maxRuntime": "مدة تشغيل قصوى {duration}"
|
||||
},
|
||||
"freeTier": {
|
||||
"description": "تشمل خطتك المجانية {credits} رصيد شهري لتجربة Comfy Cloud.",
|
||||
"descriptionGeneric": "تشمل خطتك المجانية رصيدًا شهريًا لتجربة Comfy Cloud.",
|
||||
@@ -3823,7 +3730,7 @@
|
||||
"inviteUpTo": "ادعُ حتى",
|
||||
"invoiceHistory": "سجل الفواتير",
|
||||
"learnMore": "معرفة المزيد",
|
||||
"manageBilling": "إدارة الفواتير",
|
||||
"managePayment": "إدارة الدفع",
|
||||
"managePlan": "إدارة الخطة",
|
||||
"manageSubscription": "إدارة الاشتراك",
|
||||
"maxDuration": {
|
||||
@@ -3837,99 +3744,50 @@
|
||||
"maxMembersLabel": "الحد الأقصى للأعضاء",
|
||||
"member": "عضو",
|
||||
"memberCount": "{count} عضو | {count} أعضاء",
|
||||
"membersLabel": "حتى {count} عضو",
|
||||
"messageSupport": "مراسلة الدعم",
|
||||
"monthly": "شهري",
|
||||
"monthlyBonusDescription": "مكافأة الرصيد الشهرية",
|
||||
"monthlyCredits": "رصيد شهري",
|
||||
"monthlyCreditsInfo": "يتم تحديث هذا الرصيد شهريًا ولا ينتقل للشهر التالي",
|
||||
"monthlyCreditsLabel": "الرصيد الشهري",
|
||||
"monthlyCreditsPerMemberLabel": "الرصيد الشهري / عضو",
|
||||
"monthlyCreditsRollover": "سيتم ترحيل هذا الرصيد إلى الشهر التالي",
|
||||
"monthlyCreditsUsedUpDescription": "أنت الآن تستخدم الرصيد الإضافي.",
|
||||
"monthlyCreditsUsedUpTitle": "تم استهلاك الرصيد الشهري. إعادة التعبئة {date}",
|
||||
"monthlyCreditsUsedUpTitleNoDate": "تم استهلاك الرصيد الشهري",
|
||||
"monthlyUsageProgress": "{used} من {total} من الرصيد الشهري مستخدم",
|
||||
"mostPopular": "الأكثر شيوعًا",
|
||||
"needTeamWorkspace": "هل تحتاج إلى مساحة عمل للفريق؟",
|
||||
"nextBillingCycle": "دورة الفوترة التالية",
|
||||
"outOfCreditsDescription": "أضف المزيد من الرصيد للمتابعة في التوليد.",
|
||||
"outOfCreditsTitle": "نفد رصيدك. سيتم إعادة التعبئة {date}",
|
||||
"outOfCreditsTitleNoDate": "نفد رصيدك",
|
||||
"nextMonthInvoice": "فاتورة الشهر القادم",
|
||||
"partnerNodesBalance": "رصيد \"عُقَد الشريك\"",
|
||||
"partnerNodesCredits": "رصيد العقد الشريكة",
|
||||
"partnerNodesDescription": "لتشغيل النماذج التجارية/المملوكة",
|
||||
"partnerNodesPricingTable": "جدول أسعار Partner Nodes",
|
||||
"perMonth": "دولار أمريكي / شهر",
|
||||
"personalHeader": "الخطط الشخصية للاستخدام الفردي فقط. {action}",
|
||||
"personalHeaderAction": "لإضافة زملاء، اشترك في خطة الفريق.",
|
||||
"personalWorkspace": "مساحة العمل الشخصية",
|
||||
"planLoadError": "تعذر تحميل تفاصيل خطتك.",
|
||||
"planLoadErrorRetry": "حاول مرة أخرى",
|
||||
"planScope": {
|
||||
"personal": "للاستخدام الشخصي",
|
||||
"team": "للفرق"
|
||||
},
|
||||
"plansAndPricing": "الخطط والأسعار",
|
||||
"plansForWorkspace": "الخطط لمساحة العمل {workspace}",
|
||||
"prepaidCreditsInfo": "رصيد تم شراؤه بشكل منفصل ولا ينتهي صلاحيته",
|
||||
"prepaidDescription": "رصيد مسبق الدفع",
|
||||
"preview": {
|
||||
"addCreditCard": "إضافة بطاقة ائتمان",
|
||||
"afterThat": "بعد ذلك",
|
||||
"backToAllPlans": "العودة إلى جميع الخطط",
|
||||
"billedEachMonth": "{amount} يتم تحصيلها كل شهر. يمكنك الإلغاء في أي وقت.",
|
||||
"commitment": "الالتزام",
|
||||
"confirm": "تأكيد",
|
||||
"confirmChange": "تأكيد التغيير",
|
||||
"confirmChangeTitle": "مراجعة التغيير المجدول",
|
||||
"confirmPayment": "تأكيد الدفع",
|
||||
"confirmPlanChange": "تأكيد تغيير الخطة",
|
||||
"confirmUpgradeCta": "تأكيد الترقية",
|
||||
"confirmUpgradeTitle": "تأكيد الترقية",
|
||||
"creditFromCurrent": "رصيد من {plan} الحالي",
|
||||
"creditsRefillMonthlyTo": "يتم إعادة تعبئة الرصيد شهرياً إلى",
|
||||
"creditsRefillTo": "سيتم إعادة تعبئة الرصيد إلى",
|
||||
"creditsYoullGetToday": "الرصيد الذي ستحصل عليه اليوم",
|
||||
"currentMonthly": "الخطة الشهرية",
|
||||
"eachMonthCreditsRefill": "يتم إعادة تعبئة الرصيد كل شهر إلى",
|
||||
"eachYearCreditsRefill": "يتم إعادة تعبئة الرصيد سنوياً إلى",
|
||||
"ends": "ينتهي في {date}",
|
||||
"everyMonthStarting": "كل شهر ابتداءً من {date}",
|
||||
"hideFeatures": "إخفاء الميزات",
|
||||
"newMonthlySubscription": "اشتراك شهري جديد",
|
||||
"nextPaymentDue": "الدفع القادم مستحق في {date}. يمكنك الإلغاء في أي وقت.",
|
||||
"paymentPopupBlocked": "تعذر فتح صفحة الدفع — يرجى السماح بالنوافذ المنبثقة والمحاولة مرة أخرى.",
|
||||
"perMember": "/ عضو",
|
||||
"privacyPolicy": "سياسة الخصوصية",
|
||||
"proratedCharge": "رسوم نسبية لخطة {plan}",
|
||||
"proratedRefund": "استرداد نسبي لخطة {plan}",
|
||||
"refillReplacesNote": "يستبدل إعادة التعبئة الشهرية. الرصيد الحالي يبقى محفوظاً.",
|
||||
"showMoreFeatures": "عرض المزيد من الميزات",
|
||||
"starting": "يبدأ في {date}",
|
||||
"startingToday": "يبدأ اليوم",
|
||||
"startsOn": "يبدأ في {date}",
|
||||
"stayOnUntil": "ستبقى على {plan} حتى {date}.",
|
||||
"subscribeToPlan": "اشترك في {plan}",
|
||||
"switchToPlan": "انتقل إلى {plan}",
|
||||
"switchesToday": "تبديلات اليوم",
|
||||
"terms": "الشروط",
|
||||
"termsAgreement": "بالمتابعة، أنت توافق على {terms} و{privacy} الخاصة بـ Comfy Org.",
|
||||
"totalDueToday": "الإجمالي المستحق اليوم",
|
||||
"yearlySubscription": "اشتراك سنوي",
|
||||
"youllBeCharged": "سيتم خصم"
|
||||
"totalDueToday": "الإجمالي المستحق اليوم"
|
||||
},
|
||||
"pricingBlurb": "*استنادًا إلى هذا القالب، {seeDetails}. تواصل معنا لـ {questions} أو {enterpriseDiscussions}. لمزيد من تفاصيل الأسعار، {clickHere}.",
|
||||
"pricingBlurbClickHere": "اضغط هنا",
|
||||
"pricingBlurbEnterprise": "مناقشات المؤسسات",
|
||||
"pricingBlurbQuestions": "الاستفسارات",
|
||||
"pricingBlurbSeeDetails": "اعرض التفاصيل",
|
||||
"reactivatePlan": "إعادة تفعيل الخطة",
|
||||
"refillsDate": "إعادة التعبئة {date}",
|
||||
"refillsNextCycle": "إعادة التعبئة في الدورة التالية",
|
||||
"refreshCredits": "تحديث الرصيد",
|
||||
"remaining": "متبقي",
|
||||
"renewsDate": "تجديد في {date}",
|
||||
"renewsOnDate": "يتجدد في {date}",
|
||||
"required": {
|
||||
"pollingFailed": "فشل تفعيل الاشتراك",
|
||||
"pollingSuccess": "تم تفعيل الاشتراك بنجاح!",
|
||||
@@ -3941,11 +3799,7 @@
|
||||
"resubscribe": "إعادة الاشتراك",
|
||||
"resubscribeSuccess": "تمت إعادة تفعيل الاشتراك بنجاح",
|
||||
"resubscribeTo": "إعادة الاشتراك في {plan}",
|
||||
"saveYearly": "وفّر 20%",
|
||||
"saveYearlyUpTo": "وفر حتى ٢٠٪",
|
||||
"soloUseOnly": "للاستخدام الفردي فقط",
|
||||
"subscribe": "اشترك",
|
||||
"subscribeFailed": "فشل الاشتراك",
|
||||
"subscribeForMore": "ترقية",
|
||||
"subscribeNow": "اشترك الآن",
|
||||
"subscribeTo": "اشترك في {plan}",
|
||||
@@ -3953,46 +3807,10 @@
|
||||
"subscribeToRun": "اشتراك",
|
||||
"subscribeToRunFull": "الاشتراك للتشغيل",
|
||||
"subscriptionRequiredMessage": "الاشتراك مطلوب للأعضاء لتشغيل سير العمل على السحابة",
|
||||
"success": {
|
||||
"allSet": "تم كل شيء بنجاح",
|
||||
"inviteEmailsPlaceholder": "أدخل عناوين البريد الإلكتروني مفصولة بفواصل",
|
||||
"inviteSubtext": "يمكنك أيضاً دعوة الأشخاص لاحقاً من الإعدادات",
|
||||
"inviteTitle": "دعوة فريقك",
|
||||
"planUpdated": "تم تحديث خطتك بنجاح.",
|
||||
"receiptEmailed": "تم إرسال إيصال إلى بريدك الإلكتروني.",
|
||||
"sendInvites": "إرسال الدعوات"
|
||||
},
|
||||
"teamHeader": "للفرق التي ترغب في التعاون. تحتاج إلى المزيد من الأعضاء؟ {learnMore} حول المؤسسات.",
|
||||
"teamHeaderLearnMore": "اعرف المزيد",
|
||||
"teamPerks": {
|
||||
"concurrentRuns": "يمكن للأعضاء تشغيل سير العمل في نفس الوقت",
|
||||
"inviteMembers": "دعوة الأعضاء",
|
||||
"rolePermissions": "أذونات حسب الدور",
|
||||
"sharedCreditPool": "رصيد مشترك لجميع الأعضاء"
|
||||
},
|
||||
"teamPlan": {
|
||||
"changePlan": "تغيير الخطة",
|
||||
"comingSoonLabel": "قريبًا:",
|
||||
"cta": "اشترك في خطة الفريق السنوية",
|
||||
"ctaMonthly": "اشترك في خطة الفريق الشهرية",
|
||||
"currentPlan": "الخطة الحالية",
|
||||
"detailsTitle": "التفاصيل",
|
||||
"name": "خطة الفريق",
|
||||
"perkConcurrentRuns": "يمكن للأعضاء تشغيل سير العمل في نفس الوقت",
|
||||
"perkInviteMembers": "دعوة أعضاء الفريق",
|
||||
"perkProjectAssets": "إدارة المشاريع والأصول",
|
||||
"perkRolePermissions": "أذونات حسب الدور",
|
||||
"perkSharedPool": "رصيد مشترك لجميع الأعضاء",
|
||||
"tagline": "اختر اشتراك الرصيد الشهري الخاص بك. احصل على خصم أكبر مع اشتراك رصيد أكبر.",
|
||||
"unavailable": "خطة الفريق هذه غير متوفرة حالياً."
|
||||
},
|
||||
"teamPlanIncludes": "تشمل خطتك كل ما في {plan}، بالإضافة إلى:",
|
||||
"teamPlanName": "فريق",
|
||||
"teamWorkspace": "مساحة عمل الفريق",
|
||||
"tierNameYearly": "{name} سنوي",
|
||||
"tiers": {
|
||||
"creator": {
|
||||
"feature1": "استيراد نماذجك الخاصة",
|
||||
"name": "المُبدع"
|
||||
},
|
||||
"founder": {
|
||||
@@ -4002,12 +3820,9 @@
|
||||
"name": "مجاني"
|
||||
},
|
||||
"pro": {
|
||||
"feature1": "مدة تشغيل سير عمل أطول (حتى ساعة واحدة)",
|
||||
"name": "احترافي"
|
||||
},
|
||||
"standard": {
|
||||
"feature1": "حد أقصى لمدة تشغيل سير العمل ٣٠ دقيقة",
|
||||
"feature2": "إضافة المزيد من الرصيد في أي وقت",
|
||||
"name": "قياسي"
|
||||
}
|
||||
},
|
||||
@@ -4020,8 +3835,6 @@
|
||||
"upgradeToAddCredits": "قم بالترقية لإضافة أرصدة",
|
||||
"usdPerMonth": "دولار أمريكي / شهريًا",
|
||||
"usdPerMonthPerMember": "دولار أمريكي / شهر / عضو",
|
||||
"usedAfterMonthly": "يتم استخدامه بعد نفاد الرصيد الشهري",
|
||||
"videoEstimate": "ينتج تقريبًا ~{count} فيديوهات ٥ ثوانٍ*",
|
||||
"videoEstimateExplanation": "هذه التقديرات مبنية على قالب Wan 2.2 لتحويل الصورة إلى فيديو باستخدام الإعدادات الافتراضية (5 ثوانٍ، 640x640، 16 إطار/ثانية، 4 خطوات أخذ عينات).",
|
||||
"videoEstimateHelp": "مزيد من التفاصيل حول هذا القالب",
|
||||
"videoEstimateLabel": "العدد التقريبي لمقاطع الفيديو 5 ثوانٍ التي يتم إنشاؤها باستخدام قالب Wan 2.2 لتحويل الصورة إلى فيديو",
|
||||
@@ -4031,10 +3844,10 @@
|
||||
"viewMoreDetails": "عرض المزيد من التفاصيل",
|
||||
"viewMoreDetailsPlans": "عرض المزيد من التفاصيل حول الخطط والأسعار",
|
||||
"viewUsageHistory": "عرض سجل الاستخدام",
|
||||
"whatsIncluded": "ما يتضمنه:",
|
||||
"workspaceNotSubscribed": "هذه مساحة العمل ليست مشتركة",
|
||||
"yearly": "سنوي",
|
||||
"yearlyCreditsLabel": "إجمالي الرصيد السنوي",
|
||||
"yearlyDiscount": "خصم 20%",
|
||||
"yourPlanIncludes": "خطتك تشمل:"
|
||||
},
|
||||
"tabMenu": {
|
||||
@@ -4325,19 +4138,6 @@
|
||||
}
|
||||
},
|
||||
"workspacePanel": {
|
||||
"changeRoleDialog": {
|
||||
"demoteConfirm": "تخفيض إلى عضو",
|
||||
"demoteMessage": "سيفقد صلاحيات الإدارة.",
|
||||
"demoteTitle": "تخفيض {name} إلى عضو؟",
|
||||
"error": "فشل في تحديث الدور",
|
||||
"promoteConfirm": "جعل مالكًا",
|
||||
"promoteIntro": "سيكون بإمكانه:",
|
||||
"promotePermissionCredits": "إضافة أرصدة إضافية",
|
||||
"promotePermissionManage": "إدارة الأعضاء، طرق الدفع، وإعدادات مساحة العمل",
|
||||
"promotePermissionRoles": "ترقية أو تخفيض مالكين آخرين (باستثناء منشئ مساحة العمل).",
|
||||
"promoteTitle": "جعل {name} مالكًا؟",
|
||||
"success": "تم تحديث الدور"
|
||||
},
|
||||
"createWorkspaceDialog": {
|
||||
"create": "إنشاء",
|
||||
"message": "تتيح مساحات العمل للأعضاء مشاركة رصيد واحد. ستصبح المالك بعد الإنشاء.",
|
||||
@@ -4362,11 +4162,17 @@
|
||||
"inviteLimitReached": "لقد وصلت إلى الحد الأقصى وهو ٥٠ عضواً",
|
||||
"inviteMember": "دعوة عضو",
|
||||
"inviteMemberDialog": {
|
||||
"failedCount": "تعذر إرسال {count} دعوة. حاول مرة أخرى. | تعذر إرسال {count} دعوات. حاول مرة أخرى.",
|
||||
"invalidEmailCount": "{count} عنوان بريد إلكتروني غير صالح | {count} عناوين بريد إلكتروني غير صالحة",
|
||||
"invitedMessage": "تم إرسال دعوة إلى {emails} | تم إرسال دعوات إلى {emails}",
|
||||
"createLink": "إنشاء الرابط",
|
||||
"linkCopied": "تم النسخ",
|
||||
"linkCopyFailed": "فشل في نسخ الرابط",
|
||||
"linkStep": {
|
||||
"copyLink": "نسخ الرابط",
|
||||
"done": "تم",
|
||||
"message": "تأكد من أن حسابه يستخدم هذا البريد الإلكتروني.",
|
||||
"title": "أرسل هذا الرابط إلى الشخص"
|
||||
},
|
||||
"message": "أنشئ رابط دعوة قابل للمشاركة لإرساله إلى شخص ما",
|
||||
"placeholder": "أدخل بريد الشخص الإلكتروني",
|
||||
"seatLimitReached": "يمكنك دعوة حتى {count} زميل. | يمكنك دعوة حتى {count} زملاء.",
|
||||
"title": "دعوة شخص إلى هذه المساحة"
|
||||
},
|
||||
"inviteUpsellDialog": {
|
||||
@@ -4374,7 +4180,8 @@
|
||||
"messageSingleSeat": "خطة Standard تتضمن مقعدًا واحدًا فقط لمالك مساحة العمل. لدعوة أعضاء إضافيين، قم بالترقية إلى خطة Creator أو أعلى لتفعيل المقاعد المتعددة.",
|
||||
"titleNotSubscribed": "الاشتراك مطلوب لدعوة الأعضاء",
|
||||
"titleSingleSeat": "خطتك الحالية تدعم مقعدًا واحدًا فقط",
|
||||
"upgradeToTeam": "الترقية إلى فريق"
|
||||
"upgradeToCreator": "الترقية إلى Creator",
|
||||
"viewPlans": "عرض الخطط"
|
||||
},
|
||||
"leaveDialog": {
|
||||
"leave": "مغادرة",
|
||||
@@ -4383,35 +4190,30 @@
|
||||
},
|
||||
"members": {
|
||||
"actions": {
|
||||
"cancelInvite": "إلغاء الدعوة",
|
||||
"changeRole": "تغيير الدور",
|
||||
"copyLink": "نسخ رابط الدعوة",
|
||||
"removeMember": "إزالة العضو",
|
||||
"resendInvite": "إعادة إرسال الدعوة"
|
||||
"revokeInvite": "إلغاء الدعوة"
|
||||
},
|
||||
"columns": {
|
||||
"expiryDate": "تاريخ الانتهاء",
|
||||
"inviteDate": "تاريخ الدعوة",
|
||||
"role": "الدور"
|
||||
"joinDate": "تاريخ الانضمام"
|
||||
},
|
||||
"contactUs": "تواصل معنا",
|
||||
"header": "الأعضاء",
|
||||
"createNewWorkspace": "أنشئ واحدة جديدة.",
|
||||
"membersCount": "{count}/٥٠ عضواً",
|
||||
"needMoreMembers": "تحتاج إلى المزيد من الأعضاء؟",
|
||||
"noInvites": "لا توجد دعوات معلقة",
|
||||
"noMembers": "لا يوجد أعضاء",
|
||||
"pendingInvitesCount": "{count} دعوة معلقة | {count} دعوات معلقة",
|
||||
"reactivateTeam": "إعادة تفعيل الفريق",
|
||||
"searchPlaceholder": "بحث...",
|
||||
"personalWorkspaceMessage": "لا يمكنك دعوة أعضاء آخرين إلى مساحة العمل الشخصية حالياً. لإضافة أعضاء إلى مساحة عمل،",
|
||||
"tabs": {
|
||||
"active": "نشط",
|
||||
"pendingCount": "معلق ({count})"
|
||||
},
|
||||
"upgradeToTeam": "الترقية إلى فريق",
|
||||
"upsellBanner": "لإضافة زملاء، قم بترقية خطتك.",
|
||||
"upsellBannerReactivate": "لإضافة المزيد من الزملاء، فعّل خطتك من جديد."
|
||||
"upsellBannerSubscribe": "اشترك في خطة Creator أو أعلى لدعوة أعضاء الفريق إلى مساحة العمل هذه.",
|
||||
"upsellBannerUpgrade": "قم بالترقية إلى خطة Creator أو أعلى لدعوة أعضاء فريق إضافيين.",
|
||||
"viewPlans": "عرض الخطط"
|
||||
},
|
||||
"menu": {
|
||||
"creatorCannotLeave": "لا يمكن لمنشئ مساحة العمل مغادرة المساحة التي أنشأها",
|
||||
"deleteWorkspace": "حذف مساحة العمل",
|
||||
"deleteWorkspaceDisabledTooltip": "يرجى إلغاء الاشتراك النشط لمساحة العمل أولاً",
|
||||
"editWorkspace": "تعديل تفاصيل مساحة العمل",
|
||||
@@ -4440,8 +4242,6 @@
|
||||
"failedToFetchWorkspaces": "فشل في تحميل مساحات العمل",
|
||||
"failedToLeaveWorkspace": "فشل في مغادرة مساحة العمل",
|
||||
"failedToUpdateWorkspace": "فشل في تحديث مساحة العمل",
|
||||
"inviteResendFailed": "فشل في إعادة إرسال الدعوة",
|
||||
"inviteResent": "تمت إعادة إرسال الدعوة",
|
||||
"workspaceCreated": {
|
||||
"message": "اشترك في خطة، وادعُ زملاءك، وابدأ التعاون.",
|
||||
"subscribe": "اشترك",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user