Compare commits

..

1 Commits

Author SHA1 Message Date
Rizumu Ayaka
3e34506515 fix: cancel click-to-place node when released over a panel
The full-bleed canvas sits under the sidebar/properties panels, so the
click-to-place release path used geometric bounds and could drop a hidden
node behind a panel (FE-688). Hit-test the actual event target against the
canvas element instead, mirroring how native drag treats the canvas as its
only drop target. Mark ghost placement active during the drag so existing
Vue nodes render inert and releases over occupied canvas areas still place.
Suppress the hover preview while a node is being placed.

Claude-Session: https://claude.ai/code/session_0134SELvedJXhGvsX1RvWezW
2026-06-24 22:51:29 +08:00
299 changed files with 4268 additions and 21467 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'
})
}
}

View File

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

View File

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

View File

@@ -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' }))
)
}

View File

@@ -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')
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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([])
})
})
})

View File

@@ -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' },

View File

@@ -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'
)
})
})

View File

@@ -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')
})
})

View File

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

View File

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

View File

@@ -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'),

View File

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

View File

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

View File

@@ -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')
})
})

View File

@@ -51,7 +51,8 @@ const mockHandleNativeDrop = vi.fn()
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({
startDrag: mockStartDrag,
handleNativeDrop: mockHandleNativeDrop
handleNativeDrop: mockHandleNativeDrop,
isDragging: ref(false)
})
}))

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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')
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: [
{

View File

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

View File

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

View File

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

View File

@@ -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[]
}

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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])
})

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'
)
}
})

View File

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

View File

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

View File

@@ -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)
}
},

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

@@ -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 ?? {}

View File

@@ -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'])
})
})

View File

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

View File

@@ -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', () => ({}))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }) => {

View File

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

View File

@@ -71,7 +71,6 @@ export interface IWidgetOptions<TValues = unknown> {
// Vue widget options
disabled?: boolean
removable?: boolean
useGrouping?: boolean
placeholder?: string
showThumbnails?: boolean

View File

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