mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-26 17:47:17 +00:00
Compare commits
30 Commits
jaeone/sub
...
DynamicGro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a16d488838 | ||
|
|
13b42d9b59 | ||
|
|
e604c85b88 | ||
|
|
7ae3ad936c | ||
|
|
7b83228cdd | ||
|
|
d26f578c81 | ||
|
|
cfbc378df3 | ||
|
|
db085eb7a1 | ||
|
|
48d429bd13 | ||
|
|
6eaad99502 | ||
|
|
2ced8a25d4 | ||
|
|
9209a4b923 | ||
|
|
90cb8021df | ||
|
|
5a01c5b3b4 | ||
|
|
e3049e7c31 | ||
|
|
87e84e7280 | ||
|
|
67009dcda2 | ||
|
|
026b2c4795 | ||
|
|
d60260ac3c | ||
|
|
0c89f5a3a7 | ||
|
|
f19597ce81 | ||
|
|
988dc71955 | ||
|
|
da55529d23 | ||
|
|
52d430d1b6 | ||
|
|
7ab6cb57c5 | ||
|
|
3c3a2ab4e2 | ||
|
|
a07854755f | ||
|
|
842e3d7541 | ||
|
|
2adef5d9f6 | ||
|
|
c406042215 |
@@ -2,6 +2,7 @@ issue_enrichment:
|
||||
auto_enrich:
|
||||
enabled: true
|
||||
reviews:
|
||||
profile: assertive
|
||||
high_level_summary: false
|
||||
request_changes_workflow: true
|
||||
auto_review:
|
||||
|
||||
21
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
21
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -133,3 +133,24 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No Customer.io references found'
|
||||
|
||||
- name: Scan dist for Cloudflare Turnstile sitekey references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Scanning for Cloudflare Turnstile sitekeys...'
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
-e '0x4AAAAAADnYZPVOpFCL_zeo' \
|
||||
-e '0x4AAAAAADnYY4_Q0qxHZ5a7' \
|
||||
-e '1x00000000000000000000AA' \
|
||||
dist; then
|
||||
echo '❌ ERROR: Cloudflare Turnstile sitekey found in dist assets!'
|
||||
echo 'The per-env Turnstile sitekeys are cloud-only and must be tree-shaken from OSS builds.'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Gate sitekey selection on the __DISTRIBUTION__ build define, not the runtime isCloud const'
|
||||
echo '2. See getTurnstileSiteKey() in src/config/turnstile.ts'
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No Turnstile sitekey references found'
|
||||
|
||||
2
.github/workflows/cla.yml
vendored
2
.github/workflows/cla.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
|
||||
# Allowlist bots so they don't need to sign (optional, comma-separated).
|
||||
# *[bot] is a catch-all for any GitHub App bot account.
|
||||
allowlist: actions-user,ampagent,claude,coderabbitai[bot],comfy-pr-bot,dependabot[bot],github-actions[bot],copilot-swe-agent[bot],devin-ai-integration[bot],*[bot]
|
||||
allowlist: actions-user,ampagent,claude,comfy-pr-bot,github-actions,*[bot],Glary Bot
|
||||
|
||||
# Custom PR comment messages
|
||||
custom-notsigned-prcomment: |
|
||||
|
||||
@@ -83,6 +83,16 @@ const config: StorybookConfig = {
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
|
||||
},
|
||||
{
|
||||
find: '@/composables/useFeatureFlags',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/stores/teamWorkspaceStore',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/teamWorkspaceStore.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/formatUtil',
|
||||
replacement:
|
||||
|
||||
@@ -47,6 +47,11 @@ test.describe('Download page @smoke', () => {
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
await expect(downloadBtn).toHaveAttribute('target', '_blank')
|
||||
await expect(downloadBtn).toHaveAttribute(
|
||||
'href',
|
||||
'https://comfy.org/download/windows/nsis/x64'
|
||||
)
|
||||
await expect(downloadBtn).toHaveAttribute('data-astro-prefetch', 'false')
|
||||
|
||||
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
|
||||
await expect(githubBtn).toBeVisible()
|
||||
@@ -73,7 +78,7 @@ test.describe('Download page @smoke', () => {
|
||||
})
|
||||
|
||||
const windowsBtn = hero.locator(
|
||||
'a[href="https://download.comfy.org/windows/nsis/x64"]'
|
||||
'a[href="https://comfy.org/download/windows/nsis/x64"]'
|
||||
)
|
||||
await expect(windowsBtn).toBeVisible()
|
||||
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)
|
||||
|
||||
@@ -72,6 +72,7 @@ const buttons = computed<ButtonSpec[]>(() => {
|
||||
size="lg"
|
||||
:class="customClass"
|
||||
:aria-label="btn.ariaLabel"
|
||||
:data-astro-prefetch="btn.key === 'windows' ? 'false' : undefined"
|
||||
@click="captureDownloadClick(btn.key)"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { externalLinks } from '@/config/routes'
|
||||
|
||||
export const downloadUrls = {
|
||||
windows: 'https://download.comfy.org/windows/nsis/x64',
|
||||
windows: 'https://comfy.org/download/windows/nsis/x64',
|
||||
macArm: 'https://download.comfy.org/mac/dmg/arm64'
|
||||
} as const
|
||||
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
{
|
||||
"id": "test-missing-model-nested-promoted-widget",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "outer-subgraph-with-promoted-missing-model",
|
||||
"pos": [10, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"title": "Resolved Shared Outer Subgraph",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["resolved_model.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "outer-subgraph-with-promoted-missing-model",
|
||||
"pos": [450, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"title": "Outer Subgraph with Promoted Missing Model",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "outer-subgraph-with-promoted-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Outer Subgraph with Promoted Missing Model",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [600, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "outer-ckpt-name-input-id",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"linkIds": [2],
|
||||
"pos": { "0": 150, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "inner-subgraph-with-promoted-missing-model",
|
||||
"pos": [250, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "ckpt_name",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 2,
|
||||
"target_slot": 0,
|
||||
"type": "COMBO"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "inner-subgraph-with-promoted-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 1,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Inner Subgraph with Promoted Missing Model",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "inner-ckpt-name-input-id",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 150, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [250, 180],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "ckpt_name",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "COMBO"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
96
browser_tests/fixtures/data/cloudWorkspace.ts
Normal file
96
browser_tests/fixtures/data/cloudWorkspace.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type {
|
||||
BillingStatusResponse,
|
||||
Member,
|
||||
Plan,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
// `/api/features` is the remote-config source: production builds resolve the
|
||||
// workspaces flag from it (the `ff:` localStorage override is dev-only).
|
||||
export const WORKSPACE_FEATURE_FLAG: RemoteConfig = {
|
||||
team_workspaces_enabled: true
|
||||
}
|
||||
|
||||
export const TEAM_WORKSPACE: WorkspaceWithRole = {
|
||||
id: 'ws-team',
|
||||
name: 'Team Comfy',
|
||||
type: 'team',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
joined_at: '2025-01-02T00:00:00Z',
|
||||
role: 'owner',
|
||||
subscription_tier: 'PRO'
|
||||
}
|
||||
|
||||
export const CREATOR: Member = {
|
||||
id: 'u-liz',
|
||||
name: 'Liz',
|
||||
email: 'liz@test.comfy.org',
|
||||
joined_at: '2025-01-01T00:00:00Z',
|
||||
role: 'owner',
|
||||
is_original_owner: true
|
||||
}
|
||||
|
||||
// Identity must match the CloudAuthHelper mock user so this row counts as
|
||||
// "(You)".
|
||||
export const VIEWER: Member = {
|
||||
id: 'u-me',
|
||||
name: 'E2E Test User',
|
||||
email: 'e2e@test.comfy.org',
|
||||
joined_at: '2025-01-02T00:00:00Z',
|
||||
role: 'owner',
|
||||
is_original_owner: false
|
||||
}
|
||||
|
||||
export const MEMBER_JANE: Member = {
|
||||
id: 'u-jane',
|
||||
name: 'Jane',
|
||||
email: 'jane@test.comfy.org',
|
||||
joined_at: '2025-01-03T00:00:00Z',
|
||||
role: 'member',
|
||||
is_original_owner: false
|
||||
}
|
||||
|
||||
export const MEMBER_JOHN: Member = {
|
||||
id: 'u-john',
|
||||
name: 'John',
|
||||
email: 'john@test.comfy.org',
|
||||
joined_at: '2025-01-04T00:00:00Z',
|
||||
role: 'member',
|
||||
is_original_owner: false
|
||||
}
|
||||
|
||||
export const DEFAULT_TEAM_MEMBERS: Member[] = [
|
||||
CREATOR,
|
||||
VIEWER,
|
||||
MEMBER_JANE,
|
||||
MEMBER_JOHN
|
||||
]
|
||||
|
||||
export const TEAM_BILLING_STATUS: BillingStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_status: 'active',
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
plan_slug: 'pro-monthly',
|
||||
billing_status: 'paid',
|
||||
has_funds: true,
|
||||
renewal_date: '2099-02-20T00:00:00Z'
|
||||
}
|
||||
|
||||
// `max_seats > 1` on the current plan is what flips `isOnTeamPlan`, which gates
|
||||
// the whole role-management UI.
|
||||
export const TEAM_PRO_PLAN: Plan = {
|
||||
slug: 'pro-monthly',
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
price_cents: 10000,
|
||||
credits_cents: 21100,
|
||||
max_seats: 30,
|
||||
availability: { available: true },
|
||||
seat_summary: {
|
||||
seat_count: 4,
|
||||
total_cost_cents: 40000,
|
||||
total_credits_cents: 0
|
||||
}
|
||||
}
|
||||
150
browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts
Normal file
150
browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { Member } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import {
|
||||
DEFAULT_TEAM_MEMBERS,
|
||||
TEAM_BILLING_STATUS,
|
||||
TEAM_PRO_PLAN,
|
||||
TEAM_WORKSPACE,
|
||||
WORKSPACE_FEATURE_FLAG
|
||||
} from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
interface RoleChangeRequest {
|
||||
url: string
|
||||
role: string
|
||||
}
|
||||
|
||||
interface MemberMockState {
|
||||
members: Member[]
|
||||
patches: RoleChangeRequest[]
|
||||
}
|
||||
|
||||
const jsonRoute = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
/**
|
||||
* Boots the cloud app against fully mocked workspace + billing endpoints so
|
||||
* member/role specs can drive a raw `page` (the `comfyPage` fixture would try
|
||||
* to reach the OSS devtools backend during setup).
|
||||
*
|
||||
* Returns the mutable mock state: `members` reflects PATCH-applied roles and
|
||||
* `patches` records every role-change request for assertion.
|
||||
*/
|
||||
export class CloudWorkspaceMockHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async setup(
|
||||
members: Member[] = DEFAULT_TEAM_MEMBERS
|
||||
): Promise<MemberMockState> {
|
||||
const state = await this.mockBoot(members)
|
||||
await new CloudAuthHelper(this.page).mockAuth()
|
||||
await this.page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
localStorage.setItem('Comfy.Workspace.LastWorkspaceId', 'ws-team')
|
||||
})
|
||||
return state
|
||||
}
|
||||
|
||||
private async mockBoot(members: Member[]): Promise<MemberMockState> {
|
||||
const state: MemberMockState = {
|
||||
members: members.map((m) => ({ ...m })),
|
||||
patches: []
|
||||
}
|
||||
const { page } = this
|
||||
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(jsonRoute(WORKSPACE_FEATURE_FLAG))
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// A non-empty settings payload with TutorialCompleted marks the user as
|
||||
// returning, so the new-user Templates dialog never auto-opens to block the
|
||||
// Settings button. Errors tab off suppresses the model-folder 401 toast.
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': false
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(jsonRoute({ workspaces: [TEAM_WORKSPACE] }))
|
||||
)
|
||||
|
||||
await page.route('**/api/workspace/members**', (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() === 'PATCH') {
|
||||
const url = request.url()
|
||||
const id = url.match(/\/api\/workspace\/members\/([^/?]+)/)?.[1]
|
||||
const { role } = request.postDataJSON() as { role: Member['role'] }
|
||||
state.patches.push({ url, role })
|
||||
const member = state.members.find((m) => m.id === id)
|
||||
if (member) member.role = role
|
||||
// Echo the updated row like the real BE; the store merges only the role
|
||||
// locally, so the response body shape is not load-bearing.
|
||||
return route.fulfill(jsonRoute(member))
|
||||
}
|
||||
return route.fulfill(
|
||||
jsonRoute({
|
||||
members: state.members,
|
||||
pagination: { offset: 0, limit: 50, total: state.members.length }
|
||||
})
|
||||
)
|
||||
})
|
||||
await page.route('**/api/workspace/invites', (r) =>
|
||||
r.fulfill(jsonRoute({ invites: [] }))
|
||||
)
|
||||
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(jsonRoute(TEAM_BILLING_STATUS))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
amount_micros: 6000,
|
||||
currency: 'usd',
|
||||
effective_balance_micros: 6000,
|
||||
cloud_credit_balance_micros: 5000,
|
||||
prepaid_balance_micros: 1000
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ current_plan_slug: 'pro-monthly', plans: [TEAM_PRO_PLAN] })
|
||||
)
|
||||
)
|
||||
|
||||
return state
|
||||
}
|
||||
}
|
||||
34
browser_tests/fixtures/utils/cloudBillingMocks.ts
Normal file
34
browser_tests/fixtures/utils/cloudBillingMocks.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
/**
|
||||
* Minimal valid billing shapes so the billing facade resolves while a
|
||||
* subscription dialog mounts. Active personal sub with zero balance.
|
||||
*/
|
||||
export async function mockBilling(page: Page) {
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_status: 'active',
|
||||
subscription_tier: 'pro',
|
||||
subscription_duration: 'MONTHLY',
|
||||
billing_status: 'paid'
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(jsonRoute({ plans: [] }))
|
||||
)
|
||||
await page.route('**/customers/cloud-subscription-status', (r) =>
|
||||
r.fulfill(jsonRoute({ is_active: false }))
|
||||
)
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
|
||||
)
|
||||
}
|
||||
64
browser_tests/fixtures/utils/cloudBootMocks.ts
Normal file
64
browser_tests/fixtures/utils/cloudBootMocks.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
interface CloudBootOptions {
|
||||
/** Remote-config payload for `/api/features` (enables the flags under test). */
|
||||
features: RemoteConfig
|
||||
/** Body for `/api/settings` (defaults to `{}`). */
|
||||
settings?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the core endpoints the cloud app hits on boot so a raw `page` reaches the
|
||||
* working app without falling through to the OSS devtools backend. Specs layer
|
||||
* their own feature- or flow-specific routes on top.
|
||||
*/
|
||||
export async function mockCloudBoot(
|
||||
page: Page,
|
||||
{ features, settings = {} }: CloudBootOptions
|
||||
) {
|
||||
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(features)))
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/user', (r) =>
|
||||
r.fulfill(jsonRoute({ status: 'active' }))
|
||||
)
|
||||
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute(settings)))
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Firebase auth and pre-select the e2e user so the cloud app boots
|
||||
* signed-in. The signed-in email (`e2e@test.comfy.org`) is what the
|
||||
* original-owner gate matches against the members self-row.
|
||||
*/
|
||||
export async function bootCloud(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
}
|
||||
12
browser_tests/fixtures/utils/jsonRoute.ts
Normal file
12
browser_tests/fixtures/utils/jsonRoute.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Build a 200 JSON body for `route.fulfill()`. Generic so callers can type the
|
||||
* payload (e.g. `jsonRoute({ ... } satisfies RemoteConfig)`) and catch contract
|
||||
* drift against the real API shape.
|
||||
*/
|
||||
export function jsonRoute<T>(body: T) {
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import {
|
||||
getComboSpecComboOptions,
|
||||
isComboInputSpec,
|
||||
isComboInputSpecV1
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import type {
|
||||
ComboInputSpec,
|
||||
ComboInputSpecV2,
|
||||
ComfyNodeDef,
|
||||
InputSpec
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
|
||||
export type ObjectInfoResponse = Record<string, ComfyNodeDef>
|
||||
|
||||
type ComboInput = ComboInputSpec | ComboInputSpecV2
|
||||
|
||||
const OBJECT_INFO_ROUTE = '**/object_info'
|
||||
|
||||
function getRequiredInput(
|
||||
objectInfo: ObjectInfoResponse,
|
||||
nodeType: string,
|
||||
inputName: string
|
||||
): InputSpec {
|
||||
const nodeInfo = objectInfo[nodeType]
|
||||
if (!nodeInfo) {
|
||||
throw new Error(`Missing object_info entry for ${nodeType}`)
|
||||
}
|
||||
|
||||
const requiredInputs = nodeInfo.input?.required
|
||||
if (!requiredInputs) {
|
||||
throw new Error(`Missing required inputs for ${nodeType}`)
|
||||
}
|
||||
|
||||
const input = requiredInputs[inputName]
|
||||
if (!input) {
|
||||
throw new Error(`Missing input ${nodeType}.${inputName}`)
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
function getComboInput(
|
||||
objectInfo: ObjectInfoResponse,
|
||||
nodeType: string,
|
||||
inputName: string
|
||||
): ComboInput {
|
||||
const input = getRequiredInput(objectInfo, nodeType, inputName)
|
||||
if (isComboInputSpec(input)) {
|
||||
return input
|
||||
}
|
||||
|
||||
throw new Error(`Expected ${nodeType}.${inputName} to be a combo input`)
|
||||
}
|
||||
|
||||
export function setComboInputOptions(
|
||||
objectInfo: ObjectInfoResponse,
|
||||
nodeType: string,
|
||||
inputName: string,
|
||||
values: ReadonlyArray<string | number>
|
||||
): void {
|
||||
const input = getComboInput(objectInfo, nodeType, inputName)
|
||||
const nextValues = [...values]
|
||||
|
||||
if (isComboInputSpecV1(input)) {
|
||||
input[0] = nextValues
|
||||
return
|
||||
}
|
||||
|
||||
input[1] = { ...input[1], options: nextValues }
|
||||
}
|
||||
|
||||
export function appendComboInputOptions(
|
||||
objectInfo: ObjectInfoResponse,
|
||||
nodeType: string,
|
||||
inputName: string,
|
||||
values: ReadonlyArray<string | number>
|
||||
): void {
|
||||
const input = getComboInput(objectInfo, nodeType, inputName)
|
||||
setComboInputOptions(objectInfo, nodeType, inputName, [
|
||||
...getComboSpecComboOptions(input),
|
||||
...values
|
||||
])
|
||||
}
|
||||
|
||||
export async function routeObjectInfoFromSetupApi(
|
||||
page: Page,
|
||||
customize?: (objectInfo: ObjectInfoResponse) => void | Promise<void>
|
||||
): Promise<() => Promise<void>> {
|
||||
const setupApiUrl =
|
||||
process.env.PLAYWRIGHT_SETUP_API_URL ?? 'http://127.0.0.1:8188'
|
||||
const objectInfoUrl = new URL('/object_info', setupApiUrl).toString()
|
||||
|
||||
const objectInfoRouteHandler = async (route: Route) => {
|
||||
let objectInfo: ObjectInfoResponse
|
||||
try {
|
||||
const response = await fetch(objectInfoUrl, {
|
||||
signal: AbortSignal.timeout(5_000)
|
||||
})
|
||||
if (!response.ok) {
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
contentType: response.headers.get('content-type') ?? 'text/plain',
|
||||
body: await response.text()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
objectInfo = (await response.json()) as ObjectInfoResponse
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
await route.fulfill({
|
||||
status: 502,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: `Failed to fetch setup object_info from ${objectInfoUrl}: ${message}`
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await customize?.(objectInfo)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(objectInfo)
|
||||
})
|
||||
}
|
||||
|
||||
await page.route(OBJECT_INFO_ROUTE, objectInfoRouteHandler)
|
||||
return async () => {
|
||||
if (page.isClosed()) return
|
||||
await page.unroute(OBJECT_INFO_ROUTE, objectInfoRouteHandler)
|
||||
}
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
const PROMOTED_MODEL_WIDGET_NAME = 'ckpt_name'
|
||||
|
||||
export interface PromotedMissingModelWorkflow {
|
||||
workflowName: string
|
||||
hostNodeId: number
|
||||
hostNodeTitle: string
|
||||
sharedDefinitionSiblingHostNodeId?: number
|
||||
sharedDefinitionSiblingHostNodeTitle?: string
|
||||
}
|
||||
|
||||
type RootWorkflowNode = {
|
||||
id: number | string
|
||||
widgets_values?: unknown[] | Record<string, unknown>
|
||||
}
|
||||
|
||||
type RootWorkflowData = ComfyWorkflowJSON & {
|
||||
nodes?: RootWorkflowNode[]
|
||||
}
|
||||
|
||||
export const NESTED_PROMOTED_MISSING_MODEL_WORKFLOW: PromotedMissingModelWorkflow =
|
||||
{
|
||||
workflowName: 'missing/missing_model_nested_promoted_widget',
|
||||
hostNodeId: 4,
|
||||
hostNodeTitle: 'Outer Subgraph with Promoted Missing Model',
|
||||
sharedDefinitionSiblingHostNodeId: 3,
|
||||
sharedDefinitionSiblingHostNodeTitle: 'Resolved Shared Outer Subgraph'
|
||||
}
|
||||
|
||||
export function getMissingModelLabel(group: Locator, modelName: string) {
|
||||
return group.getByRole('button', { name: modelName, exact: true })
|
||||
}
|
||||
|
||||
export async function expectSingleMissingModelReference(
|
||||
group: Locator,
|
||||
modelName: string
|
||||
) {
|
||||
await expectMissingModelReferenceCount(group, modelName, 1)
|
||||
}
|
||||
|
||||
export async function expectMissingModelReferenceCount(
|
||||
group: Locator,
|
||||
modelName: string,
|
||||
count: number
|
||||
) {
|
||||
await expect(getMissingModelLabel(group, modelName)).toHaveCount(1)
|
||||
const badge = group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
|
||||
if (count === 1) {
|
||||
await expect(badge).toBeHidden()
|
||||
return
|
||||
}
|
||||
await expect(badge).toBeVisible()
|
||||
await expect(badge).toHaveText(String(count))
|
||||
}
|
||||
|
||||
export async function loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
modelName: string
|
||||
): Promise<Locator> {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, workflow.workflowName)
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expectSingleMissingModelReference(missingModelGroup, modelName)
|
||||
return missingModelGroup
|
||||
}
|
||||
|
||||
export async function loadPromotedMissingModelWithHostValuesAndOpenErrorsTab(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
hostValues: Record<number, string>,
|
||||
modelName: string,
|
||||
expectedReferenceCount: number
|
||||
): Promise<Locator> {
|
||||
await loadPromotedMissingModelWithHostValues(comfyPage, workflow, hostValues)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expectMissingModelReferenceCount(
|
||||
missingModelGroup,
|
||||
modelName,
|
||||
expectedReferenceCount
|
||||
)
|
||||
return missingModelGroup
|
||||
}
|
||||
|
||||
export async function expectNoMissingModelUi(comfyPage: ComfyPage) {
|
||||
const panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeHidden()
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
await expect(
|
||||
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeHidden()
|
||||
}
|
||||
|
||||
export async function selectVueComboPromotedModelByTitle(
|
||||
comfyPage: ComfyPage,
|
||||
nodeTitle: string,
|
||||
modelName: string
|
||||
) {
|
||||
await comfyPage.vueNodes.selectComboOption(
|
||||
nodeTitle,
|
||||
PROMOTED_MODEL_WIDGET_NAME,
|
||||
modelName
|
||||
)
|
||||
}
|
||||
|
||||
export async function selectVueAssetPromotedModel(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
currentModelName: string,
|
||||
modelName: string
|
||||
) {
|
||||
await selectModelFromFormDropdown(
|
||||
comfyPage,
|
||||
comfyPage.vueNodes.getNodeByTitle(workflow.hostNodeTitle),
|
||||
currentModelName,
|
||||
modelName
|
||||
)
|
||||
}
|
||||
|
||||
export async function selectSectionComboPromotedModel(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
modelName: string
|
||||
) {
|
||||
const panel = await openHostNodeParametersPanel(comfyPage, workflow)
|
||||
const combo = panel.contentArea.getByRole('combobox', {
|
||||
name: PROMOTED_MODEL_WIDGET_NAME,
|
||||
exact: true
|
||||
})
|
||||
await combo.click()
|
||||
await comfyPage.page
|
||||
.getByRole('option', { name: modelName, exact: true })
|
||||
.click()
|
||||
}
|
||||
|
||||
export async function selectSectionAssetPromotedModel(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
currentModelName: string,
|
||||
modelName: string
|
||||
) {
|
||||
const panel = await openHostNodeParametersPanel(comfyPage, workflow)
|
||||
await selectModelFromFormDropdown(
|
||||
comfyPage,
|
||||
panel.contentArea,
|
||||
currentModelName,
|
||||
modelName
|
||||
)
|
||||
}
|
||||
|
||||
export async function setLegacyPromotedComboModel(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
modelName: string
|
||||
) {
|
||||
await comfyPage.page.evaluate(
|
||||
({ hostNodeId, widgetName, value }) => {
|
||||
type LegacyPromotedWidget = {
|
||||
name?: string
|
||||
value?: unknown
|
||||
callback?: (value: string) => void
|
||||
setValue?: (
|
||||
value: string,
|
||||
options: {
|
||||
e: PointerEvent
|
||||
node: unknown
|
||||
canvas: unknown
|
||||
}
|
||||
) => void
|
||||
}
|
||||
type LegacyPromotedNode = {
|
||||
onWidgetChanged?: (
|
||||
name: string,
|
||||
newValue: string,
|
||||
oldValue: unknown,
|
||||
widget: LegacyPromotedWidget
|
||||
) => void
|
||||
widgets?: LegacyPromotedWidget[]
|
||||
}
|
||||
type LegacyPromotedGraph = {
|
||||
getNodeById: (nodeId: number) => LegacyPromotedNode | undefined
|
||||
}
|
||||
|
||||
const currentGraph = window.app?.graph as LegacyPromotedGraph | undefined
|
||||
const hostNode: LegacyPromotedNode | undefined =
|
||||
currentGraph?.getNodeById(hostNodeId)
|
||||
if (!hostNode) {
|
||||
throw new Error(`Expected subgraph host node ${hostNodeId}`)
|
||||
}
|
||||
|
||||
const widget = hostNode.widgets?.find(
|
||||
(entry) => entry.name === widgetName
|
||||
) as LegacyPromotedWidget | undefined
|
||||
if (!widget) {
|
||||
throw new Error(`Expected host ${widgetName} widget`)
|
||||
}
|
||||
|
||||
const oldValue = widget.value
|
||||
if (widget.setValue) {
|
||||
widget.setValue(value, {
|
||||
e: new PointerEvent('pointerup'),
|
||||
node: hostNode,
|
||||
canvas: window.app!.canvas
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
hostNode.onWidgetChanged?.(
|
||||
widget.name ?? widgetName,
|
||||
value,
|
||||
oldValue,
|
||||
widget
|
||||
)
|
||||
},
|
||||
{
|
||||
hostNodeId: workflow.hostNodeId,
|
||||
widgetName: PROMOTED_MODEL_WIDGET_NAME,
|
||||
value: modelName
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function selectLegacyPromotedAssetModel(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
assetId: string
|
||||
) {
|
||||
await clickLegacyHostPromotedWidget(comfyPage, workflow)
|
||||
|
||||
const modal = comfyPage.page.locator(
|
||||
'[data-component-id="AssetBrowserModal"]'
|
||||
)
|
||||
await expect(modal).toBeVisible()
|
||||
const assetCard = modal.locator(`[data-asset-id="${assetId}"]`)
|
||||
await expect(assetCard).toBeVisible()
|
||||
await assetCard.getByRole('button', { name: 'Use', exact: true }).click()
|
||||
await expect(modal).toBeHidden()
|
||||
}
|
||||
|
||||
export async function expectResolvedPromotedModelSuppressesStaleInteriorErrors(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
expectedStaleInteriorWidgets: Array<{
|
||||
subgraphNodeIdToEnter: string
|
||||
nodeTitle: string
|
||||
}>,
|
||||
resolvedModelName: string,
|
||||
staleModelName: string
|
||||
) {
|
||||
await loadPromotedMissingModelWithHostValues(comfyPage, workflow, {
|
||||
[workflow.hostNodeId]: resolvedModelName
|
||||
})
|
||||
|
||||
const promotedModelCombo = comfyPage.vueNodes
|
||||
.getNodeByTitle(workflow.hostNodeTitle)
|
||||
.getByRole('combobox', { name: PROMOTED_MODEL_WIDGET_NAME, exact: true })
|
||||
await expect(promotedModelCombo).toContainText(resolvedModelName)
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
|
||||
for (const step of expectedStaleInteriorWidgets) {
|
||||
await enterSubgraphForStaleInteriorCheck(
|
||||
comfyPage,
|
||||
step.subgraphNodeIdToEnter
|
||||
)
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle(step.nodeTitle)
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
const staleCombo = node.getByRole('combobox', {
|
||||
name: PROMOTED_MODEL_WIDGET_NAME,
|
||||
exact: true
|
||||
})
|
||||
await expect(
|
||||
staleCombo,
|
||||
`${step.nodeTitle} should expose the stale linked interior widget`
|
||||
).toBeDisabled()
|
||||
await expect(
|
||||
staleCombo,
|
||||
`${step.nodeTitle} should keep the stale interior value`
|
||||
).toContainText(staleModelName)
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
}
|
||||
}
|
||||
|
||||
async function openHostNodeParametersPanel(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow
|
||||
): Promise<PropertiesPanelHelper> {
|
||||
await comfyPage.vueNodes.selectNode(String(workflow.hostNodeId))
|
||||
const panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
await expect(panel.getTab('Parameters')).toBeVisible()
|
||||
await panel.switchToTab('Parameters')
|
||||
return panel
|
||||
}
|
||||
|
||||
async function loadPromotedMissingModelWithHostValues(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
hostValues: Record<number, string>
|
||||
) {
|
||||
const graphData = readPromotedMissingModelWorkflow(workflow.workflowName)
|
||||
for (const [hostNodeId, value] of Object.entries(hostValues)) {
|
||||
setRootHostWidgetValue(graphData, Number(hostNodeId), value)
|
||||
}
|
||||
|
||||
await comfyPage.workflow.loadGraphData(graphData)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
|
||||
function readPromotedMissingModelWorkflow(workflowName: string) {
|
||||
return JSON.parse(
|
||||
readFileSync(assetPath(`${workflowName}.json`), 'utf-8')
|
||||
) as RootWorkflowData
|
||||
}
|
||||
|
||||
function setRootHostWidgetValue(
|
||||
graphData: RootWorkflowData,
|
||||
hostNodeId: number,
|
||||
value: string
|
||||
) {
|
||||
const hostNode = graphData.nodes?.find(
|
||||
(node) => Number(node.id) === hostNodeId
|
||||
)
|
||||
if (!hostNode) throw new Error(`Expected host node ${hostNodeId}`)
|
||||
|
||||
if (Array.isArray(hostNode.widgets_values)) {
|
||||
hostNode.widgets_values[0] = value
|
||||
return
|
||||
}
|
||||
|
||||
hostNode.widgets_values = {
|
||||
...(hostNode.widgets_values ?? {}),
|
||||
[PROMOTED_MODEL_WIDGET_NAME]: value
|
||||
}
|
||||
}
|
||||
|
||||
async function selectModelFromFormDropdown(
|
||||
comfyPage: ComfyPage,
|
||||
root: Locator,
|
||||
currentModelName: string,
|
||||
nextModelName: string
|
||||
) {
|
||||
const trigger = root
|
||||
.getByRole('button', { name: currentModelName, exact: true })
|
||||
.first()
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId('form-dropdown-menu')
|
||||
await expect(menu).toBeVisible()
|
||||
await menu.getByText(nextModelName, { exact: true }).click()
|
||||
await expect(menu).toBeHidden()
|
||||
}
|
||||
|
||||
async function clickLegacyHostPromotedWidget(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow
|
||||
) {
|
||||
const hostNode = await comfyPage.nodeOps.getNodeRefById(workflow.hostNodeId)
|
||||
await hostNode.centerOnNode()
|
||||
const widget = await hostNode.getWidgetByName(PROMOTED_MODEL_WIDGET_NAME)
|
||||
await widget.click()
|
||||
}
|
||||
|
||||
async function enterSubgraphForStaleInteriorCheck(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
) {
|
||||
const numericNodeId = Number(nodeId)
|
||||
if (Number.isNaN(numericNodeId)) {
|
||||
throw new Error(`Expected numeric subgraph node id, got ${nodeId}`)
|
||||
}
|
||||
|
||||
const normalizedNodeId = String(numericNodeId)
|
||||
const enterButton =
|
||||
comfyPage.vueNodes.getSubgraphEnterButton(normalizedNodeId)
|
||||
if ((await enterButton.count()) > 0) {
|
||||
await comfyPage.vueNodes.enterSubgraph(normalizedNodeId)
|
||||
return
|
||||
}
|
||||
|
||||
await comfyPage.page.evaluate((targetNodeId) => {
|
||||
const graph = window.app?.canvas.graph
|
||||
const node = graph?.getNodeById(targetNodeId)
|
||||
if (!node?.isSubgraphNode()) {
|
||||
throw new Error(`Expected visible subgraph node ${targetNodeId}`)
|
||||
}
|
||||
window.app!.canvas.setGraph(node.subgraph)
|
||||
}, numericNodeId)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
68
browser_tests/fixtures/utils/workspaceMocks.ts
Normal file
68
browser_tests/fixtures/utils/workspaceMocks.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
Member,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
export function workspace(
|
||||
type: 'personal' | 'team',
|
||||
role: 'owner' | 'member'
|
||||
): WorkspaceWithRole {
|
||||
return {
|
||||
id: `ws-${type}`,
|
||||
name: type === 'team' ? 'My Team' : 'Personal Workspace',
|
||||
type,
|
||||
role,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
}
|
||||
|
||||
export function member(
|
||||
overrides: Partial<Member> & Pick<Member, 'email' | 'role'>
|
||||
): Member {
|
||||
return {
|
||||
id: `user-${overrides.email}`,
|
||||
name: overrides.email,
|
||||
joined_at: '2026-01-01T00:00:00Z',
|
||||
is_original_owner: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the workspace resolution + members list so the cloud app boots into the
|
||||
* given workspace with the given roster (drives the original-owner gate).
|
||||
*/
|
||||
export async function mockWorkspace(
|
||||
page: Page,
|
||||
ws: WorkspaceWithRole,
|
||||
members: Member[]
|
||||
) {
|
||||
await page.route('**/api/workspaces', async (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
await route.fulfill(jsonRoute({ workspaces: [ws] }))
|
||||
})
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
workspace: { id: ws.id, name: ws.name, type: ws.type },
|
||||
role: ws.role,
|
||||
permissions: []
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/workspace/members**', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
members,
|
||||
pagination: { offset: 0, limit: 50, total: members.length }
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
194
browser_tests/tests/billingFacadeConsumers.spec.ts
Normal file
194
browser_tests/tests/billingFacadeConsumers.spec.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type {
|
||||
BillingBalanceResponse,
|
||||
BillingStatusResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
/**
|
||||
* Billing facade consumers — FE-933 (B3) regression.
|
||||
*
|
||||
* The repointed surfaces (avatar popover tier badge / balance, free-tier
|
||||
* dialog renewal date) must keep rendering from `useBillingContext`. The facade
|
||||
* selects its backend by flag: `team_workspaces_enabled: false` routes through
|
||||
* the legacy `/customers/*` endpoints, while `true` routes a personal workspace
|
||||
* through the workspace `/api/billing/*` endpoints. Both shapes are mocked here.
|
||||
* Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
* against fully mocked endpoints — same pattern as creditsTile.spec.ts.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
const jsonRoute = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
// The workspace `/api/billing/status` shape mirrors the legacy subscription
|
||||
// status; map the fields so a single test fixture drives both backends.
|
||||
const toWorkspaceStatus = (
|
||||
s: CloudSubscriptionStatusResponse
|
||||
): BillingStatusResponse => ({
|
||||
is_active: s.is_active ?? false,
|
||||
subscription_tier: s.subscription_tier ?? undefined,
|
||||
subscription_duration: s.subscription_duration ?? undefined,
|
||||
renewal_date: s.renewal_date ?? undefined,
|
||||
cancel_at: s.end_date ?? undefined,
|
||||
has_funds: s.has_fund ?? true
|
||||
})
|
||||
|
||||
const mockBalance: BillingBalanceResponse = {
|
||||
amount_micros: 6000, // -> 12,660 credits
|
||||
currency: 'usd',
|
||||
effective_balance_micros: 6000
|
||||
}
|
||||
|
||||
async function mockCloudBoot(
|
||||
page: Page,
|
||||
subscriptionStatus: CloudSubscriptionStatusResponse,
|
||||
remoteConfig: RemoteConfig = { team_workspaces_enabled: false }
|
||||
) {
|
||||
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(remoteConfig)))
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// TutorialCompleted suppresses the new-user template browser, whose modal
|
||||
// overlay would otherwise intercept clicks on the topbar.
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(jsonRoute({ 'Comfy.TutorialCompleted': true }))
|
||||
)
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: 'Personal Workspace',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Legacy backend (team_workspaces_enabled: false).
|
||||
await page.route('**/customers/cloud-subscription-status', (r) =>
|
||||
r.fulfill(jsonRoute(subscriptionStatus))
|
||||
)
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(jsonRoute(mockBalance))
|
||||
)
|
||||
|
||||
// Workspace backend (team_workspaces_enabled: true) — a personal workspace
|
||||
// now routes through `/api/billing/*`.
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(jsonRoute(toWorkspaceStatus(subscriptionStatus)))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(jsonRoute(mockBalance))
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(jsonRoute({ plans: [] }))
|
||||
)
|
||||
}
|
||||
|
||||
async function bootApp(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Billing facade consumers (FE-933)', { tag: '@cloud' }, () => {
|
||||
test('avatar popover renders tier badge and balance from the facade', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-02-20T10:00:00Z',
|
||||
end_date: null
|
||||
})
|
||||
await bootApp(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
const popover = page.locator('.current-user-popover')
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
await expect(popover.getByText('Pro', { exact: true })).toBeVisible()
|
||||
await expect(popover.getByText('12,660')).toBeVisible()
|
||||
await expect(popover.getByTestId('add-credits-button')).toBeVisible()
|
||||
})
|
||||
|
||||
test('free-tier dialog shows the renewal date from the facade', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
// Boots with team workspaces enabled (production shape); the facade routes a
|
||||
// personal workspace through the workspace `/api/billing/*` endpoints. With
|
||||
// subscription gating on, an inactive FREE user gets the "Subscribe to run"
|
||||
// button, which opens the free-tier dialog on click. (refreshRemoteConfig
|
||||
// overwrites window.__CONFIG__ from /api/features, so the flags must come
|
||||
// from the features mock, not an init script.)
|
||||
await mockCloudBoot(
|
||||
page,
|
||||
{
|
||||
is_active: false,
|
||||
subscription_tier: 'FREE',
|
||||
subscription_duration: 'MONTHLY',
|
||||
// 10:00Z keeps the en-US calendar date stable across CI timezones.
|
||||
renewal_date: '2099-02-20T10:00:00Z',
|
||||
end_date: null
|
||||
},
|
||||
{ team_workspaces_enabled: true, subscription_required: true }
|
||||
)
|
||||
await bootApp(page)
|
||||
|
||||
await page.getByTestId('subscribe-to-run-button').click()
|
||||
|
||||
// T5: the dialog must source the date from facade renewalDate — when this
|
||||
// line read the legacy store it silently vanished for team users.
|
||||
await expect(
|
||||
page.getByText('Your credits refresh on Feb 20, 2099.')
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -4,8 +4,7 @@ import type { Page } from '@playwright/test'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
|
||||
/**
|
||||
* getSurveyCompletedStatus fails safe: a transient 401 on `/` must not bounce a
|
||||
@@ -16,51 +15,12 @@ import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
function jsonRoute(body: unknown) {
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
|
||||
async function mockCloudBoot(page: Page) {
|
||||
// `/api/features` is the remote-config source: production builds resolve
|
||||
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
|
||||
// dev-only). Enable the survey so the gate is actually live.
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ onboarding_survey_enabled: true } satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// Cloud user status (getUserCloudStatus) — an active account so the gate
|
||||
// proceeds to the survey check instead of bouncing back to login.
|
||||
await page.route('**/api/user', (r) =>
|
||||
r.fulfill(jsonRoute({ status: 'active' }))
|
||||
)
|
||||
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
}
|
||||
// `/api/features` is the remote-config source: production builds resolve
|
||||
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
|
||||
// dev-only). Enable the survey so the gate is actually live.
|
||||
const BOOT_FEATURES = {
|
||||
onboarding_survey_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
|
||||
// Genuine "not completed": the cloud backend returns 404 for a survey key that
|
||||
// was never stored. This is the response that must still route to the survey.
|
||||
@@ -89,22 +49,13 @@ async function mockSurveyTransient401(page: Page) {
|
||||
)
|
||||
}
|
||||
|
||||
async function bootCloud(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
// Pre-select the mock user to skip the user-select screen.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
|
||||
test('a transient 401 on the survey check does not bounce a working user to the survey', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page)
|
||||
await mockCloudBoot(page, { features: BOOT_FEATURES })
|
||||
await mockSurveyTransient401(page)
|
||||
await bootCloud(page)
|
||||
|
||||
@@ -122,9 +73,9 @@ test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
|
||||
test('a not-completed (404) user landing on / is routed to the survey', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page)
|
||||
await mockCloudBoot(page, { features: BOOT_FEATURES })
|
||||
await mockSurveyNotCompleted(page)
|
||||
await bootCloud(page)
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
|
||||
import type {
|
||||
BillingStatusResponse,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -51,6 +54,20 @@ const mockSubscriptionStatus: CloudSubscriptionStatusResponse = {
|
||||
end_date: FUTURE_DATE
|
||||
}
|
||||
|
||||
// With team workspaces enabled, the facade routes a personal workspace through
|
||||
// `/api/billing/*`. The cancelled-but-active state maps to `is_active: true`
|
||||
// with `subscription_status: 'canceled'`; a paid tier keeps "Add credits"
|
||||
// visible (free tier would swap it for "Upgrade to add credits").
|
||||
const mockBillingStatus: BillingStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_status: 'canceled',
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
has_funds: true,
|
||||
cancel_at: FUTURE_DATE,
|
||||
renewal_date: FUTURE_DATE
|
||||
}
|
||||
|
||||
// ~6.3M credits — a 7-digit balance is what pushes the second action button out
|
||||
// of the popover before the fix.
|
||||
const mockBalance: CustomerBalanceResponse = {
|
||||
@@ -105,6 +122,32 @@ const test = comfyPageFixture.extend({
|
||||
})
|
||||
)
|
||||
|
||||
// Flag-on (team workspaces enabled) routes a personal workspace through the
|
||||
// workspace billing endpoints, so the popover sources its data from here.
|
||||
await page.route('**/api/billing/status', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockBillingStatus)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/api/billing/balance', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockBalance)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/api/billing/plans', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ plans: [] })
|
||||
})
|
||||
)
|
||||
|
||||
await use(page)
|
||||
}
|
||||
})
|
||||
|
||||
264
browser_tests/tests/dialogs/creditsTile.spec.ts
Normal file
264
browser_tests/tests/dialogs/creditsTile.spec.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
|
||||
// devtools backend during setup.
|
||||
|
||||
/**
|
||||
* Credits tile (Settings ▸ Workspace ▸ Plan & Credits) — DES-247 / FE-964.
|
||||
*
|
||||
* The credits tile only lives inside the authenticated cloud app, which the
|
||||
* shared `comfyPage` fixture can't boot (it expects the OSS devtools backend).
|
||||
* Instead this drives a raw page: mock Firebase auth + every boot endpoint so
|
||||
* the cloud app initializes against fully stubbed data. With team workspaces
|
||||
* enabled the facade routes a personal workspace through the workspace
|
||||
* `/api/billing/*` endpoints (mocked with an active Pro subscription); the
|
||||
* legacy `/customers/*` shapes are mocked too for the flag-off path. The tile
|
||||
* should then render its total / progress bar / monthly+additional breakdown /
|
||||
* add-credits.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
const jsonRoute = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
// Legacy `/customers/balance` and workspace `/api/billing/balance` share the
|
||||
// same response shape, so one body fulfills both endpoints.
|
||||
const balanceRoute = (balance: {
|
||||
amount: number
|
||||
monthly: number
|
||||
prepaid: number
|
||||
}) =>
|
||||
jsonRoute({
|
||||
amount_micros: balance.amount,
|
||||
currency: 'usd',
|
||||
effective_balance_micros: balance.amount,
|
||||
cloud_credit_balance_micros: balance.monthly,
|
||||
prepaid_balance_micros: balance.prepaid
|
||||
})
|
||||
|
||||
// 6000 -> 12,660 total; 5000 -> 10,550 monthly remaining; 1000 -> 2,110 extra.
|
||||
const DEFAULT_BALANCE = { amount: 6000, monthly: 5000, prepaid: 1000 }
|
||||
|
||||
const mockBillingStatus: BillingStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-02-20T12:00:00Z',
|
||||
has_funds: true
|
||||
}
|
||||
|
||||
async function mockCloudBoot(page: Page) {
|
||||
// Frontend-origin boot endpoints (proxied to the backend in production).
|
||||
// `/api/features` is the remote-config source: production builds resolve
|
||||
// `teamWorkspacesEnabled` from it (the `ff:` localStorage override is
|
||||
// dev-only), and the flag gates the Workspace settings panel.
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ team_workspaces_enabled: true } satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
// Include the mock user so the multi-user select screen auto-selects it
|
||||
// (paired with the `Comfy.userId` localStorage seed below).
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// Non-empty settings with a completed tutorial keep the cloud app from
|
||||
// booting as a new user, whose Workflow Templates dialog would otherwise
|
||||
// auto-open and intercept the Settings click behind its modal backdrop.
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(jsonRoute({ 'Comfy.TutorialCompleted': true }))
|
||||
)
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: 'Personal Workspace',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Legacy billing (flag-off path, api.comfy.org/customers/*).
|
||||
await page.route('**/customers/cloud-subscription-status', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-02-20T12:00:00Z',
|
||||
end_date: null
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(balanceRoute(DEFAULT_BALANCE))
|
||||
)
|
||||
|
||||
// Workspace billing (flag-on path) — a personal workspace now routes through
|
||||
// `/api/billing/*`.
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(jsonRoute(mockBillingStatus))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(balanceRoute(DEFAULT_BALANCE))
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(jsonRoute({ plans: [] }))
|
||||
)
|
||||
}
|
||||
|
||||
async function mockBalance(
|
||||
page: Page,
|
||||
balance: { amount: number; monthly: number; prepaid: number }
|
||||
) {
|
||||
await page.unroute('**/customers/balance')
|
||||
await page.unroute('**/api/billing/balance')
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(balanceRoute(balance))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(balanceRoute(balance))
|
||||
)
|
||||
}
|
||||
|
||||
/** Boots the mocked cloud app and opens Settings ▸ Workspace ▸ Plan & Credits. */
|
||||
async function openPlanAndCredits(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
|
||||
// Pre-select the mock user to skip the user-select screen.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
// Open Settings ▸ Workspace.
|
||||
await page
|
||||
.getByRole('button', { name: /^Settings/ })
|
||||
.first()
|
||||
.click()
|
||||
const dialog = page.getByTestId('settings-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
|
||||
|
||||
return dialog.getByRole('main')
|
||||
}
|
||||
|
||||
test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
|
||||
test('renders the unified tile with breakdown and add-credits', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page)
|
||||
|
||||
const content = await openPlanAndCredits(page)
|
||||
|
||||
// Total + remaining suffix (Pro monthly allowance = 21,100; remaining
|
||||
// 10,550 -> used 10,550).
|
||||
await expect(content.getByText('Total credits')).toBeVisible()
|
||||
await expect(content.getByText('12,660')).toBeVisible()
|
||||
|
||||
// Monthly usage bar header + used / left-of-total labels.
|
||||
await expect(content.getByText('Monthly', { exact: true })).toBeVisible()
|
||||
await expect(content.getByText(/Refills Feb/)).toBeVisible()
|
||||
await expect(content.getByText('10,550 used')).toBeVisible()
|
||||
await expect(content.getByText('10,550 left of 21,100')).toBeVisible()
|
||||
|
||||
// Additional credits row + subtitle.
|
||||
await expect(content.getByText('Additional credits')).toBeVisible()
|
||||
await expect(content.getByText('2,110')).toBeVisible()
|
||||
await expect(content.getByText('Used after monthly runs out')).toBeVisible()
|
||||
|
||||
// Permission-gated add-credits action (personal owner can top up).
|
||||
await expect(
|
||||
content.getByRole('button', { name: 'Add credits' })
|
||||
).toBeVisible()
|
||||
|
||||
// Narrow container (DES-247 responsive variants): drop the used/remaining
|
||||
// labels and the breakdown subtitle, compact the monthly summary numbers.
|
||||
await page.setViewportSize({ width: 360, height: 800 })
|
||||
await expect(content.getByText('10,550 used')).toBeHidden()
|
||||
await expect(content.getByText('remaining', { exact: true })).toBeHidden()
|
||||
await expect(content.getByText('Used after monthly runs out')).toBeHidden()
|
||||
await expect(content.getByText('10,550 left of 21,100')).toBeHidden()
|
||||
await expect(content.getByText('11K left of 21K')).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders the depleted-credit empty states', async ({ page }) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page)
|
||||
// Monthly allowance fully spent; additional credits keep generation going.
|
||||
await mockBalance(page, { amount: 1000, monthly: 0, prepaid: 1000 })
|
||||
|
||||
const content = await openPlanAndCredits(page)
|
||||
|
||||
// 0-monthly state: depletion notice + IN USE badge on additional credits.
|
||||
await expect(
|
||||
content.getByText('Monthly credits are used up. Refills Feb 20')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
content.getByText("You're now spending additional credits.")
|
||||
).toBeVisible()
|
||||
await expect(content.getByText('In use')).toBeVisible()
|
||||
await expect(content.getByText('0 left of 21,100')).toBeVisible()
|
||||
|
||||
// Drain the remaining additional credits and refresh the tile: the
|
||||
// out-of-credits notice takes over and the badge drops.
|
||||
await mockBalance(page, { amount: 0, monthly: 0, prepaid: 0 })
|
||||
await content.getByRole('button', { name: 'Refresh credits' }).click()
|
||||
|
||||
await expect(
|
||||
content.getByText("You're out of credits. Credits refill Feb 20")
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
content.getByText('Add more credits to continue generating.')
|
||||
).toBeVisible()
|
||||
await expect(content.getByText('In use')).toBeHidden()
|
||||
await expect(
|
||||
content.getByRole('button', { name: 'Add credits' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
264
browser_tests/tests/dialogs/memberRoleChange.spec.ts
Normal file
264
browser_tests/tests/dialogs/memberRoleChange.spec.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { Member } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
CREATOR,
|
||||
MEMBER_JANE,
|
||||
MEMBER_JOHN,
|
||||
VIEWER
|
||||
} from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
|
||||
|
||||
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
|
||||
// devtools backend during setup.
|
||||
|
||||
/**
|
||||
* Member role change (Settings ▸ Workspace ▸ Members) — Figma 2993-15512.
|
||||
*
|
||||
* The viewer is a promoted owner (not the workspace creator), so the spec can
|
||||
* distinguish the creator guard from the self guard: the creator row and the
|
||||
* viewer's own row hide the row menu, every other row exposes
|
||||
* "Change role ›" (Owner / Member) plus "Remove member". Promoting a member
|
||||
* sends PATCH /api/workspace/members/:id {role}, flips the Role column,
|
||||
* re-sorts the row under the creator, and the promoted owner stays demotable.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
async function openMembersTab(page: Page): Promise<Locator> {
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: /^Settings/ })
|
||||
.first()
|
||||
.click()
|
||||
const dialog = page.getByTestId('settings-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
|
||||
|
||||
const content = dialog.getByRole('main')
|
||||
await content.getByRole('tab', { name: /Members/ }).click()
|
||||
await expect(content.getByText('4 of 30 members')).toBeVisible()
|
||||
return content
|
||||
}
|
||||
|
||||
function memberRow(content: Locator, email: string): Locator {
|
||||
return content
|
||||
.locator('div.grid')
|
||||
.filter({ has: content.page().getByText(email, { exact: true }) })
|
||||
}
|
||||
|
||||
function menuButton(row: Locator): Locator {
|
||||
return row.getByRole('button', { name: 'More Options' })
|
||||
}
|
||||
|
||||
// Reka submenus open on real pointer travel or keyboard; Playwright's
|
||||
// synthetic hover doesn't trigger the pointermove handler, so drive the
|
||||
// subtrigger with ArrowRight instead.
|
||||
async function openChangeRoleSubmenu(page: Page) {
|
||||
const trigger = page.getByRole('menuitem', { name: 'Change role' })
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.press('ArrowRight')
|
||||
await expect(
|
||||
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
|
||||
test.describe.configure({ timeout: 60_000 })
|
||||
|
||||
test('row menus respect creator and self guards', async ({ page }) => {
|
||||
await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
// US8/US9 — no row actions on the creator row (Liz) nor on the viewer's
|
||||
// own row; the two plain members each expose a menu.
|
||||
await expect(
|
||||
menuButton(memberRow(content, MEMBER_JOHN.email))
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
menuButton(memberRow(content, MEMBER_JANE.email))
|
||||
).toBeVisible()
|
||||
await expect(menuButton(memberRow(content, CREATOR.email))).toHaveCount(0)
|
||||
await expect(menuButton(memberRow(content, VIEWER.email))).toHaveCount(0)
|
||||
|
||||
// US1/US12 — the row menu exposes Change role and the FE-768 remove flow.
|
||||
await menuButton(memberRow(content, MEMBER_JANE.email)).click()
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Change role' })
|
||||
).toBeVisible()
|
||||
await page.getByRole('menuitem', { name: 'Remove member' }).click()
|
||||
await expect(page.getByText('Remove this member?')).toBeVisible()
|
||||
})
|
||||
|
||||
test('selecting the current role is a no-op', async ({ page }) => {
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
|
||||
// The current role is a checked radio item so assistive tech can announce
|
||||
// which role is active.
|
||||
await expect(
|
||||
page.getByRole('menuitemradio', { name: 'Member', exact: true })
|
||||
).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(
|
||||
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Member', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: /an owner\?/ })).toHaveCount(
|
||||
0
|
||||
)
|
||||
expect(state.patches).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('promote dialog shows the Figma copy and cancelling keeps the role', async ({
|
||||
page
|
||||
}) => {
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText("They'll be able to:")).toBeVisible()
|
||||
await expect(page.getByText('Add additional credits')).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('Manage members, payment methods, and workspace settings')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Promote and demote other owners (except the workspace creator).'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
).toHaveCount(0)
|
||||
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
|
||||
expect(state.patches).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('promoting a member re-sorts the row under the creator and stays demotable', async ({
|
||||
page
|
||||
}) => {
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const emails = content.getByText(/@test\.comfy\.org/)
|
||||
await expect(emails).toHaveText([
|
||||
CREATOR.email,
|
||||
VIEWER.email,
|
||||
MEMBER_JOHN.email,
|
||||
MEMBER_JANE.email
|
||||
])
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Make owner' }).click()
|
||||
|
||||
await expect(page.getByText('Role updated')).toBeVisible()
|
||||
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
|
||||
await expect(emails).toHaveText([
|
||||
CREATOR.email,
|
||||
VIEWER.email,
|
||||
MEMBER_JANE.email,
|
||||
MEMBER_JOHN.email
|
||||
])
|
||||
expect(state.patches).toEqual([
|
||||
{
|
||||
url: expect.stringContaining('/api/workspace/members/u-jane'),
|
||||
role: 'owner'
|
||||
}
|
||||
])
|
||||
|
||||
// The promoted owner keeps its row menu (still demotable).
|
||||
await expect(menuButton(janeRow)).toBeVisible()
|
||||
})
|
||||
|
||||
test('demoting an owner returns them to member', async ({ page }) => {
|
||||
const ownerJane: Member = { ...MEMBER_JANE, role: 'owner' }
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup([
|
||||
CREATOR,
|
||||
VIEWER,
|
||||
ownerJane,
|
||||
MEMBER_JOHN
|
||||
])
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
|
||||
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Member', exact: true })
|
||||
.click()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Demote Jane to member?' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText("They'll lose admin access.")).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Demote to member' }).click()
|
||||
|
||||
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
|
||||
expect(state.patches).toEqual([
|
||||
{
|
||||
url: expect.stringContaining('/api/workspace/members/u-jane'),
|
||||
role: 'member'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('failed role change keeps the dialog open with an error toast', async ({
|
||||
page
|
||||
}) => {
|
||||
await new CloudWorkspaceMockHelper(page).setup()
|
||||
// Override the member route so PATCH fails after boot succeeds.
|
||||
await page.route('**/api/workspace/members/**', (route) =>
|
||||
route.request().method() === 'PATCH'
|
||||
? route.fulfill({ status: 500, body: '{}' })
|
||||
: route.fallback()
|
||||
)
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Make owner' }).click()
|
||||
|
||||
// US10 — error toast, dialog stays open, role unchanged.
|
||||
await expect(page.getByText('Failed to update role')).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
|
||||
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
128
browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts
Normal file
128
browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type {
|
||||
Member,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockBilling } from '@e2e/fixtures/utils/cloudBillingMocks'
|
||||
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
import {
|
||||
member,
|
||||
mockWorkspace,
|
||||
workspace
|
||||
} from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
/**
|
||||
* The `?pricing=` deep link opens the pricing table on app load, gated to the
|
||||
* original owner (canManageSubscriptionLifecycle). Drives a raw `page` so the
|
||||
* cloud app boots against fully mocked endpoints, like the survey-gate spec.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
// CloudAuthHelper.mockAuth() signs in as this email; the original-owner gate
|
||||
// matches it against the members self-row.
|
||||
const SELF_EMAIL = 'e2e@test.comfy.org'
|
||||
|
||||
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
|
||||
// Disable the experimental Asset API: with it on (cloud default) the unmocked
|
||||
// asset endpoints 403 and workflow restore throws uncaught, aborting the
|
||||
// GraphCanvas onMounted chain before the deep-link loader.
|
||||
const BOOT_SETTINGS = { 'Comfy.Assets.UseAssetAPI': false }
|
||||
|
||||
// The deep-link loader runs at the tail of GraphCanvas onMounted, so the boot
|
||||
// chain must not throw before it: a missing settings subpath, prompt exec_info,
|
||||
// or queue status each abort that chain.
|
||||
async function mockGraphBootExtras(page: Page) {
|
||||
// Boot only reads these; fall back on any write so an unexpected POST/PUT
|
||||
// surfaces instead of being masked by a blanket 200.
|
||||
await page.route('**/api/settings/**', (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
return route.fulfill(jsonRoute({}))
|
||||
})
|
||||
await page.route('**/api/prompt', (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
return route.fulfill(jsonRoute({ exec_info: { queue_remaining: 0 } }))
|
||||
})
|
||||
await page.route('**/api/queue', (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
return route.fulfill(jsonRoute({ queue_running: [], queue_pending: [] }))
|
||||
})
|
||||
}
|
||||
|
||||
async function setupCloudApp(
|
||||
page: Page,
|
||||
ws: WorkspaceWithRole,
|
||||
members: Member[]
|
||||
) {
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
})
|
||||
await mockGraphBootExtras(page)
|
||||
await mockBilling(page)
|
||||
await mockWorkspace(page, ws, members)
|
||||
await bootCloud(page)
|
||||
}
|
||||
|
||||
const pricingHeading = (page: Page) =>
|
||||
page.getByRole('heading', { name: 'Choose a Plan' })
|
||||
|
||||
test.describe('Pricing table deep link', { tag: '@cloud' }, () => {
|
||||
test('opens the pricing table for a personal owner', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('personal', 'owner'), [])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=1`)
|
||||
|
||||
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
|
||||
await expect(page).not.toHaveURL(/[?&]pricing=/)
|
||||
})
|
||||
|
||||
test('opens on the Team tab for ?pricing=team', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('personal', 'owner'), [])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=team`)
|
||||
|
||||
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'For Teams' })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('opens for a team original owner', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('team', 'owner'), [
|
||||
member({ email: SELF_EMAIL, role: 'owner', is_original_owner: true })
|
||||
])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=1`)
|
||||
|
||||
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
|
||||
})
|
||||
|
||||
test('is a silent no-op for a team member', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('team', 'member'), [
|
||||
member({
|
||||
email: 'creator@test.comfy.org',
|
||||
role: 'owner',
|
||||
is_original_owner: true
|
||||
}),
|
||||
member({ email: SELF_EMAIL, role: 'member' })
|
||||
])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=1`)
|
||||
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
await expect(page).not.toHaveURL(/[?&]pricing=/)
|
||||
await expect(pricingHeading(page)).toBeHidden()
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -10,18 +10,7 @@ import {
|
||||
countAssetRequestsByTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import {
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
expectNoMissingModelUi,
|
||||
loadPromotedMissingModelAndOpenErrorsTab,
|
||||
selectLegacyPromotedAssetModel,
|
||||
selectSectionAssetPromotedModel,
|
||||
selectVueAssetPromotedModel
|
||||
} from '@e2e/fixtures/utils/promotedMissingModel'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
@@ -31,8 +20,6 @@ const WORKFLOW = 'missing/nested_subgraph_installed_model'
|
||||
const IMPORT_SECTIONS_WORKFLOW = 'missing/cloud_missing_model_import_sections'
|
||||
const OUTER_SUBGRAPH_NODE_ID = '205'
|
||||
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
|
||||
const FAKE_MODEL_NAME = 'fake_model.safetensors'
|
||||
const RESOLVED_PROMOTED_MODEL_NAME = 'resolved_model.safetensors'
|
||||
const CLOUD_IMPORTABLE_MODEL_NAME = 'cloud_importable_model.safetensors'
|
||||
const CLOUD_UNKNOWN_MODEL_NAME = 'cloud_unknown_model.safetensors'
|
||||
const CLOUD_IMPORTED_CANONICAL_MODEL_NAME =
|
||||
@@ -68,25 +55,7 @@ const EXISTING_CLOUD_IMPORTABLE_MODEL: Asset & { hash?: string } = {
|
||||
}
|
||||
}
|
||||
|
||||
const RESOLVED_PROMOTED_MODEL_ASSET: Asset & { hash?: string } = {
|
||||
id: 'test-resolved-promoted-model',
|
||||
name: RESOLVED_PROMOTED_MODEL_NAME,
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000205',
|
||||
size: 1_024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
created_at: '2026-05-05T00:00:00Z',
|
||||
updated_at: '2026-05-05T00:00:00Z',
|
||||
last_access_time: '2026-05-05T00:00:00Z',
|
||||
user_metadata: {
|
||||
filename: RESOLVED_PROMOTED_MODEL_NAME
|
||||
}
|
||||
}
|
||||
|
||||
const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
|
||||
const promotedModelTest = createCloudAssetsFixture([
|
||||
RESOLVED_PROMOTED_MODEL_ASSET
|
||||
])
|
||||
|
||||
function getRequestedIncludeTags(requestUrl: string): string[] {
|
||||
return (
|
||||
@@ -394,84 +363,3 @@ test.describe(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest.describe(
|
||||
'Errors tab - Cloud promoted subgraph missing models',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
promotedModelTest.beforeEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
promotedModelTest.afterEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
promotedModelTest(
|
||||
'Changing a Cloud Vue promoted asset widget clears a nested subgraph error',
|
||||
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
|
||||
await selectVueAssetPromotedModel(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest(
|
||||
'Changing a Cloud Vue promoted asset from the Parameters tab clears a nested subgraph error',
|
||||
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
|
||||
await selectSectionAssetPromotedModel(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest(
|
||||
'Changing a Cloud legacy promoted asset clears a nested subgraph error',
|
||||
{ tag: ['@canvas', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
|
||||
await selectLegacyPromotedAssetModel(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
RESOLVED_PROMOTED_MODEL_ASSET.id
|
||||
)
|
||||
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,10 +8,6 @@ import {
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
routeObjectInfoFromSetupApi,
|
||||
setComboInputOptions
|
||||
} from '@e2e/fixtures/utils/objectInfo'
|
||||
import {
|
||||
createRouteMockJob,
|
||||
jobsRouteFixture
|
||||
@@ -90,6 +86,50 @@ interface CloudUploadAssetState {
|
||||
isUploadedAssetAvailable: boolean
|
||||
}
|
||||
|
||||
type ObjectInfoResponse = Record<
|
||||
string,
|
||||
{ input?: { required?: Record<string, unknown> } }
|
||||
>
|
||||
|
||||
function setComboInputOptions(
|
||||
objectInfo: ObjectInfoResponse,
|
||||
nodeType: string,
|
||||
inputName: string,
|
||||
values: string[]
|
||||
) {
|
||||
const nodeInfo = objectInfo[nodeType]
|
||||
if (!nodeInfo) {
|
||||
throw new Error(`Missing object_info entry for ${nodeType}`)
|
||||
}
|
||||
|
||||
const requiredInputs = nodeInfo.input?.required
|
||||
if (!requiredInputs) {
|
||||
throw new Error(`Missing required inputs for ${nodeType}`)
|
||||
}
|
||||
|
||||
const input = requiredInputs[inputName]
|
||||
if (!Array.isArray(input)) {
|
||||
throw new Error(`Expected ${nodeType}.${inputName} to be a combo input`)
|
||||
}
|
||||
|
||||
const [valuesOrType, options] = input
|
||||
const optionsObject =
|
||||
options && typeof options === 'object' && !Array.isArray(options)
|
||||
if (Array.isArray(valuesOrType)) {
|
||||
input[0] = values
|
||||
} else if (valuesOrType !== 'COMBO') {
|
||||
throw new Error(`Expected ${nodeType}.${inputName} to have combo options`)
|
||||
}
|
||||
|
||||
if (optionsObject) {
|
||||
Object.assign(options, { options: values })
|
||||
} else if (!Array.isArray(valuesOrType)) {
|
||||
throw new Error(
|
||||
`Expected ${nodeType}.${inputName} to have options metadata`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function routeCloudBootstrapApis(page: Page) {
|
||||
await page.route('**/api/settings**', async (route) => {
|
||||
await route.fulfill({
|
||||
@@ -121,10 +161,57 @@ async function routeCloudBootstrapApis(page: Page) {
|
||||
})
|
||||
}
|
||||
|
||||
async function routeSetupObjectInfo(
|
||||
page: Page,
|
||||
customize?: (objectInfo: ObjectInfoResponse) => void
|
||||
) {
|
||||
const setupApiUrl =
|
||||
process.env.PLAYWRIGHT_SETUP_API_URL ?? 'http://127.0.0.1:8188'
|
||||
const objectInfoUrl = new URL('/object_info', setupApiUrl).toString()
|
||||
|
||||
const objectInfoRouteHandler = async (route: Route) => {
|
||||
try {
|
||||
const response = await fetch(objectInfoUrl, {
|
||||
signal: AbortSignal.timeout(5_000)
|
||||
})
|
||||
if (!response.ok) {
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
contentType: response.headers.get('content-type') ?? 'text/plain',
|
||||
body: await response.text()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const objectInfo = (await response.json()) as ObjectInfoResponse
|
||||
customize?.(objectInfo)
|
||||
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(objectInfo)
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
await route.fulfill({
|
||||
status: 502,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: `Failed to fetch setup object_info from ${objectInfoUrl}: ${message}`
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await page.route('**/object_info', objectInfoRouteHandler)
|
||||
return async () =>
|
||||
await page.unroute('**/object_info', objectInfoRouteHandler)
|
||||
}
|
||||
|
||||
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
|
||||
const unrouteObjectInfo = await routeSetupObjectInfo(page)
|
||||
|
||||
try {
|
||||
await use(page)
|
||||
@@ -138,16 +225,13 @@ const cloudEmptyMediaInputsTest = createCloudAssetsFixture([]).extend({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
|
||||
page,
|
||||
(objectInfo) => {
|
||||
for (const node of emptyMediaLoaderNodes) {
|
||||
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
|
||||
node.serverOnlyOption
|
||||
])
|
||||
}
|
||||
const unrouteObjectInfo = await routeSetupObjectInfo(page, (objectInfo) => {
|
||||
for (const node of emptyMediaLoaderNodes) {
|
||||
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
|
||||
node.serverOnlyOption
|
||||
])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
try {
|
||||
await use(page)
|
||||
@@ -162,7 +246,7 @@ const cloudUploadRaceTest = comfyPageFixture.extend<{
|
||||
}>({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
|
||||
const unrouteObjectInfo = await routeSetupObjectInfo(page)
|
||||
|
||||
const state: CloudUploadAssetState = {
|
||||
isUploadedAssetAvailable: false
|
||||
|
||||
@@ -8,46 +8,12 @@ import {
|
||||
openErrorsTab,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import {
|
||||
appendComboInputOptions,
|
||||
routeObjectInfoFromSetupApi
|
||||
} from '@e2e/fixtures/utils/objectInfo'
|
||||
import {
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
expectMissingModelReferenceCount,
|
||||
expectNoMissingModelUi,
|
||||
expectResolvedPromotedModelSuppressesStaleInteriorErrors,
|
||||
expectSingleMissingModelReference,
|
||||
getMissingModelLabel,
|
||||
loadPromotedMissingModelAndOpenErrorsTab,
|
||||
loadPromotedMissingModelWithHostValuesAndOpenErrorsTab,
|
||||
selectSectionComboPromotedModel,
|
||||
selectVueComboPromotedModelByTitle,
|
||||
setLegacyPromotedComboModel
|
||||
} from '@e2e/fixtures/utils/promotedMissingModel'
|
||||
|
||||
const FAKE_MODEL_NAME = 'fake_model.safetensors'
|
||||
const RESOLVED_PROMOTED_MODEL_NAME = 'resolved_model.safetensors'
|
||||
|
||||
const promotedModelTest = test.extend({
|
||||
page: async ({ page }, use) => {
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
|
||||
page,
|
||||
(objectInfo) =>
|
||||
appendComboInputOptions(
|
||||
objectInfo,
|
||||
'CheckpointLoaderSimple',
|
||||
'ckpt_name',
|
||||
[RESOLVED_PROMOTED_MODEL_NAME]
|
||||
)
|
||||
)
|
||||
try {
|
||||
await use(page)
|
||||
} finally {
|
||||
await unrouteObjectInfo()
|
||||
}
|
||||
}
|
||||
})
|
||||
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
|
||||
return group.getByRole('button', { name: modelName, exact: true })
|
||||
}
|
||||
|
||||
async function expectReferenceBadge(group: Locator, count: number) {
|
||||
await expect(
|
||||
@@ -203,9 +169,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
await expect(
|
||||
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
@@ -301,9 +265,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
|
||||
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node1.click('title')
|
||||
await expect(
|
||||
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(
|
||||
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
|
||||
).toHaveCount(1)
|
||||
@@ -419,184 +381,92 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
promotedModelTest(
|
||||
'Changing an OSS Vue promoted model clears a nested subgraph error',
|
||||
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
let missingModelGroup: Locator
|
||||
|
||||
await test.step('A: shared-definition active host reports the missing model', async () => {
|
||||
missingModelGroup = await loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('B: bypassing the resolved sibling host keeps the active host error visible', async () => {
|
||||
const siblingHostNodeId =
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeId
|
||||
if (siblingHostNodeId === undefined) {
|
||||
throw new Error('Expected a shared-definition sibling host')
|
||||
}
|
||||
|
||||
const siblingHost =
|
||||
await comfyPage.nodeOps.getNodeRefById(siblingHostNodeId)
|
||||
await siblingHost.centerOnNode()
|
||||
await siblingHost.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => siblingHost.isBypassed()).toBeTruthy()
|
||||
await comfyPage.canvas.click({ position: { x: 700, y: 650 } })
|
||||
await openErrorsTab(comfyPage)
|
||||
await expectSingleMissingModelReference(
|
||||
missingModelGroup,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('C: changing the active host promoted widget resolves the model', async () => {
|
||||
const activeHost = await comfyPage.nodeOps.getNodeRefById(
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeId
|
||||
)
|
||||
await activeHost.centerOnNode()
|
||||
await selectVueComboPromotedModelByTitle(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('D: the missing model UI clears', async () => {
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
})
|
||||
|
||||
await test.step('E: two missing shared-definition hosts report two references', async () => {
|
||||
const siblingHostNodeId =
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeId
|
||||
if (siblingHostNodeId === undefined) {
|
||||
throw new Error('Expected a shared-definition sibling host')
|
||||
}
|
||||
|
||||
missingModelGroup =
|
||||
await loadPromotedMissingModelWithHostValuesAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
{
|
||||
[siblingHostNodeId]: FAKE_MODEL_NAME,
|
||||
[NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeId]:
|
||||
FAKE_MODEL_NAME
|
||||
},
|
||||
FAKE_MODEL_NAME,
|
||||
2
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('F: changing one missing host leaves the other missing reference', async () => {
|
||||
await selectVueComboPromotedModelByTitle(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
await expectMissingModelReferenceCount(
|
||||
missingModelGroup,
|
||||
FAKE_MODEL_NAME,
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('G: changing the remaining missing host clears the model error', async () => {
|
||||
const siblingHostTitle =
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeTitle
|
||||
if (siblingHostTitle === undefined) {
|
||||
throw new Error('Expected a shared-definition sibling host title')
|
||||
}
|
||||
|
||||
await selectVueComboPromotedModelByTitle(
|
||||
comfyPage,
|
||||
siblingHostTitle,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest(
|
||||
'Changing an OSS Vue promoted model from the Parameters tab clears a nested subgraph error',
|
||||
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
|
||||
await selectSectionComboPromotedModel(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest(
|
||||
'Changing an OSS legacy promoted model clears a nested subgraph error',
|
||||
test(
|
||||
'Resolving a promoted missing model widget through the legacy canvas path clears its error',
|
||||
{ tag: ['@canvas', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
const resolvedModelName = 'v1-5-pruned-emaonly-fp16.safetensors'
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await loadPromotedMissingModelAndOpenErrorsTab(
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME
|
||||
'missing/missing_model_promoted_widget'
|
||||
)
|
||||
|
||||
await setLegacyPromotedComboModel(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
await comfyPage.page.evaluate((value) => {
|
||||
const hostNode = window.app!.graph!.getNodeById(2)
|
||||
if (!hostNode?.isSubgraphNode()) {
|
||||
throw new Error('Expected subgraph host node')
|
||||
}
|
||||
|
||||
const interiorNode = hostNode.subgraph.getNodeById(1)
|
||||
const widget = interiorNode?.widgets?.find(
|
||||
(entry) => entry.name === 'ckpt_name'
|
||||
)
|
||||
type SettableWidget = typeof widget & {
|
||||
setValue?: (
|
||||
value: string,
|
||||
options: {
|
||||
e: PointerEvent
|
||||
node: unknown
|
||||
canvas: unknown
|
||||
}
|
||||
) => void
|
||||
}
|
||||
const settableWidget = widget as SettableWidget | undefined
|
||||
|
||||
if (!settableWidget?.setValue) {
|
||||
throw new Error('Expected concrete ckpt_name widget')
|
||||
}
|
||||
|
||||
settableWidget.setValue(value, {
|
||||
e: new PointerEvent('pointerup'),
|
||||
node: hostNode,
|
||||
canvas: window.app!.canvas
|
||||
})
|
||||
}, resolvedModelName)
|
||||
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest(
|
||||
test(
|
||||
'Refreshing a resolved promoted missing model clears the combo invalid state',
|
||||
{ tag: ['@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.workflowName
|
||||
'missing/missing_model_promoted_widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(
|
||||
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
|
||||
const promotedModelCombo = comfyPage.vueNodes
|
||||
.getNodeByTitle(NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle)
|
||||
.getNodeByTitle('Subgraph with Promoted Missing Model')
|
||||
.getByRole('combobox', { name: 'ckpt_name', exact: true })
|
||||
await expect(promotedModelCombo).toHaveAttribute('aria-invalid', 'true')
|
||||
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
|
||||
comfyPage.page,
|
||||
(objectInfo) =>
|
||||
appendComboInputOptions(
|
||||
objectInfo,
|
||||
'CheckpointLoaderSimple',
|
||||
'ckpt_name',
|
||||
[FAKE_MODEL_NAME, RESOLVED_PROMOTED_MODEL_NAME]
|
||||
)
|
||||
)
|
||||
|
||||
const objectInfoRoute = /\/object_info$/
|
||||
try {
|
||||
await comfyPage.page.route(objectInfoRoute, async (route) => {
|
||||
const response = await route.fetch()
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.dialogs.missingModelRefresh)
|
||||
.click()
|
||||
@@ -608,31 +478,11 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
'true'
|
||||
)
|
||||
} finally {
|
||||
await unrouteObjectInfo()
|
||||
await comfyPage.page.unroute(objectInfoRoute)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest(
|
||||
'Reloading a resolved nested promoted model ignores stale interior values',
|
||||
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await expectResolvedPromotedModelSuppressesStaleInteriorErrors(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
[
|
||||
{
|
||||
subgraphNodeIdToEnter: '4',
|
||||
nodeTitle: 'Inner Subgraph with Promoted Missing Model'
|
||||
},
|
||||
{ subgraphNodeIdToEnter: '2', nodeTitle: 'Load Checkpoint' }
|
||||
],
|
||||
RESOLVED_PROMOTED_MODEL_NAME,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 (
|
||||
@@ -335,6 +336,79 @@ 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' },
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('tooltips', { tag: '@vue-nodes' }, async () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.settings.setSetting('LiteGraph.Node.TooltipDelay', 0)
|
||||
})
|
||||
|
||||
test('widget value tooltips', async ({ comfyPage }) => {
|
||||
const tooltip = comfyPage.page.locator('.p-tooltip-text')
|
||||
await comfyPage.vueNodes.getWidgetByName('load check', 'ckpt_name').hover()
|
||||
await expect(tooltip, 'displays for combos').toContainText('v1-5-pruned')
|
||||
|
||||
await comfyPage.vueNodes.getWidgetByName('ksampler', 'seed').hover()
|
||||
await expect(tooltip, 'displays for numbers').toContainText('15668')
|
||||
|
||||
await comfyPage.vueNodes.getNodeLocator('6').getByLabel('text').hover()
|
||||
await expect(tooltip).toBeVisible()
|
||||
await expect(tooltip, "doesn't display for prompts").not.toContainText(
|
||||
'purple galaxy bottle'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -73,4 +73,16 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(widget, 'Widget has restored value').toHaveText('scale width')
|
||||
})
|
||||
|
||||
test('Dynamic children have separate state', async ({ comfyPage }) => {
|
||||
const nodeName = 'Node With Dynamic Combo'
|
||||
await comfyPage.searchBoxV2.addNode(nodeName, {
|
||||
position: { x: 200, y: 150 }
|
||||
})
|
||||
const child = comfyPage.vueNodes.getWidgetByName(nodeName, 'suboption')
|
||||
await expect(child, 'initial state').toHaveText('1x')
|
||||
|
||||
await comfyPage.vueNodes.selectComboOption(nodeName, 'combo', 'option2')
|
||||
await expect(child, 'child of same name has new state').toHaveText('2x')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.47.3",
|
||||
"version": "1.47.5",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -19,7 +19,10 @@
|
||||
"size:collect": "node scripts/size-collect.js",
|
||||
"size:report": "node scripts/size-report.js",
|
||||
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
|
||||
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' vite --config vite.config.mts",
|
||||
"dev:cloud": "pnpm dev:cloud:test",
|
||||
"dev:cloud:test": "cross-env DEV_SERVER_COMFYUI_URL=https://testcloud.comfy.org/ vite --config vite.config.mts",
|
||||
"dev:cloud:staging": "cross-env DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/ vite --config vite.config.mts",
|
||||
"dev:cloud:prod": "cross-env DEV_SERVER_COMFYUI_URL=https://cloud.comfy.org/ vite --config vite.config.mts",
|
||||
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
|
||||
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
|
||||
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
--color-gold-500: #fdab34;
|
||||
--color-gold-600: #fd9903;
|
||||
|
||||
--color-credit: #fabc25;
|
||||
|
||||
--color-coral-500: #f75951;
|
||||
--color-coral-600: #e04e48;
|
||||
--color-coral-700: #b33a3a;
|
||||
@@ -236,6 +238,8 @@
|
||||
--secondary-background: var(--color-smoke-200);
|
||||
--secondary-background-hover: var(--color-smoke-400);
|
||||
--secondary-background-selected: var(--color-smoke-600);
|
||||
--tertiary-background: var(--color-smoke-400);
|
||||
--tertiary-background-hover: var(--color-smoke-500);
|
||||
--base-background: var(--color-white);
|
||||
--primary-background: var(--color-azure-400);
|
||||
--primary-background-hover: var(--color-cobalt-800);
|
||||
@@ -384,6 +388,8 @@
|
||||
--secondary-background: var(--color-charcoal-600);
|
||||
--secondary-background-hover: var(--color-charcoal-400);
|
||||
--secondary-background-selected: var(--color-charcoal-200);
|
||||
--tertiary-background: var(--color-charcoal-400);
|
||||
--tertiary-background-hover: var(--color-charcoal-300);
|
||||
--base-background: var(--color-charcoal-800);
|
||||
--primary-background: var(--color-azure-600);
|
||||
--primary-background-hover: var(--color-azure-400);
|
||||
@@ -554,6 +560,8 @@
|
||||
--color-secondary-background: var(--secondary-background);
|
||||
--color-secondary-background-hover: var(--secondary-background-hover);
|
||||
--color-secondary-background-selected: var(--secondary-background-selected);
|
||||
--color-tertiary-background: var(--tertiary-background);
|
||||
--color-tertiary-background-hover: var(--tertiary-background-hover);
|
||||
--color-primary-background: var(--primary-background);
|
||||
--color-primary-background-hover: var(--primary-background-hover);
|
||||
--color-destructive-background: var(--destructive-background);
|
||||
|
||||
@@ -344,6 +344,15 @@ export const zDynamicComboInputSpec = z.tuple([
|
||||
})
|
||||
])
|
||||
|
||||
export const zDynamicGroupInputSpec = z.tuple([
|
||||
z.literal('COMFY_DYNAMICGROUP_V3'),
|
||||
zBaseInputOptions.extend({
|
||||
template: zComfyInputsSpec,
|
||||
min: z.number().int().nonnegative().optional().default(0),
|
||||
max: z.number().int().positive().max(100).optional().default(50)
|
||||
})
|
||||
])
|
||||
|
||||
export const zMatchTypeOptions = z.object({
|
||||
...zBaseInputOptions.shape,
|
||||
type: z.literal('COMFY_MATCHTYPE_V3'),
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineOptions({
|
||||
@@ -50,11 +52,27 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem
|
||||
v-else
|
||||
:class="itemClass"
|
||||
v-tooltip="
|
||||
item.tooltip ? { value: String(item.tooltip), showDelay: 0 } : undefined
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
itemClass,
|
||||
String(item.class ?? ''),
|
||||
Boolean(item.tooltip) && toValue(item.disabled) && 'pointer-events-auto'
|
||||
)
|
||||
"
|
||||
v-bind="
|
||||
'checked' in item
|
||||
? { role: 'menuitemradio', 'aria-checked': Boolean(item.checked) }
|
||||
: {}
|
||||
"
|
||||
:disabled="toValue(item.disabled) ?? !item.command"
|
||||
@select="item.command?.({ originalEvent: $event, item })"
|
||||
>
|
||||
<i class="size-5 shrink-0" :class="item.icon" />
|
||||
<!-- Items declaring an icon key (even empty) keep the slot so labels align
|
||||
within icon-bearing menus; icon-less menus render labels flush-left. -->
|
||||
<i v-if="'icon' in item" class="size-5 shrink-0" :class="item.icon" />
|
||||
<div class="mr-auto truncate" v-text="item.label" />
|
||||
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import {
|
||||
DropdownMenuArrow,
|
||||
@@ -7,13 +8,16 @@ import {
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, toValue } from 'vue'
|
||||
import { computed, ref, toValue } from 'vue'
|
||||
|
||||
import DropdownItem from '@/components/common/DropdownItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { ButtonVariants } from '../ui/button/button.variants'
|
||||
|
||||
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
|
||||
const MODAL_BASE_Z_INDEX = 1700
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
@@ -41,10 +45,20 @@ const contentClass = computed(() =>
|
||||
contentProp
|
||||
)
|
||||
)
|
||||
|
||||
// Body-portaled content keeps its static z-1700 unless a dialog that joined
|
||||
// @primeuix's auto-incrementing 'modal' counter is open above it; then lift
|
||||
// past that dialog so the menu isn't hidden behind it.
|
||||
const open = ref(false)
|
||||
const contentStyle = computed(() => {
|
||||
if (!open.value) return undefined
|
||||
const topZIndex = ZIndex.getCurrent('modal')
|
||||
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuRoot v-model:open="open">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
|
||||
@@ -60,6 +74,7 @@ const contentClass = computed(() =>
|
||||
:collision-padding="10"
|
||||
v-bind="$attrs"
|
||||
:class="contentClass"
|
||||
:style="contentStyle"
|
||||
>
|
||||
<slot :item-class>
|
||||
<DropdownItem
|
||||
|
||||
56
src/components/common/DropdownMenu.zindex.test.ts
Normal file
56
src/components/common/DropdownMenu.zindex.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import DropdownMenu from './DropdownMenu.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderMenu() {
|
||||
return render(DropdownMenu, {
|
||||
props: { entries: [{ label: 'Item A' }] },
|
||||
global: { plugins: [i18n], directives: { tooltip: {} } }
|
||||
})
|
||||
}
|
||||
|
||||
let openModal: HTMLElement | undefined
|
||||
|
||||
afterEach(() => {
|
||||
if (openModal) {
|
||||
ZIndex.clear(openModal)
|
||||
openModal = undefined
|
||||
}
|
||||
})
|
||||
|
||||
describe('DropdownMenu z-index', () => {
|
||||
it('opens above a dialog registered with the modal z-index counter', async () => {
|
||||
openModal = document.createElement('div')
|
||||
ZIndex.set('modal', openModal, 1700)
|
||||
const dialogZ = Number(openModal.style.zIndex)
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderMenu()
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
const menu = await screen.findByRole('menu')
|
||||
expect(Number(menu.style.zIndex)).toBeGreaterThan(dialogZ)
|
||||
})
|
||||
|
||||
it('leaves the static z-index untouched when no dialog is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderMenu()
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
const menu = await screen.findByRole('menu')
|
||||
expect(menu.style.zIndex).toBe('')
|
||||
expect(menu.className).toContain('z-1700')
|
||||
})
|
||||
})
|
||||
@@ -37,7 +37,7 @@
|
||||
<Message v-if="userIsInChina" severity="warn" class="mb-4">
|
||||
{{ t('auth.signup.regionRestrictionChina') }}
|
||||
</Message>
|
||||
<SignUpForm v-else @submit="signUpWithEmail" />
|
||||
<SignUpForm v-else ref="signUpForm" @submit="signUpWithEmail" />
|
||||
</template>
|
||||
|
||||
<!-- Divider -->
|
||||
@@ -206,9 +206,21 @@ const signInWithEmail = async (values: SignInData) => {
|
||||
}
|
||||
}
|
||||
|
||||
const signUpWithEmail = async (values: SignUpData) => {
|
||||
if (await authActions.signUpWithEmail(values.email, values.password)) {
|
||||
const signUpForm = ref<InstanceType<typeof SignUpForm> | null>(null)
|
||||
|
||||
const signUpWithEmail = async (values: SignUpData, turnstileToken?: string) => {
|
||||
if (
|
||||
await authActions.signUpWithEmail(
|
||||
values.email,
|
||||
values.password,
|
||||
turnstileToken
|
||||
)
|
||||
) {
|
||||
onSuccess()
|
||||
} else {
|
||||
// Signup failed while the form is still mounted: re-arm the single-use
|
||||
// Turnstile token so the next attempt sends a fresh one.
|
||||
signUpForm.value?.resetTurnstile()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
97
src/components/dialog/content/setting/CreditsPanel.vue
Normal file
97
src/components/dialog/content/setting/CreditsPanel.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="credits-container flex h-full flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('credits.credits') }}
|
||||
</h2>
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<CreditsTile />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="m-0">{{ $t('credits.activity') }}</h3>
|
||||
<Button variant="muted-textonly" @click="handleCreditsHistoryClick">
|
||||
<i class="pi pi-arrow-up-right" />
|
||||
{{ $t('credits.invoiceHistory') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<UsageLogsTable ref="usageLogsTableRef" />
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button variant="muted-textonly" @click="handleFaqClick">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('credits.faqs') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleOpenPartnerNodesInfo">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('subscription.partnerNodesCredits') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleMessageSupport">
|
||||
<i class="pi pi-comments" />
|
||||
{{ $t('credits.messageSupport') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Divider from 'primevue/divider'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const authStore = useAuthStore()
|
||||
const authActions = useAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
|
||||
|
||||
watch(
|
||||
() => authStore.lastBalanceUpdateTime,
|
||||
(newTime, oldTime) => {
|
||||
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
|
||||
void usageLogsTableRef.value.refresh()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleCreditsHistoryClick = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
const handleMessageSupport = async () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'credits_panel'
|
||||
})
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
const handleFaqClick = () => {
|
||||
window.open(
|
||||
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -1,195 +0,0 @@
|
||||
<template>
|
||||
<div class="credits-container h-full">
|
||||
<!-- Legacy Design -->
|
||||
<div class="flex h-full flex-col">
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('credits.credits') }}
|
||||
</h2>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-sm font-medium text-muted">
|
||||
{{ $t('credits.yourCreditBalance') }}
|
||||
</h3>
|
||||
<div class="flex items-center justify-between">
|
||||
<UserCredit text-class="text-3xl font-bold" />
|
||||
<Skeleton v-if="loading" width="2rem" height="2rem" />
|
||||
<Button
|
||||
v-else-if="isActiveSubscription"
|
||||
:loading="loading"
|
||||
@click="handlePurchaseCreditsClick"
|
||||
>
|
||||
{{ $t('credits.purchaseCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-row items-center">
|
||||
<Skeleton
|
||||
v-if="balanceLoading"
|
||||
width="12rem"
|
||||
height="1rem"
|
||||
class="text-xs"
|
||||
/>
|
||||
<div v-else-if="formattedLastUpdateTime" class="text-xs text-muted">
|
||||
{{ $t('credits.lastUpdated') }}: {{ formattedLastUpdateTime }}
|
||||
</div>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('g.refresh')"
|
||||
@click="() => authActions.fetchBalance()"
|
||||
>
|
||||
<i class="pi pi-refresh" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3>{{ $t('credits.activity') }}</h3>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
:loading="loading"
|
||||
@click="handleCreditsHistoryClick"
|
||||
>
|
||||
<i class="pi pi-arrow-up-right" />
|
||||
{{ $t('credits.invoiceHistory') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<template v-if="creditHistory.length > 0">
|
||||
<div class="grow">
|
||||
<DataTable :value="creditHistory" :show-headers="false">
|
||||
<Column field="title" :header="$t('g.name')">
|
||||
<template #body="{ data }">
|
||||
<div class="text-sm font-medium">{{ data.title }}</div>
|
||||
<div class="text-xs text-muted">{{ data.timestamp }}</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="amount" :header="$t('g.amount')">
|
||||
<template #body="{ data }">
|
||||
<div
|
||||
:class="[
|
||||
'text-center text-base font-medium',
|
||||
data.isPositive ? 'text-sky-500' : 'text-red-400'
|
||||
]"
|
||||
>
|
||||
{{ data.isPositive ? '+' : '-' }}${{
|
||||
formatMetronomeCurrency(data.amount, 'usd')
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Divider />
|
||||
|
||||
<UsageLogsTable ref="usageLogsTableRef" />
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button variant="muted-textonly" @click="handleFaqClick">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('credits.faqs') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleOpenPartnerNodesInfo">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('subscription.partnerNodesCredits') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleMessageSupport">
|
||||
<i class="pi pi-comments" />
|
||||
{{ $t('credits.messageSupport') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Divider from 'primevue/divider'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { formatMetronomeCurrency } from '@/utils/formatUtil'
|
||||
|
||||
interface CreditHistoryItemData {
|
||||
title: string
|
||||
timestamp: string
|
||||
amount: number
|
||||
isPositive: boolean
|
||||
}
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const dialogService = useDialogService()
|
||||
const authStore = useAuthStore()
|
||||
const authActions = useAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
|
||||
|
||||
const formattedLastUpdateTime = computed(() =>
|
||||
authStore.lastBalanceUpdateTime
|
||||
? authStore.lastBalanceUpdateTime.toLocaleString()
|
||||
: ''
|
||||
)
|
||||
|
||||
watch(
|
||||
() => authStore.lastBalanceUpdateTime,
|
||||
(newTime, oldTime) => {
|
||||
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
|
||||
usageLogsTableRef.value.refresh()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handlePurchaseCreditsClick = () => {
|
||||
// Track purchase credits entry from Settings > Credits panel
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
const handleCreditsHistoryClick = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
const handleMessageSupport = async () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'credits_panel'
|
||||
})
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
const handleFaqClick = () => {
|
||||
window.open(
|
||||
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
const creditHistory = ref<CreditHistoryItemData[]>([])
|
||||
</script>
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Form, FormField } from '@primevue/forms'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
@@ -36,34 +37,116 @@ vi.mock('@/stores/authStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockTurnstileEnabled = ref(false)
|
||||
const mockTurnstileEnforced = ref(false)
|
||||
const mockReset = vi.fn()
|
||||
let emitTurnstileToken: ((token: string) => void) | undefined
|
||||
|
||||
vi.mock('@/composables/auth/useTurnstile', () => ({
|
||||
useTurnstile: () => ({
|
||||
enabled: mockTurnstileEnabled,
|
||||
enforced: mockTurnstileEnforced
|
||||
})
|
||||
}))
|
||||
|
||||
// Stub the real widget (which loads the external Turnstile script) with one that
|
||||
// exposes a spyable reset() and lets a test drive the v-model token the way a
|
||||
// solved challenge would.
|
||||
vi.mock('./TurnstileWidget.vue', async () => {
|
||||
const { defineComponent: defineMock } = await import('vue')
|
||||
return {
|
||||
default: defineMock({
|
||||
name: 'TurnstileWidget',
|
||||
emits: ['update:token'],
|
||||
setup(_, { expose, emit }) {
|
||||
expose({ reset: mockReset })
|
||||
emitTurnstileToken = (token: string) => emit('update:token', token)
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const signUpButton = enMessages.auth.signup.signUpButton
|
||||
|
||||
function globalOptions() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
return {
|
||||
plugins: [PrimeVue, i18n],
|
||||
components: {
|
||||
Form,
|
||||
FormField,
|
||||
Button,
|
||||
InputText,
|
||||
Password,
|
||||
ProgressSpinner
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('SignUpForm', () => {
|
||||
beforeEach(() => {
|
||||
mockLoadingRef.value = false
|
||||
mockTurnstileEnabled.value = false
|
||||
mockTurnstileEnforced.value = false
|
||||
mockReset.mockClear()
|
||||
emitTurnstileToken = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
return render(SignUpForm, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
components: {
|
||||
Form,
|
||||
FormField,
|
||||
Button,
|
||||
InputText,
|
||||
Password,
|
||||
ProgressSpinner
|
||||
}
|
||||
function renderComponent(props: Record<string, unknown> = {}) {
|
||||
const user = userEvent.setup()
|
||||
const utils = render(SignUpForm, { global: globalOptions(), props })
|
||||
return { ...utils, user }
|
||||
}
|
||||
|
||||
/** Render through a host that keeps a ref, so the parent-facing exposed
|
||||
* `resetTurnstile()` can be invoked the way SignInContent would. */
|
||||
function renderWithRef() {
|
||||
const formRef = ref<{ resetTurnstile: () => void } | null>(null)
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
return () => h(SignUpForm, { ref: formRef })
|
||||
}
|
||||
})
|
||||
const utils = render(Host, { global: globalOptions() })
|
||||
return {
|
||||
...utils,
|
||||
form: () => {
|
||||
if (!formRef.value) throw new Error('form not mounted')
|
||||
return formRef.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const expectedValues = {
|
||||
email: 'new@example.com',
|
||||
password: 'Password1!',
|
||||
confirmPassword: 'Password1!'
|
||||
}
|
||||
|
||||
async function fillValidSignup(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(enMessages.auth.signup.emailPlaceholder),
|
||||
expectedValues.email
|
||||
)
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(enMessages.auth.signup.passwordPlaceholder),
|
||||
expectedValues.password
|
||||
)
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(
|
||||
enMessages.auth.login.confirmPasswordPlaceholder
|
||||
),
|
||||
expectedValues.confirmPassword
|
||||
)
|
||||
}
|
||||
|
||||
describe('Password manager autofill attributes', () => {
|
||||
@@ -107,4 +190,97 @@ describe('SignUpForm', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Turnstile single-use token reset', () => {
|
||||
it('exposes resetTurnstile() that resets the rendered widget', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
const { form } = renderWithRef()
|
||||
await nextTick()
|
||||
|
||||
form().resetTurnstile()
|
||||
|
||||
expect(mockReset).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not reset the widget on the initial render', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
renderWithRef()
|
||||
await nextTick()
|
||||
|
||||
expect(mockReset).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Turnstile token hygiene', () => {
|
||||
it('clears the stale token when Turnstile becomes disabled', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const { user } = renderComponent()
|
||||
await fillValidSignup(user)
|
||||
|
||||
emitTurnstileToken!('stale-token')
|
||||
await nextTick()
|
||||
expect(
|
||||
screen.getByRole('button', { name: signUpButton })
|
||||
).not.toBeDisabled()
|
||||
|
||||
mockTurnstileEnabled.value = false
|
||||
await nextTick()
|
||||
|
||||
// re-enable: the stale token must have been cleared so submit is blocked again
|
||||
mockTurnstileEnabled.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Turnstile submit gating', () => {
|
||||
it('disables the submit button in enforce mode until a token is present', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('does not emit submit in enforce mode while the token is empty', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: signUpButton }))
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits submit with the token in enforce mode once the challenge is solved', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
|
||||
emitTurnstileToken!('token-xyz')
|
||||
await nextTick()
|
||||
await user.click(screen.getByRole('button', { name: signUpButton }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedValues, 'token-xyz')
|
||||
})
|
||||
|
||||
it('emits submit without a token in shadow mode (never blocks)', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = false
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: signUpButton }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedValues, undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,13 +29,34 @@
|
||||
|
||||
<PasswordFields />
|
||||
|
||||
<TurnstileWidget
|
||||
v-if="turnstileEnabled"
|
||||
ref="turnstileWidget"
|
||||
v-model:token="turnstileToken"
|
||||
/>
|
||||
|
||||
<small
|
||||
v-show="submitBlockedByTurnstile"
|
||||
id="comfy-org-sign-up-turnstile-hint"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="opacity-80"
|
||||
>
|
||||
{{ t('auth.turnstile.submitBlockedHint') }}
|
||||
</small>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<ProgressSpinner v-if="loading" class="mx-auto size-8" />
|
||||
<Button
|
||||
v-else
|
||||
type="submit"
|
||||
class="mt-4 h-10 font-medium"
|
||||
:disabled="!$form.valid"
|
||||
:disabled="!$form.valid || submitBlockedByTurnstile"
|
||||
:aria-describedby="
|
||||
submitBlockedByTurnstile
|
||||
? 'comfy-org-sign-up-turnstile-hint'
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
{{ t('auth.signup.signUpButton') }}
|
||||
</Button>
|
||||
@@ -49,27 +70,58 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTurnstile } from '@/composables/auth/useTurnstile'
|
||||
import { signUpSchema } from '@/schemas/signInSchema'
|
||||
import type { SignUpData } from '@/schemas/signInSchema'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
import PasswordFields from './PasswordFields.vue'
|
||||
import TurnstileWidget from './TurnstileWidget.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const loading = computed(() => authStore.loading)
|
||||
|
||||
const { enabled: turnstileEnabled, enforced: turnstileEnforced } =
|
||||
useTurnstile()
|
||||
const turnstileToken = ref('')
|
||||
const turnstileWidget =
|
||||
useTemplateRef<InstanceType<typeof TurnstileWidget>>('turnstileWidget')
|
||||
const submitBlockedByTurnstile = computed(
|
||||
() => turnstileEnforced.value && !turnstileToken.value
|
||||
)
|
||||
|
||||
watch(turnstileEnabled, (on) => {
|
||||
if (!on) turnstileToken.value = ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [values: SignUpData]
|
||||
submit: [values: SignUpData, turnstileToken?: string]
|
||||
}>()
|
||||
|
||||
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
|
||||
if (event.valid) {
|
||||
emit('submit', event.values as SignUpData)
|
||||
if (event.valid && !submitBlockedByTurnstile.value) {
|
||||
emit(
|
||||
'submit',
|
||||
event.values as SignUpData,
|
||||
turnstileToken.value || undefined
|
||||
)
|
||||
}
|
||||
}, 1_500)
|
||||
|
||||
// Turnstile tokens are single-use. The parent calls this after a FAILED signup
|
||||
// (the form can't observe the submit outcome itself) to discard the spent token
|
||||
// and request a fresh challenge. Driving it from the actual result — instead of
|
||||
// watching the store-global loading flag — keeps an unrelated auth action from
|
||||
// wiping a freshly-solved token, and avoids resetting a widget that is about to
|
||||
// unmount on success.
|
||||
function resetTurnstile() {
|
||||
turnstileWidget.value?.reset()
|
||||
}
|
||||
|
||||
defineExpose({ resetTurnstile })
|
||||
</script>
|
||||
|
||||
264
src/components/dialog/content/signin/TurnstileWidget.test.ts
Normal file
264
src/components/dialog/content/signin/TurnstileWidget.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render } from '@testing-library/vue'
|
||||
|
||||
import type { TurnstileRenderOptions } from '@/composables/auth/turnstileScript'
|
||||
|
||||
import TurnstileWidget from './TurnstileWidget.vue'
|
||||
|
||||
const { mockLoadTurnstile, mockGetSiteKey, mockLightTheme } = vi.hoisted(
|
||||
() => ({
|
||||
mockLoadTurnstile: vi.fn(),
|
||||
mockGetSiteKey: vi.fn(() => 'site-key'),
|
||||
mockLightTheme: { value: true }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/auth/turnstileScript', () => ({
|
||||
loadTurnstile: mockLoadTurnstile
|
||||
}))
|
||||
vi.mock('@/config/turnstile', () => ({
|
||||
getTurnstileSiteKey: mockGetSiteKey
|
||||
}))
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => ({
|
||||
completedActivePalette: {
|
||||
get light_theme() {
|
||||
return mockLightTheme.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
auth: {
|
||||
turnstile: {
|
||||
expired: 'Challenge expired',
|
||||
failed: 'Verification failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** A controllable Cloudflare Turnstile global whose render() captures options. */
|
||||
function fakeTurnstile() {
|
||||
let captured: TurnstileRenderOptions | undefined
|
||||
const api = {
|
||||
render: vi.fn((_el: unknown, options: TurnstileRenderOptions) => {
|
||||
captured = options
|
||||
return 'widget-id'
|
||||
}),
|
||||
reset: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}
|
||||
return { api, options: () => captured }
|
||||
}
|
||||
|
||||
/** Drain the onMounted async (loadTurnstile) plus any follow-up microtasks. */
|
||||
const flush = async () => {
|
||||
await Promise.resolve()
|
||||
await new Promise((resolve) => setTimeout(resolve))
|
||||
}
|
||||
|
||||
const renderWidget = () =>
|
||||
render(TurnstileWidget, { global: { plugins: [i18n] } })
|
||||
|
||||
/**
|
||||
* Render TurnstileWidget through a thin host that keeps a ref to it, so the
|
||||
* exposed `reset()` method can be invoked the way a parent (SignUpForm) would.
|
||||
*/
|
||||
const renderWidgetWithExpose = () => {
|
||||
const widgetRef = ref<{ reset: () => void } | null>(null)
|
||||
const Host = defineComponent({
|
||||
setup(_, { emit }) {
|
||||
return () =>
|
||||
h(TurnstileWidget, {
|
||||
ref: widgetRef,
|
||||
'onUpdate:token': (value: string) => emit('update:token', value)
|
||||
})
|
||||
}
|
||||
})
|
||||
const utils = render(Host, { global: { plugins: [i18n] } })
|
||||
return {
|
||||
...utils,
|
||||
getCurrentInstance: () => {
|
||||
if (!widgetRef.value) throw new Error('widget not mounted')
|
||||
return widgetRef.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('TurnstileWidget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetSiteKey.mockReturnValue('site-key')
|
||||
mockLightTheme.value = true
|
||||
delete window.turnstile
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.turnstile
|
||||
})
|
||||
|
||||
it('renders the widget with the configured sitekey and light theme', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
renderWidget()
|
||||
await flush()
|
||||
|
||||
expect(mockLoadTurnstile).toHaveBeenCalledOnce()
|
||||
expect(api.render).toHaveBeenCalledOnce()
|
||||
expect(options()?.sitekey).toBe('site-key')
|
||||
expect(options()?.theme).toBe('light')
|
||||
})
|
||||
|
||||
it('uses the dark theme when the active palette is not light', async () => {
|
||||
mockLightTheme.value = false
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
renderWidget()
|
||||
await flush()
|
||||
|
||||
expect(options()?.theme).toBe('dark')
|
||||
})
|
||||
|
||||
it('emits the solved token via v-model and shows no error', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted, container } = renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:token'].at(-1)).toEqual(['token-abc'])
|
||||
expect(container.textContent).not.toContain('Challenge expired')
|
||||
expect(container.textContent).not.toContain('Verification failed')
|
||||
})
|
||||
|
||||
it('clears the token and surfaces the expired message on expiry', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted, container } = renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
options()!['expired-callback']!()
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:token'].at(-1)).toEqual([''])
|
||||
expect(container.textContent).toContain('Challenge expired')
|
||||
})
|
||||
|
||||
it('clears the token and surfaces the failure message on widget error', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted, container } = renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
options()!['error-callback']!()
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:token'].at(-1)).toEqual([''])
|
||||
expect(container.textContent).toContain('Verification failed')
|
||||
})
|
||||
|
||||
it('resets the widget on a challenge error to fetch a fresh challenge', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!['error-callback']!()
|
||||
await flush()
|
||||
|
||||
expect(api.reset).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
|
||||
it('shows the failure message when the Turnstile script fails to load', async () => {
|
||||
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
|
||||
|
||||
const { container } = renderWidget()
|
||||
await flush()
|
||||
|
||||
expect(container.textContent).toContain('Verification failed')
|
||||
})
|
||||
|
||||
it('reset() clears the token model and resets the rendered widget', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
const { emitted, getCurrentInstance } = renderWidgetWithExpose()
|
||||
await flush()
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
await flush()
|
||||
expect(emitted()['update:token'].at(-1)).toEqual(['token-abc'])
|
||||
|
||||
getCurrentInstance().reset()
|
||||
await flush()
|
||||
|
||||
expect(api.reset).toHaveBeenCalledWith('widget-id')
|
||||
expect(emitted()['update:token'].at(-1)).toEqual([''])
|
||||
})
|
||||
|
||||
it('reset() clears a stale error so it does not linger over a fresh challenge', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
const { container, getCurrentInstance } = renderWidgetWithExpose()
|
||||
await flush()
|
||||
|
||||
options()!['error-callback']!()
|
||||
await flush()
|
||||
expect(container.textContent).toContain('Verification failed')
|
||||
|
||||
getCurrentInstance().reset()
|
||||
await flush()
|
||||
expect(container.textContent).not.toContain('Verification failed')
|
||||
})
|
||||
|
||||
it('reset() clears the token even when the widget never rendered', async () => {
|
||||
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
|
||||
|
||||
const { emitted, getCurrentInstance } = renderWidgetWithExpose()
|
||||
await flush()
|
||||
|
||||
getCurrentInstance().reset()
|
||||
await flush()
|
||||
|
||||
// No widget id was captured, so window.turnstile.reset is never called,
|
||||
// but the token model is still cleared.
|
||||
expect(emitted()['update:token']?.at(-1) ?? ['']).toEqual([''])
|
||||
})
|
||||
|
||||
it('removes the widget on unmount when one was rendered', async () => {
|
||||
const { api } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
const { unmount } = renderWidget()
|
||||
await flush()
|
||||
|
||||
unmount()
|
||||
|
||||
expect(api.remove).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
})
|
||||
92
src/components/dialog/content/signin/TurnstileWidget.vue
Normal file
92
src/components/dialog/content/signin/TurnstileWidget.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div ref="containerRef"></div>
|
||||
<small
|
||||
v-if="errorMessage"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
class="text-red-500"
|
||||
>{{ errorMessage }}</small
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { loadTurnstile } from '@/composables/auth/turnstileScript'
|
||||
import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
const token = defineModel<string>('token', { default: '' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const errorMessage = ref('')
|
||||
let widgetId: string | undefined
|
||||
|
||||
const clearToken = () => {
|
||||
token.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a fresh challenge and clear the current token.
|
||||
*
|
||||
* Turnstile tokens are single-use, so after a token is consumed by a submit
|
||||
* attempt that did not succeed, the spent token must be discarded and a new
|
||||
* challenge requested. Clearing the model re-blocks submission until the user
|
||||
* solves the fresh challenge; clearing the error drops any stale failure text
|
||||
* so it can't linger over the new challenge.
|
||||
*/
|
||||
const reset = () => {
|
||||
clearToken()
|
||||
errorMessage.value = ''
|
||||
if (widgetId && window.turnstile) {
|
||||
window.turnstile.reset(widgetId)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reset })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const turnstile = await loadTurnstile()
|
||||
if (!containerRef.value) return
|
||||
|
||||
const theme = colorPaletteStore.completedActivePalette.light_theme
|
||||
? 'light'
|
||||
: 'dark'
|
||||
|
||||
widgetId = turnstile.render(containerRef.value, {
|
||||
sitekey: getTurnstileSiteKey(),
|
||||
theme,
|
||||
callback: (newToken: string) => {
|
||||
errorMessage.value = ''
|
||||
token.value = newToken
|
||||
},
|
||||
'expired-callback': () => {
|
||||
clearToken()
|
||||
errorMessage.value = t('auth.turnstile.expired')
|
||||
},
|
||||
'error-callback': () => {
|
||||
clearToken()
|
||||
console.warn('Turnstile challenge failed')
|
||||
errorMessage.value = t('auth.turnstile.failed')
|
||||
if (widgetId && window.turnstile) window.turnstile.reset(widgetId)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Turnstile failed to load', error)
|
||||
errorMessage.value = t('auth.turnstile.failed')
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (widgetId && window.turnstile) {
|
||||
window.turnstile.remove(widgetId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -195,10 +195,7 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
|
||||
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
|
||||
import { useUrlActionLoaders } from '@/composables/useUrlActionLoaders'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{
|
||||
@@ -457,10 +454,7 @@ useEventListener(
|
||||
|
||||
const comfyAppReady = ref(false)
|
||||
const workflowPersistence = useWorkflowPersistence()
|
||||
const { flags } = useFeatureFlags()
|
||||
// Set up URL loaders during setup phase so useRoute/useRouter work correctly
|
||||
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
|
||||
const createWorkspaceUrlLoader = isCloud ? useCreateWorkspaceUrlLoader() : null
|
||||
const { runUrlActionLoaders } = useUrlActionLoaders()
|
||||
useCanvasDrop(canvasRef)
|
||||
useLitegraphSettings()
|
||||
useNodeBadge()
|
||||
@@ -569,23 +563,8 @@ onMounted(async () => {
|
||||
() => canvasStore.updateSelectedItems()
|
||||
)
|
||||
|
||||
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
|
||||
// WorkspaceAuthGate ensures flag state is resolved before GraphCanvas mounts
|
||||
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {
|
||||
await inviteUrlLoader.loadInviteFromUrl()
|
||||
}
|
||||
|
||||
// Open create workspace dialog from URL if present (e.g., ?create_workspace=1)
|
||||
if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) {
|
||||
try {
|
||||
await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[GraphCanvas] Failed to load create workspace from URL:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
// Run query-param deep-link loaders (?invite, ?create_workspace, ?pricing)
|
||||
await runUrlActionLoaders()
|
||||
|
||||
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
|
||||
const { useReleaseStore } =
|
||||
|
||||
@@ -26,7 +26,6 @@ const singleErrorCard: ErrorCardData = {
|
||||
title: 'CLIPTextEncode',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeTitle: 'CLIP Text Encode (Prompt)',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input "text" is missing.',
|
||||
@@ -40,7 +39,6 @@ const multipleErrorsCard: ErrorCardData = {
|
||||
title: 'VAEDecode',
|
||||
nodeId: createNodeExecutionId([24]),
|
||||
nodeTitle: 'VAE Decode',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input "samples" is missing.',
|
||||
@@ -58,7 +56,6 @@ const runtimeErrorCard: ErrorCardData = {
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([45]),
|
||||
nodeTitle: 'KSampler',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'OutOfMemoryError: CUDA out of memory. Tried to allocate 1.2GB.',
|
||||
@@ -73,20 +70,6 @@ const runtimeErrorCard: ErrorCardData = {
|
||||
]
|
||||
}
|
||||
|
||||
const subgraphErrorCard: ErrorCardData = {
|
||||
id: 'node-3:15',
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([3, 15]),
|
||||
nodeTitle: 'Nested KSampler',
|
||||
isSubgraphNode: true,
|
||||
errors: [
|
||||
{
|
||||
message: 'Latent input is required.',
|
||||
details: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const promptOnlyCard: ErrorCardData = {
|
||||
id: '__prompt__',
|
||||
title: 'Prompt has no outputs.',
|
||||
@@ -104,13 +87,6 @@ export const SingleValidationError: Story = {
|
||||
}
|
||||
}
|
||||
|
||||
/** Subgraph node error — shows "Enter subgraph" button */
|
||||
export const WithEnterSubgraphButton: Story = {
|
||||
args: {
|
||||
card: subgraphErrorCard
|
||||
}
|
||||
}
|
||||
|
||||
/** Multiple validation errors on one node */
|
||||
export const MultipleErrors: Story = {
|
||||
args: {
|
||||
|
||||
@@ -79,7 +79,6 @@ describe('ErrorNodeCard.vue', () => {
|
||||
},
|
||||
rightSidePanel: {
|
||||
locateNode: 'Locate Node',
|
||||
enterSubgraph: 'Enter Subgraph',
|
||||
errorLog: 'Error log',
|
||||
findOnGithubTooltip: 'Search GitHub issues for related problems',
|
||||
getHelpTooltip:
|
||||
|
||||
@@ -21,15 +21,6 @@
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasRuntimeError"
|
||||
variant="textonly"
|
||||
@@ -202,7 +193,6 @@ const { card, compact = false } = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateNode: [nodeId: string]
|
||||
enterSubgraph: [nodeId: string]
|
||||
copyToClipboard: [text: string]
|
||||
}>()
|
||||
|
||||
@@ -233,12 +223,6 @@ function handleLocateNode() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnterSubgraph() {
|
||||
if (card.nodeId) {
|
||||
emit('enterSubgraph', card.nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyError(idx: number) {
|
||||
const details = displayedDetailsMap.value[idx]
|
||||
const message = getCopyMessage(card.errors[idx])
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
const mockFocusNode = vi.hoisted(() => vi.fn())
|
||||
const mockEnterSubgraph = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
@@ -35,16 +34,9 @@ vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: vi.fn(() => ({
|
||||
fitView: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/canvas/useFocusNode', () => ({
|
||||
useFocusNode: vi.fn(() => ({
|
||||
focusNode: mockFocusNode,
|
||||
enterSubgraph: mockEnterSubgraph
|
||||
focusNode: mockFocusNode
|
||||
}))
|
||||
}))
|
||||
|
||||
|
||||
@@ -249,7 +249,6 @@
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
@@ -357,7 +356,7 @@ const ErrorPanelSurveyCta =
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { focusNode } = useFocusNode()
|
||||
const { openGitHubIssues, contactSupport } = useErrorActions()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
@@ -523,8 +522,4 @@ function handleReplaceGroup(group: SwapNodeGroup) {
|
||||
function handleReplaceAll() {
|
||||
replaceAllGroups(swapNodeGroups.value)
|
||||
}
|
||||
|
||||
function handleEnterSubgraph(nodeId: string) {
|
||||
enterSubgraph(nodeId, errorNodeCache.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -16,7 +16,6 @@ export interface ErrorCardData {
|
||||
nodeId?: NodeExecutionId
|
||||
nodeTitle?: string
|
||||
graphNodeId?: string
|
||||
isSubgraphNode?: boolean
|
||||
errors: ErrorItem[]
|
||||
}
|
||||
|
||||
|
||||
@@ -671,30 +671,6 @@ describe('useErrorGroups', () => {
|
||||
expect(nodeIds).toEqual(['1', '2', '10'])
|
||||
})
|
||||
|
||||
it('marks only nested execution paths as subgraph node cards', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
},
|
||||
'1:20': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroup?.cards).toMatchObject([
|
||||
{ nodeId: '1', isSubgraphNode: false },
|
||||
{ nodeId: '1:20', isSubgraphNode: true }
|
||||
])
|
||||
})
|
||||
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
|
||||
@@ -130,7 +130,6 @@ function createErrorCard(
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: nodeId.includes(':'),
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
const setDirty = vi.fn()
|
||||
const selectedItems: unknown[] = []
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: { setDirty },
|
||||
selectedItems
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
getInputSpecForWidget: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const WidgetItemStub = defineComponent({
|
||||
inheritAttrs: false,
|
||||
emits: ['update:widget-value', 'reset-to-default'],
|
||||
template: `
|
||||
<button
|
||||
data-testid="widget-edit"
|
||||
@click="$emit('update:widget-value', 'real_model.safetensors')"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
const PropertiesAccordionItemStub = defineComponent({
|
||||
inheritAttrs: false,
|
||||
emits: ['update:collapse'],
|
||||
template: '<section><slot name="label" /><slot /></section>'
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
rightSidePanel: {
|
||||
inputs: 'Inputs',
|
||||
resetAllParameters: 'Reset all',
|
||||
seeError: 'See error'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createHostWithPromotedModel(): {
|
||||
host: SubgraphNode
|
||||
promotedWidget: IBaseWidget
|
||||
sourceWidget: IBaseWidget
|
||||
sourceExecutionId: NodeExecutionId
|
||||
hostExecutionId: NodeExecutionId
|
||||
} {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
const graph = host.graph as LGraph
|
||||
graph.add(host)
|
||||
|
||||
const sourceNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
sourceNode.id = 42
|
||||
const sourceInput = sourceNode.addInput('ckpt_name', 'COMBO')
|
||||
const sourceWidget = sourceNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'missing_model.safetensors',
|
||||
() => {},
|
||||
{ values: ['real_model.safetensors'] }
|
||||
)
|
||||
sourceInput.widget = { name: sourceWidget.name }
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(host, sourceNode, sourceWidget).ok
|
||||
).toBe(true)
|
||||
|
||||
const promotedWidget = host.widgets?.find(
|
||||
(widget) => widget.name === sourceWidget.name
|
||||
)
|
||||
if (!promotedWidget) throw new Error('Expected promoted widget')
|
||||
|
||||
const rootGraph = host.rootGraph
|
||||
const sourceExecutionId = getExecutionIdByNode(rootGraph, sourceNode)
|
||||
const hostExecutionId = getExecutionIdByNode(rootGraph, host)
|
||||
if (!sourceExecutionId || !hostExecutionId) {
|
||||
throw new Error('Expected execution ids')
|
||||
}
|
||||
|
||||
return {
|
||||
host,
|
||||
promotedWidget,
|
||||
sourceWidget,
|
||||
sourceExecutionId,
|
||||
hostExecutionId
|
||||
}
|
||||
}
|
||||
|
||||
describe('SectionWidgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
setDirty.mockClear()
|
||||
selectedItems.length = 0
|
||||
})
|
||||
|
||||
it('clears promoted widget validation by source and missing model by host', async () => {
|
||||
const {
|
||||
host,
|
||||
promotedWidget,
|
||||
sourceWidget,
|
||||
sourceExecutionId,
|
||||
hostExecutionId
|
||||
} = createHostWithPromotedModel()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const clearSpy = vi.spyOn(executionErrorStore, 'clearWidgetRelatedErrors')
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(SectionWidgets, {
|
||||
props: {
|
||||
widgets: [{ widget: promotedWidget, node: host }]
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Button: true,
|
||||
WidgetItem: WidgetItemStub,
|
||||
PropertiesAccordionItem: PropertiesAccordionItemStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('widget-edit'))
|
||||
|
||||
expect(clearSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
sourceExecutionId,
|
||||
sourceWidget.name,
|
||||
sourceWidget.name,
|
||||
'real_model.safetensors',
|
||||
{ min: undefined, max: undefined }
|
||||
)
|
||||
expect(clearSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
hostExecutionId,
|
||||
promotedWidget.name,
|
||||
promotedWidget.name,
|
||||
'real_model.safetensors',
|
||||
{ min: undefined, max: undefined }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,6 @@ import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -26,7 +25,6 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
|
||||
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
|
||||
@@ -235,49 +233,12 @@ function navigateToErrorTab() {
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
}
|
||||
|
||||
function clearWidgetErrors(
|
||||
widgetNode: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
value: WidgetValue
|
||||
) {
|
||||
const rootGraph = widgetNode.graph?.rootGraph
|
||||
if (!rootGraph) return
|
||||
|
||||
const executionId = getExecutionIdByNode(rootGraph, widgetNode)
|
||||
if (!executionId) return
|
||||
|
||||
const options = { min: widget.options?.min, max: widget.options?.max }
|
||||
const source = resolvePromotedWidgetSource(rootGraph, widgetNode, widget)
|
||||
if (source?.sourceExecutionId) {
|
||||
executionErrorStore.clearWidgetRelatedErrors(
|
||||
source.sourceExecutionId,
|
||||
source.sourceWidgetName,
|
||||
source.sourceWidgetName,
|
||||
value,
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
executionErrorStore.clearWidgetRelatedErrors(
|
||||
executionId,
|
||||
widget.name,
|
||||
widget.name,
|
||||
value,
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
function setWidgetValue(
|
||||
widgetNode: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
value: WidgetValue
|
||||
) {
|
||||
function setWidgetValue(widget: IBaseWidget, value: WidgetValue) {
|
||||
// Store-backed widgets (interior node widgets and promoted subgraph inputs)
|
||||
// are addressed by widgetId; writing there keeps the displayed value in sync.
|
||||
if (widget.widgetId) useWidgetValueStore().setValue(widget.widgetId, value)
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
clearWidgetErrors(widgetNode, widget, value)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
@@ -286,26 +247,18 @@ function handleResetAllWidgets() {
|
||||
const spec = nodeDefStore.getInputSpecForWidget(widgetNode, widget.name)
|
||||
const defaultValue = getWidgetDefaultValue(spec)
|
||||
if (defaultValue !== undefined) {
|
||||
setWidgetValue(widgetNode, widget, defaultValue)
|
||||
setWidgetValue(widget, defaultValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleWidgetValueUpdate(
|
||||
widgetNode: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
newValue: WidgetValue
|
||||
) {
|
||||
function handleWidgetValueUpdate(widget: IBaseWidget, newValue: WidgetValue) {
|
||||
if (newValue === undefined) return
|
||||
setWidgetValue(widgetNode, widget, newValue)
|
||||
setWidgetValue(widget, newValue)
|
||||
}
|
||||
|
||||
function handleWidgetReset(
|
||||
widgetNode: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
newValue: WidgetValue
|
||||
) {
|
||||
setWidgetValue(widgetNode, widget, newValue)
|
||||
function handleWidgetReset(widget: IBaseWidget, newValue: WidgetValue) {
|
||||
setWidgetValue(widget, newValue)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
@@ -401,8 +354,8 @@ defineExpose({
|
||||
:show-node-name="showNodeName"
|
||||
:parents="parents"
|
||||
:is-shown-on-parents="isWidgetShownOnParents(node, widget)"
|
||||
@update:widget-value="handleWidgetValueUpdate(node, widget, $event)"
|
||||
@reset-to-default="handleWidgetReset(node, widget, $event)"
|
||||
@update:widget-value="handleWidgetValueUpdate(widget, $event)"
|
||||
@reset-to-default="handleWidgetReset(widget, $event)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,18 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { h, ref } from 'vue'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import type { BalanceInfo, SubscriptionInfo } from '@/composables/billing/types'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
|
||||
|
||||
// Mock all firebase modules
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
getApp: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('firebase/auth', () => ({
|
||||
getAuth: vi.fn(),
|
||||
setPersistence: vi.fn(),
|
||||
browserLocalPersistence: {},
|
||||
onAuthStateChanged: vi.fn(),
|
||||
signInWithEmailAndPassword: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock pinia
|
||||
vi.mock('pinia')
|
||||
|
||||
// Mock showSettingsDialog and showTopUpCreditsDialog
|
||||
const mockShowSettingsDialog = vi.fn()
|
||||
const mockShowTopUpCreditsDialog = vi.fn()
|
||||
|
||||
// Mock the settings dialog composable
|
||||
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
useSettingsDialog: vi.fn(() => ({
|
||||
show: mockShowSettingsDialog,
|
||||
@@ -40,7 +21,6 @@ vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock window.open
|
||||
const originalWindowOpen = window.open
|
||||
beforeEach(() => {
|
||||
window.open = vi.fn()
|
||||
@@ -50,7 +30,6 @@ afterAll(() => {
|
||||
window.open = originalWindowOpen
|
||||
})
|
||||
|
||||
// Mock the useCurrentUser composable
|
||||
const mockHandleSignOut = vi.fn()
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: vi.fn(() => ({
|
||||
@@ -61,60 +40,50 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useAuthActions composable
|
||||
const mockLogout = vi.fn()
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: vi.fn(() => ({
|
||||
fetchBalance: vi.fn().mockResolvedValue(undefined),
|
||||
logout: mockLogout
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the dialog service
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => ({
|
||||
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the authStore with hoisted state for per-test manipulation
|
||||
const mockAuthStoreState = vi.hoisted(() => ({
|
||||
balance: {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 100_000,
|
||||
currency: 'usd'
|
||||
} as {
|
||||
amount_micros?: number
|
||||
effective_balance_micros?: number
|
||||
currency: string
|
||||
},
|
||||
isFetchingBalance: false
|
||||
}))
|
||||
function makeSubscription(
|
||||
overrides: Partial<SubscriptionInfo> = {}
|
||||
): SubscriptionInfo {
|
||||
return {
|
||||
isActive: true,
|
||||
tier: 'CREATOR',
|
||||
duration: 'MONTHLY',
|
||||
planSlug: null,
|
||||
renewalDate: null,
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
hasFunds: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => ({
|
||||
getAuthHeader: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),
|
||||
balance: mockAuthStoreState.balance,
|
||||
isFetchingBalance: mockAuthStoreState.isFetchingBalance
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useSubscription composable
|
||||
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
|
||||
const mockFetchBalance = vi.fn().mockResolvedValue(undefined)
|
||||
const mockIsActiveSubscription = ref(true)
|
||||
const mockIsFreeTier = ref(false)
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: vi.fn(() => ({
|
||||
isActiveSubscription: ref(true),
|
||||
const mockTier = ref<SubscriptionInfo['tier']>('CREATOR')
|
||||
const mockSubscription = ref<SubscriptionInfo | null>(makeSubscription())
|
||||
const mockBalance = ref<BalanceInfo | null>(null)
|
||||
const mockIsLoading = ref(false)
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
isActiveSubscription: mockIsActiveSubscription,
|
||||
isFreeTier: mockIsFreeTier,
|
||||
subscriptionTierName: ref('Creator'),
|
||||
subscriptionTier: ref('CREATOR'),
|
||||
fetchStatus: mockFetchStatus
|
||||
tier: mockTier,
|
||||
subscription: mockSubscription,
|
||||
balance: mockBalance,
|
||||
isLoading: mockIsLoading,
|
||||
fetchStatus: mockFetchStatus,
|
||||
fetchBalance: mockFetchBalance
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useSubscriptionDialog composable
|
||||
const mockShowPricingTable = vi.fn()
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
@@ -127,7 +96,6 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
// Mock UserAvatar component
|
||||
vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
default: {
|
||||
name: 'UserAvatarMock',
|
||||
@@ -137,22 +105,10 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock UserCredit component
|
||||
vi.mock('@/components/common/UserCredit.vue', () => ({
|
||||
default: {
|
||||
name: 'UserCreditMock',
|
||||
render() {
|
||||
return h('div', 'Credit: 100')
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock formatCreditsFromCents
|
||||
vi.mock('@/base/credits/comfyCredits', () => ({
|
||||
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
|
||||
}))
|
||||
|
||||
// Mock useExternalLink
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: vi.fn(() => ({
|
||||
buildDocsUrl: vi.fn((path) => `https://docs.comfy.org${path}`),
|
||||
@@ -162,14 +118,12 @@ vi.mock('@/composables/useExternalLink', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock useTelemetry
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackAddApiCreditButtonClicked: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock isCloud with hoisted state for per-test toggling
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: true }))
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
@@ -178,25 +132,37 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
|
||||
default: {
|
||||
default: defineComponent({
|
||||
name: 'SubscribeButtonMock',
|
||||
render() {
|
||||
return h('div', 'Subscribe Button')
|
||||
emits: ['subscribed'],
|
||||
setup(_, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
'data-testid': 'subscribe-button-mock',
|
||||
onClick: () => emit('subscribed')
|
||||
},
|
||||
'Subscribe Button'
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
describe('CurrentUserPopoverLegacy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockIsActiveSubscription.value = true
|
||||
mockIsFreeTier.value = false
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 100_000,
|
||||
mockTier.value = 'CREATOR'
|
||||
mockSubscription.value = makeSubscription()
|
||||
mockBalance.value = {
|
||||
amountMicros: 100_000,
|
||||
effectiveBalanceMicros: 100_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
mockAuthStoreState.isFetchingBalance = false
|
||||
mockIsLoading.value = false
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
@@ -230,7 +196,47 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
|
||||
it('fetches the balance through the billing facade on mount', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(mockFetchBalance).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes subscription status through the billing facade after subscribing', async () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByTestId('subscribe-button-mock'))
|
||||
|
||||
expect(mockFetchStatus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('subscription tier badge', () => {
|
||||
it('renders the tier name derived from the facade tier', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('Creator')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the yearly tier name when the facade subscription is annual', () => {
|
||||
mockSubscription.value = makeSubscription({ duration: 'ANNUAL' })
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('Creator Yearly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the badge when the facade reports no tier', () => {
|
||||
mockTier.value = null
|
||||
mockSubscription.value = null
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.queryByText('Creator')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('formats and displays the facade balance', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(formatCreditsFromCents).toHaveBeenCalledWith({
|
||||
@@ -245,6 +251,14 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('1000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a skeleton instead of the balance while billing is loading', () => {
|
||||
mockIsLoading.value = true
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.queryByText('1000')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders logout menu item with correct text', () => {
|
||||
renderComponent()
|
||||
|
||||
@@ -324,11 +338,11 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
describe('effective_balance_micros handling', () => {
|
||||
it('uses effective_balance_micros when present (positive balance)', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 200_000,
|
||||
effective_balance_micros: 150_000,
|
||||
describe('facade balance handling', () => {
|
||||
it('uses effectiveBalanceMicros when present (positive balance)', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 200_000,
|
||||
effectiveBalanceMicros: 150_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -345,10 +359,10 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('1500')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses effective_balance_micros when zero', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 0,
|
||||
it('uses effectiveBalanceMicros when zero', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 100_000,
|
||||
effectiveBalanceMicros: 0,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -365,10 +379,10 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses effective_balance_micros when negative', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 0,
|
||||
effective_balance_micros: -50_000,
|
||||
it('uses effectiveBalanceMicros when negative', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 0,
|
||||
effectiveBalanceMicros: -50_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -385,9 +399,9 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('-500')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to amount_micros when effective_balance_micros is missing', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
it('falls back to amountMicros when effectiveBalanceMicros is missing', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 100_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -404,10 +418,8 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('1000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
currency: 'usd'
|
||||
}
|
||||
it('falls back to 0 when the facade reports no balance', () => {
|
||||
mockBalance.value = null
|
||||
|
||||
renderComponent()
|
||||
|
||||
@@ -466,8 +478,11 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
})
|
||||
|
||||
it('hides subscribe button', () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
renderComponent()
|
||||
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('subscribe-button-mock')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still shows partner nodes menu item', () => {
|
||||
|
||||
@@ -32,12 +32,7 @@
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<Skeleton
|
||||
v-if="authStore.isFetchingBalance"
|
||||
width="4rem"
|
||||
height="1.25rem"
|
||||
class="w-full"
|
||||
/>
|
||||
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
}}</span>
|
||||
@@ -162,16 +157,15 @@ import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useWorkspaceTierLabel } from '@/platform/workspace/composables/useWorkspaceTierLabel'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
@@ -181,25 +175,29 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const authActions = useAuthActions()
|
||||
const authStore = useAuthStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const {
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
subscriptionTierName,
|
||||
subscriptionTier,
|
||||
fetchStatus
|
||||
} = useSubscription()
|
||||
tier,
|
||||
subscription,
|
||||
balance,
|
||||
isLoading,
|
||||
fetchStatus,
|
||||
fetchBalance
|
||||
} = useBillingContext()
|
||||
const { formatTierName } = useWorkspaceTierLabel()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
const { locale } = useI18n()
|
||||
|
||||
const subscriptionTierName = computed(() =>
|
||||
formatTierName(tier.value, subscription.value?.duration === 'ANNUAL')
|
||||
)
|
||||
|
||||
const formattedBalance = computed(() => {
|
||||
const cents =
|
||||
authStore.balance?.effective_balance_micros ??
|
||||
authStore.balance?.amount_micros ??
|
||||
0
|
||||
balance.value?.effectiveBalanceMicros ?? balance.value?.amountMicros ?? 0
|
||||
return formatCreditsFromCents({
|
||||
cents,
|
||||
locale: locale.value,
|
||||
@@ -211,12 +209,12 @@ const formattedBalance = computed(() => {
|
||||
})
|
||||
|
||||
const canUpgrade = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
const currentTier = tier.value
|
||||
return (
|
||||
tier === 'FREE' ||
|
||||
tier === 'FOUNDERS_EDITION' ||
|
||||
tier === 'STANDARD' ||
|
||||
tier === 'CREATOR'
|
||||
currentTier === 'FREE' ||
|
||||
currentTier === 'FOUNDERS_EDITION' ||
|
||||
currentTier === 'STANDARD' ||
|
||||
currentTier === 'CREATOR'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -270,6 +268,6 @@ const handleSubscribed = async () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void authActions.fetchBalance()
|
||||
void fetchBalance()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -22,6 +22,8 @@ export const buttonVariants = cva({
|
||||
link: 'bg-transparent text-muted-foreground hover:text-base-foreground',
|
||||
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
|
||||
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
|
||||
tertiary:
|
||||
'bg-tertiary-background text-base-foreground hover:bg-tertiary-background-hover',
|
||||
gradient:
|
||||
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
|
||||
},
|
||||
@@ -54,6 +56,7 @@ const variants = [
|
||||
'destructive-textonly',
|
||||
'link',
|
||||
'base',
|
||||
'tertiary',
|
||||
'overlay-white',
|
||||
'gradient'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
|
||||
@@ -13,7 +13,8 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import {
|
||||
DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
TEAM_PLAN_CREDIT_STOPS
|
||||
TEAM_PLAN_CREDIT_STOPS,
|
||||
getStopDiscountedMonthlyUsd
|
||||
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import type { CreditStop } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
|
||||
@@ -83,7 +84,7 @@ const effectiveDiscountPercent = computed(() =>
|
||||
: current.value.discountPercentYearly
|
||||
)
|
||||
const discountedMonthly = computed(() =>
|
||||
Math.round(current.value.usd * (1 - effectiveDiscountPercent.value / 100))
|
||||
getStopDiscountedMonthlyUsd(current.value, cycle)
|
||||
)
|
||||
const saveAmount = computed(() => current.value.usd - discountedMonthly.value)
|
||||
const hasDiscount = computed(() => effectiveDiscountPercent.value > 0)
|
||||
|
||||
232
src/composables/auth/turnstileScript.test.ts
Normal file
232
src/composables/auth/turnstileScript.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { TurnstileApi } from '@/composables/auth/turnstileScript'
|
||||
|
||||
const TURNSTILE_SRC =
|
||||
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||
|
||||
const fakeApi = (): TurnstileApi => ({
|
||||
render: vi.fn(() => 'widget-id'),
|
||||
reset: vi.fn(),
|
||||
remove: vi.fn()
|
||||
})
|
||||
|
||||
/**
|
||||
* Controllable stand-in for the injected <script>. We never insert a real
|
||||
* external script because jsdom would try (and fail) to fetch it and fire its
|
||||
* own `error` event, making `load` impossible to simulate deterministically.
|
||||
* Instead `createElement`/`querySelector`/`appendChild` are spied to route
|
||||
* through this fake, so the test drives `load`/`error`/timeout itself.
|
||||
*/
|
||||
class FakeScript {
|
||||
src = ''
|
||||
async = false
|
||||
private handlers: Record<string, Array<(e: Event) => void>> = {}
|
||||
|
||||
addEventListener(type: string, cb: (e: Event) => void) {
|
||||
;(this.handlers[type] ??= []).push(cb)
|
||||
}
|
||||
|
||||
dispatchEvent(event: Event): boolean {
|
||||
for (const cb of this.handlers[event.type] ?? []) cb(event)
|
||||
return true
|
||||
}
|
||||
|
||||
remove() {
|
||||
const i = inserted.indexOf(this)
|
||||
if (i >= 0) inserted.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
let inserted: FakeScript[] = []
|
||||
|
||||
const scriptEl = (): FakeScript | null =>
|
||||
inserted.find((s) => s.src === TURNSTILE_SRC) ?? null
|
||||
|
||||
const scriptCount = () => inserted.filter((s) => s.src === TURNSTILE_SRC).length
|
||||
|
||||
/**
|
||||
* The module keeps a private singleton promise, so each test imports a fresh
|
||||
* copy after `vi.resetModules()`.
|
||||
*/
|
||||
async function freshLoadTurnstile() {
|
||||
vi.resetModules()
|
||||
const mod = await import('@/composables/auth/turnstileScript')
|
||||
return mod.loadTurnstile
|
||||
}
|
||||
|
||||
describe('loadTurnstile', () => {
|
||||
beforeEach(() => {
|
||||
inserted = []
|
||||
delete window.turnstile
|
||||
|
||||
const realCreateElement = document.createElement.bind(document)
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tag: string) =>
|
||||
tag === 'script'
|
||||
? (new FakeScript() as unknown as HTMLElement)
|
||||
: realCreateElement(tag)
|
||||
)
|
||||
vi.spyOn(document, 'querySelector').mockImplementation((sel: string) =>
|
||||
typeof sel === 'string' && sel.includes('challenges.cloudflare.com')
|
||||
? (scriptEl() as unknown as Element | null)
|
||||
: null
|
||||
)
|
||||
vi.spyOn(document.head, 'appendChild').mockImplementation((node: Node) => {
|
||||
inserted.push(node as unknown as FakeScript)
|
||||
return node
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('resolves immediately with the existing global and appends no script', async () => {
|
||||
const api = fakeApi()
|
||||
window.turnstile = api
|
||||
|
||||
const loadTurnstile = await freshLoadTurnstile()
|
||||
|
||||
await expect(loadTurnstile()).resolves.toBe(api)
|
||||
expect(scriptEl()).toBeNull()
|
||||
})
|
||||
|
||||
it('appends the script and resolves once it loads and exposes the global', async () => {
|
||||
const loadTurnstile = await freshLoadTurnstile()
|
||||
|
||||
const promise = loadTurnstile()
|
||||
const el = scriptEl()
|
||||
expect(el).not.toBeNull()
|
||||
expect(el?.async).toBe(true)
|
||||
|
||||
const api = fakeApi()
|
||||
window.turnstile = api
|
||||
el!.dispatchEvent(new Event('load'))
|
||||
|
||||
await expect(promise).resolves.toBe(api)
|
||||
})
|
||||
|
||||
it('caches the in-flight promise so concurrent callers share one load', async () => {
|
||||
const loadTurnstile = await freshLoadTurnstile()
|
||||
|
||||
const p1 = loadTurnstile()
|
||||
const p2 = loadTurnstile()
|
||||
|
||||
expect(p1).toBe(p2)
|
||||
expect(scriptCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('polls for the global when it is published asynchronously after the load event', async () => {
|
||||
vi.useFakeTimers()
|
||||
const loadTurnstile = await freshLoadTurnstile()
|
||||
|
||||
const promise = loadTurnstile()
|
||||
scriptEl()!.dispatchEvent(new Event('load'))
|
||||
// global published shortly after load
|
||||
const api = fakeApi()
|
||||
window.turnstile = api
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
await expect(promise).resolves.toBe(api)
|
||||
// tag stays in place on success
|
||||
expect(scriptEl()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('rejects and clears the cache when the global never appears after load (poll timeout)', async () => {
|
||||
vi.useFakeTimers()
|
||||
const loadTurnstile = await freshLoadTurnstile()
|
||||
|
||||
const promise = loadTurnstile()
|
||||
scriptEl()!.dispatchEvent(new Event('load'))
|
||||
// global never published; deadline elapses
|
||||
const assertion = expect(promise).rejects.toThrow(/timed out/i)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
await assertion
|
||||
// dead tag is removed so a later retry starts clean
|
||||
expect(scriptEl()).toBeNull()
|
||||
// cache was reset → a later call starts a brand-new load
|
||||
const retry = loadTurnstile()
|
||||
expect(retry).not.toBe(promise)
|
||||
// settle the throwaway retry so it doesn't leak a 10s timer
|
||||
retry.catch(() => {})
|
||||
scriptEl()!.dispatchEvent(new Event('error'))
|
||||
await expect(retry).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects, removes the self-appended script, and clears the cache on load error', async () => {
|
||||
const loadTurnstile = await freshLoadTurnstile()
|
||||
|
||||
const promise = loadTurnstile()
|
||||
scriptEl()!.dispatchEvent(new Event('error'))
|
||||
|
||||
await expect(promise).rejects.toThrow(/failed to load/i)
|
||||
expect(scriptEl()).toBeNull()
|
||||
// cache was reset → a later call starts a brand-new load
|
||||
const retry = loadTurnstile()
|
||||
expect(retry).not.toBe(promise)
|
||||
// settle the throwaway retry so it doesn't leak a 10s timer
|
||||
retry.catch(() => {})
|
||||
scriptEl()!.dispatchEvent(new Event('error'))
|
||||
await expect(retry).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects, removes the script, and clears the cache on timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
const loadTurnstile = await freshLoadTurnstile()
|
||||
|
||||
const promise = loadTurnstile()
|
||||
const assertion = expect(promise).rejects.toThrow(/timed out/i)
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
await assertion
|
||||
expect(scriptEl()).toBeNull()
|
||||
})
|
||||
|
||||
it('reuses a pre-existing script tag and resolves promptly once the global appears (no duplicate, tag left in place)', async () => {
|
||||
vi.useFakeTimers()
|
||||
const existing = new FakeScript()
|
||||
existing.src = TURNSTILE_SRC
|
||||
inserted.push(existing)
|
||||
|
||||
const loadTurnstile = await freshLoadTurnstile()
|
||||
const promise = loadTurnstile()
|
||||
|
||||
// no duplicate appended
|
||||
expect(scriptCount()).toBe(1)
|
||||
|
||||
// The pre-existing tag's load event may have already fired before we
|
||||
// attached listeners, so resolution must come from polling for the global
|
||||
// rather than from a (dead) load event.
|
||||
const api = fakeApi()
|
||||
window.turnstile = api
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
await expect(promise).resolves.toBe(api)
|
||||
// a pre-existing tag is left alone (never removed by this loader)
|
||||
expect(scriptEl()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('reuses a pre-existing script tag and times out (clearing the cache) if the global never appears, leaving the tag in place', async () => {
|
||||
vi.useFakeTimers()
|
||||
const existing = new FakeScript()
|
||||
existing.src = TURNSTILE_SRC
|
||||
inserted.push(existing)
|
||||
|
||||
const loadTurnstile = await freshLoadTurnstile()
|
||||
const promise = loadTurnstile()
|
||||
const assertion = expect(promise).rejects.toThrow(/timed out/i)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
await assertion
|
||||
// pre-existing tag is never removed by the loader
|
||||
expect(scriptEl()).not.toBeNull()
|
||||
// cache was reset → a later call starts a brand-new load
|
||||
const retry = loadTurnstile()
|
||||
expect(retry).not.toBe(promise)
|
||||
// drain the throwaway retry's timer/promise so nothing leaks
|
||||
retry.catch(() => {})
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
})
|
||||
})
|
||||
36
src/composables/auth/turnstileScript.ts
Normal file
36
src/composables/auth/turnstileScript.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createScriptLoader } from '@/utils/loadExternalScript'
|
||||
|
||||
const TURNSTILE_SRC =
|
||||
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||
|
||||
export interface TurnstileRenderOptions {
|
||||
sitekey: string
|
||||
theme?: 'light' | 'dark' | 'auto'
|
||||
callback?: (token: string) => void
|
||||
'expired-callback'?: () => void
|
||||
'error-callback'?: () => void
|
||||
}
|
||||
|
||||
export interface TurnstileApi {
|
||||
render: (
|
||||
container: string | HTMLElement,
|
||||
options: TurnstileRenderOptions
|
||||
) => string
|
||||
reset: (widgetId?: string) => void
|
||||
remove: (widgetId: string) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: TurnstileApi
|
||||
}
|
||||
}
|
||||
|
||||
const loadTurnstileScript = createScriptLoader(
|
||||
TURNSTILE_SRC,
|
||||
() => window.turnstile ?? null
|
||||
)
|
||||
|
||||
export function loadTurnstile(): Promise<TurnstileApi> {
|
||||
return loadTurnstileScript()
|
||||
}
|
||||
@@ -199,8 +199,8 @@ export const useAuthActions = () => {
|
||||
)
|
||||
|
||||
const signUpWithEmail = wrapWithErrorHandlingAsync(
|
||||
async (email: string, password: string) => {
|
||||
return await authStore.register(email, password)
|
||||
async (email: string, password: string, turnstileToken?: string) => {
|
||||
return await authStore.register(email, password, turnstileToken)
|
||||
},
|
||||
reportError
|
||||
)
|
||||
|
||||
139
src/composables/auth/useTurnstile.test.ts
Normal file
139
src/composables/auth/useTurnstile.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
isTurnstileEnabled,
|
||||
normalizeTurnstileMode,
|
||||
useTurnstile
|
||||
} from '@/composables/auth/useTurnstile'
|
||||
import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { api } from '@/scripts/api'
|
||||
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
|
||||
|
||||
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
|
||||
remoteConfig: { value: {} }
|
||||
}))
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: { getServerFeature: vi.fn() }
|
||||
}))
|
||||
vi.mock('@/utils/devFeatureFlagOverride', () => ({
|
||||
getDevOverride: vi.fn()
|
||||
}))
|
||||
vi.mock('@/config/turnstile', () => ({
|
||||
getTurnstileSiteKey: vi.fn()
|
||||
}))
|
||||
|
||||
const mockedDevOverride = vi.mocked(getDevOverride)
|
||||
const mockedGetServerFeature = vi.mocked(api.getServerFeature)
|
||||
const mockedSiteKey = vi.mocked(getTurnstileSiteKey)
|
||||
|
||||
describe('normalizeTurnstileMode', () => {
|
||||
it('passes through known modes', () => {
|
||||
expect(normalizeTurnstileMode('off')).toBe('off')
|
||||
expect(normalizeTurnstileMode('shadow')).toBe('shadow')
|
||||
expect(normalizeTurnstileMode('enforce')).toBe('enforce')
|
||||
})
|
||||
|
||||
it('clamps unknown or missing values to off', () => {
|
||||
expect(normalizeTurnstileMode('enfroce')).toBe('off')
|
||||
expect(normalizeTurnstileMode('')).toBe('off')
|
||||
expect(normalizeTurnstileMode(undefined)).toBe('off')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTurnstileEnabled', () => {
|
||||
it('renders when the flag is active and a sitekey is configured', () => {
|
||||
expect(isTurnstileEnabled('shadow', 'site-key')).toBe(true)
|
||||
expect(isTurnstileEnabled('enforce', 'site-key')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render when the flag is off', () => {
|
||||
expect(isTurnstileEnabled('off', 'site-key')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not render without a sitekey (OSS / local builds)', () => {
|
||||
expect(isTurnstileEnabled('shadow', '')).toBe(false)
|
||||
expect(isTurnstileEnabled('enforce', '')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTurnstile', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
remoteConfig.value = {}
|
||||
mockedDevOverride.mockReturnValue(undefined)
|
||||
mockedGetServerFeature.mockReturnValue('off')
|
||||
mockedSiteKey.mockReturnValue('site-key')
|
||||
})
|
||||
|
||||
describe('mode precedence', () => {
|
||||
it('prefers the dev override over remote config and the server feature', () => {
|
||||
mockedDevOverride.mockReturnValue('enforce')
|
||||
remoteConfig.value = { signup_turnstile: 'shadow' }
|
||||
mockedGetServerFeature.mockReturnValue('off')
|
||||
|
||||
expect(useTurnstile().mode.value).toBe('enforce')
|
||||
})
|
||||
|
||||
it('uses remote config when there is no dev override', () => {
|
||||
remoteConfig.value = { signup_turnstile: 'shadow' }
|
||||
|
||||
expect(useTurnstile().mode.value).toBe('shadow')
|
||||
})
|
||||
|
||||
it('falls back to the server feature flag (default off) when nothing else is set', () => {
|
||||
mockedGetServerFeature.mockReturnValue('enforce')
|
||||
|
||||
expect(useTurnstile().mode.value).toBe('enforce')
|
||||
expect(mockedGetServerFeature).toHaveBeenCalledWith(
|
||||
'signup_turnstile',
|
||||
'off'
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps an unknown remote-config value to off', () => {
|
||||
remoteConfig.value = {
|
||||
signup_turnstile: 'bogus' as unknown as 'shadow'
|
||||
}
|
||||
|
||||
expect(useTurnstile().mode.value).toBe('off')
|
||||
})
|
||||
|
||||
it('resolves to off when every source is unset', () => {
|
||||
expect(useTurnstile().mode.value).toBe('off')
|
||||
})
|
||||
})
|
||||
|
||||
describe('enabled / enforced', () => {
|
||||
it('is enabled but not enforced in shadow with a sitekey', () => {
|
||||
remoteConfig.value = { signup_turnstile: 'shadow' }
|
||||
|
||||
const { enabled, enforced } = useTurnstile()
|
||||
expect(enabled.value).toBe(true)
|
||||
expect(enforced.value).toBe(false)
|
||||
})
|
||||
|
||||
it('is enabled and enforced in enforce with a sitekey', () => {
|
||||
remoteConfig.value = { signup_turnstile: 'enforce' }
|
||||
|
||||
const { enabled, enforced } = useTurnstile()
|
||||
expect(enabled.value).toBe(true)
|
||||
expect(enforced.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is neither enabled nor enforced without a sitekey, even in enforce', () => {
|
||||
remoteConfig.value = { signup_turnstile: 'enforce' }
|
||||
mockedSiteKey.mockReturnValue('')
|
||||
|
||||
const { enabled, enforced } = useTurnstile()
|
||||
expect(enabled.value).toBe(false)
|
||||
expect(enforced.value).toBe(false)
|
||||
})
|
||||
|
||||
it('is disabled when the mode is off', () => {
|
||||
const { enabled, enforced } = useTurnstile()
|
||||
expect(enabled.value).toBe(false)
|
||||
expect(enforced.value).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
44
src/composables/auth/useTurnstile.ts
Normal file
44
src/composables/auth/useTurnstile.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import type { TurnstileMode } from '@/platform/remoteConfig/types'
|
||||
|
||||
/**
|
||||
* Clamp an externally-sourced value to a known TurnstileMode. Unknown strings
|
||||
* (typos, stale flag variants) resolve to 'off' so a bad value can never leave
|
||||
* the widget rendered-but-unenforced — mirrors the server-side resolver.
|
||||
*/
|
||||
export function normalizeTurnstileMode(raw: string | undefined): TurnstileMode {
|
||||
return raw === 'shadow' || raw === 'enforce' ? raw : 'off'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the signup Turnstile widget should render. Purely config-driven: the
|
||||
* flag must be shadow/enforce and a sitekey must be configured. OSS / local
|
||||
* builds resolve no sitekey — the real per-env keys are tree-shaken out via the
|
||||
* __DISTRIBUTION__ build define (see config/turnstile.ts) — so the widget never
|
||||
* renders. The local-OSS exemption lives server-side (loopback-IP check in
|
||||
* CreateCustomer).
|
||||
*/
|
||||
export function isTurnstileEnabled(
|
||||
mode: TurnstileMode,
|
||||
siteKey: string
|
||||
): boolean {
|
||||
return mode !== 'off' && siteKey !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive Turnstile state for the signup form.
|
||||
* - `enabled`: render the widget
|
||||
* - `enforced`: block submit until the challenge is solved
|
||||
*/
|
||||
export function useTurnstile() {
|
||||
const { flags } = useFeatureFlags()
|
||||
const mode = computed(() => normalizeTurnstileMode(flags.signupTurnstileMode))
|
||||
const siteKey = computed(getTurnstileSiteKey)
|
||||
const enabled = computed(() => isTurnstileEnabled(mode.value, siteKey.value))
|
||||
const enforced = computed(() => enabled.value && mode.value === 'enforce')
|
||||
|
||||
return { mode, siteKey, enabled, enforced }
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import type {
|
||||
CreateTopupResponse,
|
||||
CurrentTeamCreditStop,
|
||||
Plan,
|
||||
PreviewSubscribeOptions,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeOptions,
|
||||
SubscribeResponse,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier,
|
||||
@@ -21,9 +23,9 @@ export interface SubscriptionInfo {
|
||||
tier: SubscriptionTier | null
|
||||
duration: SubscriptionDuration | null
|
||||
planSlug: string | null
|
||||
/** ISO 8601 */
|
||||
/** ISO 8601; format at the display site. */
|
||||
renewalDate: string | null
|
||||
/** ISO 8601 */
|
||||
/** ISO 8601; format at the display site. */
|
||||
endDate: string | null
|
||||
isCancelled: boolean
|
||||
hasFunds: boolean
|
||||
@@ -43,16 +45,27 @@ export interface BillingActions {
|
||||
fetchBalance: () => Promise<void>
|
||||
subscribe: (
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
options?: SubscribeOptions
|
||||
) => Promise<SubscribeResponse | void>
|
||||
previewSubscribe: (
|
||||
planSlug: string
|
||||
planSlug: string,
|
||||
options?: PreviewSubscribeOptions
|
||||
) => Promise<PreviewSubscribeResponse | null>
|
||||
manageSubscription: () => Promise<void>
|
||||
cancelSubscription: () => Promise<void>
|
||||
/**
|
||||
* Reactivates a cancelled-but-still-active subscription. Legacy has no
|
||||
* dedicated endpoint, so the legacy adapter re-runs the checkout flow.
|
||||
* The workspace adapter refreshes status and balance internally on success.
|
||||
*/
|
||||
resubscribe: () => Promise<void>
|
||||
/** `amountCents` must be a whole-dollar multiple of 100. */
|
||||
/**
|
||||
* Purchases additional credits. Standardized on **whole-dollar cents**
|
||||
* (multiples of 100); the legacy adapter divides by 100 for the
|
||||
* dollar-based /customers/credit endpoint.
|
||||
* Pass-through by design: the caller owns the completed/pending follow-up
|
||||
* (balance refresh or billing-op polling), so this does not refresh.
|
||||
*/
|
||||
topup: (amountCents: number) => Promise<CreateTopupResponse | void>
|
||||
fetchPlans: () => Promise<void>
|
||||
/**
|
||||
@@ -80,8 +93,11 @@ export interface BillingState {
|
||||
isLoading: Ref<boolean>
|
||||
error: Ref<string | null>
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
/** Reflects the active workspace's tier, not the user's personal tier. */
|
||||
isFreeTier: ComputedRef<boolean>
|
||||
/** Coarse funding state (`billing_status`); legacy reports null. */
|
||||
billingStatus: ComputedRef<BillingStatus | null>
|
||||
/** Lifecycle state; legacy synthesizes it from active/cancelled flags. */
|
||||
subscriptionStatus: ComputedRef<BillingSubscriptionStatus | null>
|
||||
tier: ComputedRef<SubscriptionTier | null>
|
||||
renewalDate: ComputedRef<string | null>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import type {
|
||||
BillingStatusResponse,
|
||||
Plan
|
||||
@@ -20,12 +22,14 @@ const {
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits,
|
||||
mockUpdateActiveWorkspace,
|
||||
mockBillingStatus
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn(),
|
||||
mockUpdateActiveWorkspace: vi.fn(),
|
||||
mockBillingStatus: {
|
||||
value: {
|
||||
is_active: true,
|
||||
@@ -44,15 +48,25 @@ vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
}
|
||||
vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const teamWorkspacesEnabledRef = ref(mockTeamWorkspacesEnabled.value)
|
||||
Object.defineProperty(mockTeamWorkspacesEnabled, 'value', {
|
||||
get: () => teamWorkspacesEnabledRef.value,
|
||||
set: (value: boolean) => {
|
||||
teamWorkspacesEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
}))
|
||||
return {
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
@@ -64,7 +78,7 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
? { id: 'personal-123', type: 'personal' }
|
||||
: { id: 'team-456', type: 'team' }
|
||||
},
|
||||
updateActiveWorkspace: vi.fn()
|
||||
updateActiveWorkspace: mockUpdateActiveWorkspace
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -142,11 +156,28 @@ describe('useBillingContext', () => {
|
||||
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
|
||||
})
|
||||
|
||||
it('returns legacy type for personal workspace', () => {
|
||||
it('selects legacy type when team workspaces are disabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('selects workspace type for personal when team workspaces are enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('workspace')
|
||||
})
|
||||
|
||||
it('selects workspace type for team when team workspaces are enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('workspace')
|
||||
})
|
||||
|
||||
it('provides subscription info from legacy billing', () => {
|
||||
const { subscription } = useBillingContext()
|
||||
|
||||
@@ -206,6 +237,14 @@ describe('useBillingContext', () => {
|
||||
expect(mockPurchaseCredits).toHaveBeenCalledWith(5)
|
||||
})
|
||||
|
||||
it('rejects topup amounts that are not positive whole-dollar cents', async () => {
|
||||
const { topup } = useBillingContext()
|
||||
await expect(topup(550)).rejects.toThrow()
|
||||
await expect(topup(0)).rejects.toThrow()
|
||||
await expect(topup(-100)).rejects.toThrow()
|
||||
await expect(topup(99.5)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('provides isActiveSubscription convenience computed', () => {
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
expect(isActiveSubscription.value).toBe(true)
|
||||
@@ -221,6 +260,42 @@ describe('useBillingContext', () => {
|
||||
expect(() => showSubscriptionDialog()).not.toThrow()
|
||||
})
|
||||
|
||||
it('reinitializes workspace billing when the type flips on after legacy init', async () => {
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type, initialize } = useBillingContext()
|
||||
await initialize()
|
||||
await nextTick()
|
||||
|
||||
expect(type.value).toBe('legacy')
|
||||
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
|
||||
// Authenticated remote config resolves the flag on for the same workspace
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(workspaceApi.getBillingStatus).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscription mirror to workspace store', () => {
|
||||
it('mirrors subscription for personal workspaces when team workspaces are enabled', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
await initialize()
|
||||
await nextTick()
|
||||
|
||||
expect(mockUpdateActiveWorkspace).toHaveBeenCalledWith({
|
||||
isSubscribed: true,
|
||||
subscriptionPlan: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMaxSeats', () => {
|
||||
it('returns 1 for personal workspaces regardless of tier', () => {
|
||||
const { getMaxSeats } = useBillingContext()
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
getTierFeatures
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
PreviewSubscribeOptions,
|
||||
SubscribeOptions
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import type {
|
||||
@@ -27,11 +31,11 @@ import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspa
|
||||
const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
|
||||
/**
|
||||
* Unified billing context that automatically switches between legacy (user-scoped)
|
||||
* and workspace billing based on the active workspace type.
|
||||
* Unified billing context that selects the billing implementation by build/flag.
|
||||
*
|
||||
* - Personal workspaces use legacy billing via /customers/* endpoints
|
||||
* - Team workspaces use workspace billing via /billing/* endpoints
|
||||
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
|
||||
* - Team workspaces enabled: workspace billing via /api/billing/* for both
|
||||
* personal (single-seat workspace) and team workspaces
|
||||
*
|
||||
* The context automatically initializes when the workspace changes and provides
|
||||
* a unified interface for subscription status, balance, and billing actions.
|
||||
@@ -92,16 +96,14 @@ function useBillingContextInternal(): BillingContext {
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Determines which billing type to use:
|
||||
* - If team workspaces feature is disabled: always use legacy (/customers)
|
||||
* - If team workspaces feature is enabled:
|
||||
* - Personal workspace: use legacy (/customers)
|
||||
* - Team workspace: use workspace (/billing)
|
||||
* Determines which billing type to use, keyed only on the build/flag:
|
||||
* - Team workspaces feature disabled (OSS/Desktop): legacy (/customers)
|
||||
* - Team workspaces feature enabled: workspace (/api/billing), for both
|
||||
* personal (single-seat workspace) and team workspaces
|
||||
*/
|
||||
const type = computed<BillingType>(() => {
|
||||
if (!flags.teamWorkspacesEnabled) return 'legacy'
|
||||
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
|
||||
})
|
||||
const type = computed<BillingType>(() =>
|
||||
flags.teamWorkspacesEnabled ? 'workspace' : 'legacy'
|
||||
)
|
||||
|
||||
const activeContext = computed(() =>
|
||||
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
|
||||
@@ -173,7 +175,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
watch(
|
||||
subscription,
|
||||
(sub) => {
|
||||
if (!sub || store.isInPersonalWorkspace) return
|
||||
if (!sub) return
|
||||
|
||||
store.updateActiveWorkspace({
|
||||
isSubscribed: sub.isActive && !sub.isCancelled,
|
||||
@@ -183,26 +185,28 @@ function useBillingContextInternal(): BillingContext {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Initialize billing when workspace changes
|
||||
function resetBillingState() {
|
||||
isInitialized.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// type can flip after setup when the team-workspaces flag resolves from
|
||||
// authenticated config, swapping the active backend; a fresh init is needed.
|
||||
// The watch fires only when id or type actually changes, so any fire with a
|
||||
// workspace selected warrants a reinit.
|
||||
watch(
|
||||
() => store.activeWorkspace?.id,
|
||||
async (newWorkspaceId, oldWorkspaceId) => {
|
||||
[() => store.activeWorkspace?.id, () => type.value],
|
||||
async ([newWorkspaceId]) => {
|
||||
if (!newWorkspaceId) {
|
||||
// No workspace selected - reset state
|
||||
isInitialized.value = false
|
||||
error.value = null
|
||||
resetBillingState()
|
||||
return
|
||||
}
|
||||
|
||||
if (newWorkspaceId !== oldWorkspaceId) {
|
||||
// Workspace changed - reinitialize
|
||||
isInitialized.value = false
|
||||
try {
|
||||
await initialize()
|
||||
} catch (err) {
|
||||
// Error is already captured in error ref
|
||||
console.error('Failed to initialize billing context:', err)
|
||||
}
|
||||
isInitialized.value = false
|
||||
try {
|
||||
await initialize()
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize billing context:', err)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -233,16 +237,15 @@ function useBillingContextInternal(): BillingContext {
|
||||
return activeContext.value.fetchBalance()
|
||||
}
|
||||
|
||||
async function subscribe(
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
) {
|
||||
return activeContext.value.subscribe(planSlug, returnUrl, cancelUrl)
|
||||
async function subscribe(planSlug: string, options?: SubscribeOptions) {
|
||||
return activeContext.value.subscribe(planSlug, options)
|
||||
}
|
||||
|
||||
async function previewSubscribe(planSlug: string) {
|
||||
return activeContext.value.previewSubscribe(planSlug)
|
||||
async function previewSubscribe(
|
||||
planSlug: string,
|
||||
options?: PreviewSubscribeOptions
|
||||
) {
|
||||
return activeContext.value.previewSubscribe(planSlug, options)
|
||||
}
|
||||
|
||||
async function manageSubscription() {
|
||||
@@ -258,6 +261,15 @@ function useBillingContextInternal(): BillingContext {
|
||||
}
|
||||
|
||||
async function topup(amountCents: number) {
|
||||
if (
|
||||
!Number.isInteger(amountCents) ||
|
||||
amountCents <= 0 ||
|
||||
amountCents % 100 !== 0
|
||||
) {
|
||||
throw new Error(
|
||||
'Top-up amount must be a positive whole-dollar cent value'
|
||||
)
|
||||
}
|
||||
return activeContext.value.topup(amountCents)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
|
||||
import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
PreviewSubscribeOptions,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeOptions,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -147,15 +149,15 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
|
||||
async function subscribe(
|
||||
_planSlug: string,
|
||||
_returnUrl?: string,
|
||||
_cancelUrl?: string
|
||||
_options?: SubscribeOptions
|
||||
): Promise<SubscribeResponse | void> {
|
||||
// Legacy billing uses Stripe checkout flow via useSubscription
|
||||
await legacySubscribe()
|
||||
}
|
||||
|
||||
async function previewSubscribe(
|
||||
_planSlug: string
|
||||
_planSlug: string,
|
||||
_options?: PreviewSubscribeOptions
|
||||
): Promise<PreviewSubscribeResponse | null> {
|
||||
// Legacy billing doesn't support preview - returns null
|
||||
return null
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
|
||||
async function navigateToGraph(targetGraph: LGraph) {
|
||||
const canvasStore = useCanvasStore()
|
||||
@@ -49,23 +48,7 @@ export function useFocusNode() {
|
||||
canvasStore.canvas?.animateToBounds(graphNode.boundingRect)
|
||||
}
|
||||
|
||||
async function enterSubgraph(
|
||||
nodeId: string,
|
||||
executionIdMap?: Map<string, LGraphNode>
|
||||
) {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
const graphNode = executionIdMap
|
||||
? executionIdMap.get(nodeId)
|
||||
: getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
if (!graphNode?.graph) return
|
||||
|
||||
await navigateToGraph(graphNode.graph as LGraph)
|
||||
useLitegraphService().fitView()
|
||||
}
|
||||
|
||||
return {
|
||||
focusNode,
|
||||
enterSubgraph
|
||||
focusNode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,28 +30,6 @@ beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function createNestedSubgraphRuntime() {
|
||||
const rootGraph = new LGraph()
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const innerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const leafNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
innerSubgraph.add(leafNode)
|
||||
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
|
||||
parentGraph: outerSubgraph,
|
||||
id: 77
|
||||
})
|
||||
outerSubgraph.add(innerSubgraphNode)
|
||||
|
||||
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, {
|
||||
parentGraph: rootGraph,
|
||||
id: 65
|
||||
})
|
||||
rootGraph.add(outerSubgraphNode)
|
||||
|
||||
return { rootGraph, outerSubgraph, innerSubgraphNode, outerSubgraphNode }
|
||||
}
|
||||
|
||||
describe('Connection error clearing via onConnectionsChange', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -940,7 +918,7 @@ describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('scans nested subgraph containers during parent subgraph replay scan', async () => {
|
||||
it('skips nested subgraph containers during parent subgraph replay scan', async () => {
|
||||
const rootGraph = new LGraph()
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const innerSubgraph = createTestSubgraph({ rootGraph })
|
||||
@@ -972,100 +950,25 @@ describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
rootGraph.onNodeAdded?.(outerSubgraphNode)
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(modelScanSpy).toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
outerSubgraphNode,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(modelScanSpy).toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
leafNode,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(modelScanSpy).toHaveBeenCalledWith(
|
||||
expect(modelScanSpy).not.toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
innerSubgraphNode,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(mediaScanSpy).toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
outerSubgraphNode,
|
||||
false
|
||||
)
|
||||
expect(mediaScanSpy).toHaveBeenCalledWith(rootGraph, leafNode, false)
|
||||
expect(mediaScanSpy).toHaveBeenCalledWith(
|
||||
expect(mediaScanSpy).not.toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
innerSubgraphNode,
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('removes host-keyed promoted missing models when a source ancestor is bypassed', () => {
|
||||
const { rootGraph, outerSubgraph, innerSubgraphNode } =
|
||||
createNestedSubgraphRuntime()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
installErrorClearingHooks(outerSubgraph)
|
||||
|
||||
const modelStore = useMissingModelStore()
|
||||
modelStore.setMissingModels([
|
||||
fromAny<MissingModelCandidate, unknown>({
|
||||
nodeId: '65',
|
||||
sourceExecutionId: createNodeExecutionId([65, 77, 1]),
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'outer_ckpt',
|
||||
isAssetSupported: false,
|
||||
name: 'fake.safetensors',
|
||||
isMissing: true
|
||||
})
|
||||
])
|
||||
|
||||
innerSubgraphNode.mode = LGraphEventMode.BYPASS
|
||||
outerSubgraph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: innerSubgraphNode.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.ALWAYS,
|
||||
newValue: LGraphEventMode.BYPASS
|
||||
})
|
||||
|
||||
expect(modelStore.missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('rescans ancestor hosts when a promoted source ancestor is un-bypassed', () => {
|
||||
const { rootGraph, outerSubgraph, innerSubgraphNode, outerSubgraphNode } =
|
||||
createNestedSubgraphRuntime()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
const hostCandidate = fromAny<MissingModelCandidate, unknown>({
|
||||
nodeId: '65',
|
||||
sourceExecutionId: createNodeExecutionId([65, 77, 1]),
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'outer_ckpt',
|
||||
isAssetSupported: false,
|
||||
name: 'fake.safetensors',
|
||||
isMissing: true
|
||||
})
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockImplementation(
|
||||
(_rootGraph, node) => (node === outerSubgraphNode ? [hostCandidate] : [])
|
||||
)
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
installErrorClearingHooks(outerSubgraph)
|
||||
|
||||
innerSubgraphNode.mode = LGraphEventMode.ALWAYS
|
||||
outerSubgraph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: innerSubgraphNode.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
expect(useMissingModelStore().missingModelCandidates).toEqual([
|
||||
hostCandidate
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearWidgetRelatedErrors parameter routing', () => {
|
||||
@@ -1101,7 +1004,7 @@ describe('clearWidgetRelatedErrors parameter routing', () => {
|
||||
clearSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('clears promoted widget errors by host execution id', () => {
|
||||
it('clears promoted widget errors by interior execution id', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const graph = subgraph.rootGraph
|
||||
const host = createTestSubgraphNode(subgraph, { id: 2 })
|
||||
@@ -1129,7 +1032,7 @@ describe('clearWidgetRelatedErrors parameter routing', () => {
|
||||
const missingModelStore = useMissingModelStore()
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: '2',
|
||||
nodeId: '2:1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* works in legacy canvas mode as well.
|
||||
*/
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
@@ -34,16 +34,15 @@ 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,
|
||||
getExecutionIdByNode,
|
||||
getExecutionIdForNodeInGraph,
|
||||
getNodeByExecutionId,
|
||||
isExecutionPathActive,
|
||||
isMissingCandidateActive
|
||||
isAncestorPathActive
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { getParentExecutionIds } from '@/types/nodeIdentification'
|
||||
|
||||
const hookedNodes = new WeakSet<LGraphNode>()
|
||||
|
||||
@@ -78,29 +77,23 @@ function installNodeHooks(node: LGraphNode): void {
|
||||
|
||||
node.onWidgetChanged = useChainCallback(
|
||||
node.onWidgetChanged,
|
||||
function (name, newValue, _oldValue, widget) {
|
||||
function (_name, newValue, _oldValue, widget) {
|
||||
if (!app.rootGraph) return
|
||||
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!hostExecId) return
|
||||
|
||||
const options = { min: widget.options?.min, max: widget.options?.max }
|
||||
const source = resolvePromotedWidgetSource(app.rootGraph, node, widget)
|
||||
if (source?.sourceExecutionId) {
|
||||
useExecutionErrorStore().clearWidgetRelatedErrors(
|
||||
source.sourceExecutionId,
|
||||
source.sourceWidgetName,
|
||||
source.sourceWidgetName,
|
||||
newValue,
|
||||
options
|
||||
)
|
||||
}
|
||||
const promotedSource = widgetPromotedSource(node, widget)
|
||||
const executionId = promotedSource
|
||||
? appendNodeExecutionId(hostExecId, promotedSource.nodeId)
|
||||
: hostExecId
|
||||
const widgetName = promotedSource?.widgetName ?? widget.name
|
||||
|
||||
useExecutionErrorStore().clearWidgetRelatedErrors(
|
||||
hostExecId,
|
||||
name,
|
||||
widget.name,
|
||||
executionId,
|
||||
widgetName,
|
||||
widgetName,
|
||||
newValue,
|
||||
options
|
||||
{ min: widget.options?.min, max: widget.options?.max }
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -144,8 +137,8 @@ function scanNodeErrorTargets(
|
||||
if (!app.rootGraph) return
|
||||
|
||||
if (node.isSubgraphNode?.() && node.subgraph) {
|
||||
scanNode(node)
|
||||
for (const innerNode of collectAllNodes(node.subgraph)) {
|
||||
if (innerNode.isSubgraphNode?.()) continue
|
||||
if (isNodeInactive(innerNode.mode)) continue
|
||||
scanNode(innerNode)
|
||||
}
|
||||
@@ -164,7 +157,7 @@ function getActiveExecutionId(node: LGraphNode): string | null {
|
||||
// execId means the node has no current graph (e.g. detached mid
|
||||
// lifecycle) — also skip, since we cannot verify its scope.
|
||||
const execId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!execId || !isExecutionPathActive(app.rootGraph, execId)) return null
|
||||
if (!execId || !isAncestorPathActive(app.rootGraph, execId)) return null
|
||||
return execId
|
||||
}
|
||||
|
||||
@@ -205,8 +198,6 @@ function scanSingleNodeModelsAndTypes(node: LGraphNode): void {
|
||||
void verifyAndAddPendingModels(pendingModels)
|
||||
}
|
||||
|
||||
if (node.isSubgraphNode?.()) return
|
||||
|
||||
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
|
||||
if (!(originalType in LiteGraph.registered_node_types)) {
|
||||
const nodeReplacementStore = useNodeReplacementStore()
|
||||
@@ -249,19 +240,17 @@ function scanSingleNodeMedia(node: LGraphNode): void {
|
||||
* have been bypassed, deleted, or belong to a workflow that is no
|
||||
* longer current — any of which would reintroduce stale errors.
|
||||
*/
|
||||
function isModelCandidateStillActive(
|
||||
candidate: MissingModelCandidate
|
||||
): boolean {
|
||||
return isMissingCandidateActive(app.rootGraph, candidate)
|
||||
}
|
||||
|
||||
function isNodeCandidateStillActive(nodeId: unknown): boolean {
|
||||
return (
|
||||
app.rootGraph !== null &&
|
||||
app.rootGraph !== undefined &&
|
||||
nodeId != null &&
|
||||
isExecutionPathActive(app.rootGraph, String(nodeId))
|
||||
)
|
||||
function isCandidateStillActive(nodeId: unknown): boolean {
|
||||
if (!app.rootGraph || nodeId == null) return false
|
||||
const execId = String(nodeId)
|
||||
const node = getNodeByExecutionId(app.rootGraph, execId)
|
||||
if (!node) return false
|
||||
if (isNodeInactive(node.mode)) return false
|
||||
// Also reject if any enclosing subgraph was bypassed between scan
|
||||
// kick-off and verification resolving — mirrors the pipeline-level
|
||||
// ancestor post-filter so realtime and initial-load paths stay
|
||||
// symmetric.
|
||||
return isAncestorPathActive(app.rootGraph, execId)
|
||||
}
|
||||
|
||||
async function verifyAndAddPendingModels(
|
||||
@@ -275,7 +264,7 @@ async function verifyAndAddPendingModels(
|
||||
await verifyAssetSupportedCandidates(pending)
|
||||
if (app.rootGraph !== rootGraphAtScan) return
|
||||
const verified = pending.filter(
|
||||
(c) => c.isMissing === true && isModelCandidateStillActive(c)
|
||||
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
|
||||
)
|
||||
if (verified.length) useMissingModelStore().addMissingModels(verified)
|
||||
} catch (error: unknown) {
|
||||
@@ -291,7 +280,7 @@ async function verifyAndAddPendingMedia(
|
||||
await verifyMediaCandidates(pending, { isCloud })
|
||||
if (app.rootGraph !== rootGraphAtScan) return
|
||||
const verified = pending.filter(
|
||||
(c) => c.isMissing === true && isNodeCandidateStillActive(c.nodeId)
|
||||
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
|
||||
)
|
||||
if (verified.length) useMissingMediaStore().addMissingMedia(verified)
|
||||
} catch (error: unknown) {
|
||||
@@ -343,7 +332,6 @@ function handleNodeModeChange(
|
||||
removeNodeErrors(node, execId)
|
||||
} else {
|
||||
scanAndAddNodeErrors(node)
|
||||
scanAncestorSubgraphHosts(execId)
|
||||
if (
|
||||
useMissingModelStore().hasMissingModels ||
|
||||
useMissingMediaStore().hasMissingMedia ||
|
||||
@@ -354,15 +342,6 @@ function handleNodeModeChange(
|
||||
}
|
||||
}
|
||||
|
||||
function scanAncestorSubgraphHosts(execId: string): void {
|
||||
if (!app.rootGraph) return
|
||||
for (const ancestorId of getParentExecutionIds(execId)) {
|
||||
if (!isExecutionPathActive(app.rootGraph, ancestorId)) continue
|
||||
const ancestor = getNodeByExecutionId(app.rootGraph, ancestorId)
|
||||
if (ancestor?.isSubgraphNode?.()) scanSingleNodeErrors(ancestor)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove all missing asset errors for a node and, if it's a subgraph
|
||||
* container, for all interior nodes (prefix match on execution ID). */
|
||||
function removeNodeErrors(node: LGraphNode, execId: string): void {
|
||||
@@ -371,7 +350,6 @@ function removeNodeErrors(node: LGraphNode, execId: string): void {
|
||||
const nodesStore = useMissingNodesErrorStore()
|
||||
|
||||
modelStore.removeMissingModelsByNodeId(execId)
|
||||
modelStore.removeMissingModelsBySourceScope(execId)
|
||||
mediaStore.removeMissingMediaByNodeId(execId)
|
||||
nodesStore.removeMissingNodesByNodeId(execId)
|
||||
|
||||
|
||||
@@ -7,8 +7,12 @@ import cloneDeep from 'es-toolkit/compat/cloneDeep'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import {
|
||||
inputForWidget,
|
||||
promotedInputSource,
|
||||
promotedInputWidgets
|
||||
} from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
@@ -42,6 +46,7 @@ import type {
|
||||
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { app } from '@/scripts/app'
|
||||
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
export interface WidgetSlotMetadata {
|
||||
index: number
|
||||
@@ -79,6 +84,7 @@ export interface SafeWidgetData {
|
||||
advanced?: boolean
|
||||
hidden?: boolean
|
||||
read_only?: boolean
|
||||
removable?: boolean
|
||||
values?: unknown
|
||||
}
|
||||
/** Input specification from node definition */
|
||||
@@ -87,13 +93,15 @@ export interface SafeWidgetData {
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
/**
|
||||
* Execution ID of the interior node that owns the source widget.
|
||||
* Only set for promoted widgets where the source node differs from the host
|
||||
* subgraph node. Retained for source-scoped validation errors.
|
||||
* Only set for promoted widgets where the source node differs from the
|
||||
* 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
|
||||
/**
|
||||
* Interior source widget name. Only set for promoted widgets, where `name` is
|
||||
* the host input slot name and the source widget name can differ.
|
||||
* Interior source widget name. Only set for promoted widgets, where `name`
|
||||
* is the host input slot name; missing-model lookups key by the interior
|
||||
* widget name, which can differ from the slot name (e.g. after a rename).
|
||||
*/
|
||||
sourceWidgetName?: string
|
||||
/** Tooltip text from the resolved widget. */
|
||||
@@ -206,7 +214,8 @@ function extractWidgetDisplayOptions(
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only
|
||||
read_only: widget.options.read_only,
|
||||
removable: widget.options.removable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,20 +243,31 @@ function resolvePromotedMetadata(
|
||||
node: SubgraphNode,
|
||||
widget: IBaseWidget
|
||||
): PromotedWidgetMetadata | undefined {
|
||||
const source = resolvePromotedWidgetSource(app.rootGraph, node, widget)
|
||||
const input = inputForWidget(node, widget)
|
||||
if (!input?.widgetId) return undefined
|
||||
const source = promotedInputSource(node, input)
|
||||
if (!source) return undefined
|
||||
|
||||
ensurePromotedHostWidgetState(
|
||||
source.input.widgetId,
|
||||
source.input,
|
||||
source.sourceWidget
|
||||
const resolution = resolveConcretePromotedWidget(
|
||||
node,
|
||||
source.nodeId,
|
||||
source.widgetName
|
||||
)
|
||||
const resolved =
|
||||
resolution.status === 'resolved' ? resolution.resolved : undefined
|
||||
const sourceWidget = resolved?.widget
|
||||
const sourceNode = resolved?.node
|
||||
|
||||
ensurePromotedHostWidgetState(input.widgetId, input, sourceWidget)
|
||||
|
||||
return {
|
||||
controlWidget: getControlWidget(source.sourceWidget),
|
||||
isDOMWidget: isDOMBackedWidget(source.sourceWidget),
|
||||
sourceExecutionId: source.sourceExecutionId,
|
||||
sourceWidgetName: source.sourceWidgetName
|
||||
controlWidget: sourceWidget ? getControlWidget(sourceWidget) : undefined,
|
||||
isDOMWidget: sourceWidget ? isDOMBackedWidget(sourceWidget) : false,
|
||||
sourceExecutionId:
|
||||
sourceNode && app.rootGraph
|
||||
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
|
||||
: undefined,
|
||||
sourceWidgetName: sourceWidget?.name
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -221,6 +221,39 @@ describe('useFeatureFlags', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('signupTurnstileMode', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('falls back to the server feature flag with default off', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.SIGNUP_TURNSTILE) return 'enforce'
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.signupTurnstileMode).toBe('enforce')
|
||||
expect(api.getServerFeature).toHaveBeenCalledWith(
|
||||
ServerFeatureFlag.SIGNUP_TURNSTILE,
|
||||
'off'
|
||||
)
|
||||
})
|
||||
|
||||
it('lets a dev override beat the server value', () => {
|
||||
vi.mocked(api.getServerFeature).mockReturnValue('off')
|
||||
localStorage.setItem(
|
||||
`ff:${ServerFeatureFlag.SIGNUP_TURNSTILE}`,
|
||||
'"shadow"'
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.signupTurnstileMode).toBe('shadow')
|
||||
})
|
||||
})
|
||||
|
||||
describe('unifiedCloudAuthEnabled', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
|
||||
@@ -29,7 +29,8 @@ export enum ServerFeatureFlag {
|
||||
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth'
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,6 +174,13 @@ export function useFeatureFlags() {
|
||||
remoteConfig.value.unified_cloud_auth,
|
||||
false
|
||||
)
|
||||
},
|
||||
get signupTurnstileMode() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.SIGNUP_TURNSTILE,
|
||||
remoteConfig.value.signup_turnstile,
|
||||
'off'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
96
src/composables/useUrlActionLoaders.test.ts
Normal file
96
src/composables/useUrlActionLoaders.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useUrlActionLoaders } from './useUrlActionLoaders'
|
||||
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: true }))
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
const mockFlags = vi.hoisted(() => ({ value: { teamWorkspacesEnabled: true } }))
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags.value })
|
||||
}))
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadInvite: vi.fn().mockResolvedValue(undefined),
|
||||
loadCreateWorkspace: vi.fn().mockResolvedValue(undefined),
|
||||
loadPricingTable: vi.fn().mockResolvedValue(undefined),
|
||||
useInvite: vi.fn(),
|
||||
useCreateWorkspace: vi.fn(),
|
||||
usePricingTable: vi.fn()
|
||||
}))
|
||||
mocks.useInvite.mockImplementation(() => ({
|
||||
loadInviteFromUrl: mocks.loadInvite
|
||||
}))
|
||||
mocks.useCreateWorkspace.mockImplementation(() => ({
|
||||
loadCreateWorkspaceFromUrl: mocks.loadCreateWorkspace
|
||||
}))
|
||||
mocks.usePricingTable.mockImplementation(() => ({
|
||||
loadPricingTableFromUrl: mocks.loadPricingTable
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useInviteUrlLoader', () => ({
|
||||
useInviteUrlLoader: mocks.useInvite
|
||||
}))
|
||||
vi.mock('@/platform/workspace/composables/useCreateWorkspaceUrlLoader', () => ({
|
||||
useCreateWorkspaceUrlLoader: mocks.useCreateWorkspace
|
||||
}))
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/usePricingTableUrlLoader',
|
||||
() => ({ usePricingTableUrlLoader: mocks.usePricingTable })
|
||||
)
|
||||
|
||||
describe('useUrlActionLoaders', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockFlags.value = { teamWorkspacesEnabled: true }
|
||||
})
|
||||
|
||||
it('does not instantiate or run any loader off cloud', async () => {
|
||||
mockIsCloud.value = false
|
||||
|
||||
const { runUrlActionLoaders } = useUrlActionLoaders()
|
||||
await runUrlActionLoaders()
|
||||
|
||||
expect(mocks.useInvite).not.toHaveBeenCalled()
|
||||
expect(mocks.useCreateWorkspace).not.toHaveBeenCalled()
|
||||
expect(mocks.usePricingTable).not.toHaveBeenCalled()
|
||||
expect(mocks.loadInvite).not.toHaveBeenCalled()
|
||||
expect(mocks.loadCreateWorkspace).not.toHaveBeenCalled()
|
||||
expect(mocks.loadPricingTable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs all loaders on cloud when team workspaces are enabled', async () => {
|
||||
const { runUrlActionLoaders } = useUrlActionLoaders()
|
||||
await runUrlActionLoaders()
|
||||
|
||||
expect(mocks.loadInvite).toHaveBeenCalledOnce()
|
||||
expect(mocks.loadCreateWorkspace).toHaveBeenCalledOnce()
|
||||
expect(mocks.loadPricingTable).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('runs the pricing loader but skips the flag-gated loaders when team workspaces are disabled', async () => {
|
||||
mockFlags.value = { teamWorkspacesEnabled: false }
|
||||
|
||||
const { runUrlActionLoaders } = useUrlActionLoaders()
|
||||
await runUrlActionLoaders()
|
||||
|
||||
expect(mocks.loadInvite).not.toHaveBeenCalled()
|
||||
expect(mocks.loadCreateWorkspace).not.toHaveBeenCalled()
|
||||
expect(mocks.loadPricingTable).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('isolates a pricing-loader failure so it does not abort the boot chain', async () => {
|
||||
mocks.loadPricingTable.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
const { runUrlActionLoaders } = useUrlActionLoaders()
|
||||
await expect(runUrlActionLoaders()).resolves.toBeUndefined()
|
||||
|
||||
expect(mocks.loadInvite).toHaveBeenCalledOnce()
|
||||
expect(mocks.loadCreateWorkspace).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
55
src/composables/useUrlActionLoaders.ts
Normal file
55
src/composables/useUrlActionLoaders.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { usePricingTableUrlLoader } from '@/platform/cloud/subscription/composables/usePricingTableUrlLoader'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
|
||||
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
|
||||
|
||||
/**
|
||||
* Aggregates the query-param "deep link" loaders the cloud app checks on mount
|
||||
* (`?invite`, `?create_workspace`, `?pricing`). The loaders are instantiated in
|
||||
* setup so their `useRoute`/`useRouter` resolve; call `runUrlActionLoaders()`
|
||||
* from `onMounted` once the app is ready.
|
||||
*/
|
||||
export function useUrlActionLoaders() {
|
||||
const { flags } = useFeatureFlags()
|
||||
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
|
||||
const createWorkspaceUrlLoader = isCloud
|
||||
? useCreateWorkspaceUrlLoader()
|
||||
: null
|
||||
const pricingTableUrlLoader = isCloud ? usePricingTableUrlLoader() : null
|
||||
|
||||
async function runUrlActionLoaders() {
|
||||
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN).
|
||||
// WorkspaceAuthGate ensures flag state is resolved before the app mounts.
|
||||
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {
|
||||
await inviteUrlLoader.loadInviteFromUrl()
|
||||
}
|
||||
|
||||
// Open create workspace dialog from URL if present (e.g., ?create_workspace=1).
|
||||
if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) {
|
||||
try {
|
||||
await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[UrlActionLoaders] Failed to load create workspace from URL:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Open the pricing table from URL if present (e.g., ?pricing=1 / ?pricing=team).
|
||||
// Not gated on the team-workspaces flag: it also drives personal/legacy users.
|
||||
if (pricingTableUrlLoader) {
|
||||
try {
|
||||
await pricingTableUrlLoader.loadPricingTableFromUrl()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[UrlActionLoaders] Failed to load pricing table from URL:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { runUrlActionLoaders }
|
||||
}
|
||||
@@ -8,12 +8,12 @@ export function useWorkflowStatusDismissal() {
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow,
|
||||
(workflow) => {
|
||||
if (
|
||||
workflow &&
|
||||
executionStore.getWorkflowStatus(workflow) !== 'running'
|
||||
) {
|
||||
() => {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
return [workflow, executionStore.getWorkflowStatus(workflow)] as const
|
||||
},
|
||||
([workflow, status]) => {
|
||||
if (workflow && status !== undefined && status !== 'running') {
|
||||
executionStore.clearWorkflowStatus(workflow)
|
||||
}
|
||||
},
|
||||
|
||||
72
src/config/turnstile.test.ts
Normal file
72
src/config/turnstile.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
|
||||
const TURNSTILE_TEST_SITE_KEY = '1x00000000000000000000AA'
|
||||
// __USE_PROD_CONFIG__ is false under vitest (see vitest.setup.ts), so the
|
||||
// build-time fallback resolves to the staging sitekey.
|
||||
const STAGING_TURNSTILE_SITE_KEY = '0x4AAAAAADnYY4_Q0qxHZ5a7'
|
||||
|
||||
// Mutable containers go through vi.hoisted so the hoisted vi.mock factories can
|
||||
// reference them without a temporal-dead-zone crash (which surfaces under
|
||||
// coverage instrumentation, not a plain run).
|
||||
const { mockRemoteConfig } = vi.hoisted(() => ({
|
||||
mockRemoteConfig: { value: {} as Record<string, unknown> }
|
||||
}))
|
||||
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
|
||||
remoteConfig: mockRemoteConfig,
|
||||
configValueOrDefault: (
|
||||
cfg: Record<string, unknown>,
|
||||
key: string,
|
||||
fallback: unknown
|
||||
) => cfg[key] || fallback
|
||||
}))
|
||||
|
||||
describe('getTurnstileSiteKey', () => {
|
||||
beforeEach(() => {
|
||||
mockRemoteConfig.value = {}
|
||||
vi.stubGlobal('__DISTRIBUTION__', 'localhost')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('OSS / non-cloud build', () => {
|
||||
it('falls back to the always-pass test key in dev', () => {
|
||||
vi.stubEnv('DEV', true)
|
||||
|
||||
expect(getTurnstileSiteKey()).toBe(TURNSTILE_TEST_SITE_KEY)
|
||||
})
|
||||
|
||||
it('returns empty string outside dev so the widget never renders', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
|
||||
expect(getTurnstileSiteKey()).toBe('')
|
||||
})
|
||||
|
||||
it('ignores remote config (the widget is cloud-only)', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
mockRemoteConfig.value = { turnstile_sitekey: '0xshould-not-be-used' }
|
||||
|
||||
expect(getTurnstileSiteKey()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud build', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('__DISTRIBUTION__', 'cloud')
|
||||
})
|
||||
|
||||
it('returns the sitekey delivered via remote config', () => {
|
||||
mockRemoteConfig.value = { turnstile_sitekey: '0x4AAAAAreal' }
|
||||
|
||||
expect(getTurnstileSiteKey()).toBe('0x4AAAAAreal')
|
||||
})
|
||||
|
||||
it('falls back to the build-time per-env sitekey during a remote-config gap', () => {
|
||||
expect(getTurnstileSiteKey()).toBe(STAGING_TURNSTILE_SITE_KEY)
|
||||
})
|
||||
})
|
||||
})
|
||||
43
src/config/turnstile.ts
Normal file
43
src/config/turnstile.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
configValueOrDefault,
|
||||
remoteConfig
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
/**
|
||||
* Cloudflare Turnstile always-pass test sitekey, used only in local dev so the
|
||||
* signup flow can be exercised without a real key.
|
||||
* @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/
|
||||
*/
|
||||
const TURNSTILE_TEST_SITE_KEY = '1x00000000000000000000AA'
|
||||
|
||||
// Public per-environment sitekeys, baked at build time so a cloud build renders
|
||||
// the widget even before (or without) remote config; remote config still
|
||||
// overrides them, so keys rotate live without a rebuild.
|
||||
const PROD_TURNSTILE_SITE_KEY = '0x4AAAAAADnYZPVOpFCL_zeo'
|
||||
const STAGING_TURNSTILE_SITE_KEY = '0x4AAAAAADnYY4_Q0qxHZ5a7'
|
||||
|
||||
/**
|
||||
* Returns the Cloudflare Turnstile sitekey for the current environment.
|
||||
* - OSS / localhost never renders the cloud widget (server-side loopback
|
||||
* exemption covers local signup); in dev it falls back to the always-pass test
|
||||
* key so the flow is exercisable locally, otherwise ''.
|
||||
* - Cloud builds prefer the per-env sitekey delivered via remote config
|
||||
* (`turnstile_sitekey`) and fall back to the build-time constant, so the widget
|
||||
* still renders during a remote-config gap rather than silently disappearing.
|
||||
*/
|
||||
export function getTurnstileSiteKey(): string {
|
||||
// Gate on the __DISTRIBUTION__ build define rather than the cross-module
|
||||
// `isCloud` const so dead-code elimination strips the real per-env sitekeys
|
||||
// from OSS/desktop bundles — same idiom as initTelemetry.ts, enforced by the
|
||||
// dist scan in ci-dist-telemetry-scan.yaml.
|
||||
const isCloudBuild = __DISTRIBUTION__ === 'cloud'
|
||||
if (!isCloudBuild) {
|
||||
return import.meta.env.DEV ? TURNSTILE_TEST_SITE_KEY : ''
|
||||
}
|
||||
|
||||
return configValueOrDefault(
|
||||
remoteConfig.value,
|
||||
'turnstile_sitekey',
|
||||
__USE_PROD_CONFIG__ ? PROD_TURNSTILE_SITE_KEY : STAGING_TURNSTILE_SITE_KEY
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export interface ResolvedPromotedWidget {
|
||||
node: LGraphNode
|
||||
nodePath: string[]
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ describe('resolveConcretePromotedWidget', () => {
|
||||
expect(result.status).toBe('resolved')
|
||||
if (result.status !== 'resolved') return
|
||||
expect(result.resolved.node.id).toBe(concreteNode.id)
|
||||
expect(result.resolved.nodePath).toEqual([String(concreteNode.id)])
|
||||
expect(result.resolved.widget.name).toBe('seed')
|
||||
})
|
||||
|
||||
@@ -92,10 +91,6 @@ describe('resolveConcretePromotedWidget', () => {
|
||||
expect(result.status).toBe('resolved')
|
||||
if (result.status !== 'resolved') return
|
||||
expect(result.resolved.node.id).toBe(leaf.id)
|
||||
expect(result.resolved.nodePath).toEqual([
|
||||
String(innerNode.id),
|
||||
String(leaf.id)
|
||||
])
|
||||
expect(result.resolved.widget.name).toBe('seed')
|
||||
expect(result.resolved.widget.type).toBe('combo')
|
||||
})
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidge
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
type PromotedWidgetResolutionFailure =
|
||||
| 'invalid-host'
|
||||
@@ -27,7 +25,6 @@ function traversePromotedWidgetChain(
|
||||
let currentHost = hostNode
|
||||
let currentNodeId = nodeId
|
||||
let currentWidgetName = widgetName
|
||||
const nodePath: string[] = []
|
||||
|
||||
for (let depth = 0; depth < MAX_PROMOTED_WIDGET_CHAIN_DEPTH; depth++) {
|
||||
const key = `${currentNodeId}:${currentWidgetName}`
|
||||
@@ -42,7 +39,6 @@ function traversePromotedWidgetChain(
|
||||
if (!sourceNode) {
|
||||
return { status: 'failure', failure: 'missing-node' }
|
||||
}
|
||||
nodePath.push(String(sourceNode.id))
|
||||
|
||||
if (sourceNode.isSubgraphNode()) {
|
||||
const target = resolveSubgraphInputTarget(sourceNode, currentWidgetName)
|
||||
@@ -64,7 +60,7 @@ function traversePromotedWidgetChain(
|
||||
|
||||
return {
|
||||
status: 'resolved',
|
||||
resolved: { node: sourceNode, nodePath, widget: sourceWidget }
|
||||
resolved: { node: sourceNode, widget: sourceWidget }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,12 +77,3 @@ export function resolveConcretePromotedWidget(
|
||||
}
|
||||
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
|
||||
}
|
||||
|
||||
export function buildPromotedSourceExecutionId(
|
||||
hostExecutionId: string,
|
||||
nodePath: readonly string[]
|
||||
): NodeExecutionId | undefined {
|
||||
return nodePath.length
|
||||
? createNodeExecutionId([...hostExecutionId.split(':'), ...nodePath])
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import { inputForWidget, promotedInputSource } from './promotedInputWidget'
|
||||
import {
|
||||
buildPromotedSourceExecutionId,
|
||||
resolveConcretePromotedWidget
|
||||
} from './resolveConcretePromotedWidget'
|
||||
|
||||
type PromotedWidgetInput = INodeInputSlot & {
|
||||
widgetId: NonNullable<INodeInputSlot['widgetId']>
|
||||
}
|
||||
|
||||
function hasWidgetId(
|
||||
input: INodeInputSlot | undefined
|
||||
): input is PromotedWidgetInput {
|
||||
return input?.widgetId !== undefined
|
||||
}
|
||||
|
||||
interface ResolvedPromotedWidgetSource {
|
||||
input: PromotedWidgetInput
|
||||
sourceExecutionId?: NodeExecutionId
|
||||
sourceNode: LGraphNode
|
||||
sourceWidget: IBaseWidget
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
export function resolvePromotedWidgetSource(
|
||||
rootGraph: LGraph | null | undefined,
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): ResolvedPromotedWidgetSource | undefined {
|
||||
if (!node.isSubgraphNode?.()) return undefined
|
||||
|
||||
const input = inputForWidget(node, widget)
|
||||
if (!hasWidgetId(input)) return undefined
|
||||
|
||||
const source = promotedInputSource(node, input)
|
||||
if (!source) return undefined
|
||||
|
||||
const resolution = resolveConcretePromotedWidget(
|
||||
node,
|
||||
source.nodeId,
|
||||
source.widgetName
|
||||
)
|
||||
if (resolution.status !== 'resolved') return undefined
|
||||
|
||||
const { node: sourceNode, widget: sourceWidget } = resolution.resolved
|
||||
const hostExecutionId = rootGraph
|
||||
? getExecutionIdByNode(rootGraph, node)
|
||||
: undefined
|
||||
return {
|
||||
input,
|
||||
sourceExecutionId: hostExecutionId
|
||||
? buildPromotedSourceExecutionId(
|
||||
hostExecutionId,
|
||||
resolution.resolved.nodePath
|
||||
)
|
||||
: undefined,
|
||||
sourceNode,
|
||||
sourceWidget,
|
||||
sourceWidgetName: sourceWidget.name
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import { zAutogrowOptions, zMatchTypeOptions } from '@/schemas/nodeDefSchema'
|
||||
import {
|
||||
zAutogrowOptions,
|
||||
zDynamicGroupInputSpec,
|
||||
zMatchTypeOptions
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
@@ -8,6 +12,7 @@ const dynamicTypeResolvers: Record<
|
||||
(inputSpec: InputSpecV2) => string[]
|
||||
> = {
|
||||
COMFY_AUTOGROW_V3: resolveAutogrowType,
|
||||
COMFY_DYNAMICGROUP_V3: resolveDynamicGroupType,
|
||||
COMFY_MATCHTYPE_V3: (input) =>
|
||||
zMatchTypeOptions
|
||||
.safeParse(input)
|
||||
@@ -20,6 +25,21 @@ export function resolveInputType(input: InputSpecV2): string[] {
|
||||
: input.type.split(',')
|
||||
}
|
||||
|
||||
function resolveDynamicGroupType(rawSpec: InputSpecV2): string[] {
|
||||
const parsed = zDynamicGroupInputSpec.safeParse([rawSpec.type, rawSpec])
|
||||
const template = parsed.data?.[1]?.template
|
||||
if (!template) return []
|
||||
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
|
||||
template.required,
|
||||
template.optional
|
||||
]
|
||||
return inputTypes.flatMap((inputType) =>
|
||||
Object.entries(inputType ?? {}).flatMap(([name, v]) =>
|
||||
resolveInputType(transformInputSpecV1ToV2(v, { name }))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveAutogrowType(rawSpec: InputSpecV2): string[] {
|
||||
const { input } = zAutogrowOptions.safeParse(rawSpec).data?.template ?? {}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
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 { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -47,6 +49,22 @@ 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,
|
||||
@@ -287,3 +305,101 @@ describe('Autogrow', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
describe('Dynamic Groups', () => {
|
||||
const stringTemplate = { required: { a: ['STRING', {}] } }
|
||||
const widgetNames = (node: LGraphNode) => node.widgets!.map((w) => w.name)
|
||||
const inputNames = (node: LGraphNode) => node.inputs.map((i) => i.name)
|
||||
const widgetNamed = (node: LGraphNode, name: string) =>
|
||||
node.widgets!.find((w) => w.name === name)!
|
||||
|
||||
test('renders min rows on creation', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 2, max: 5 })
|
||||
expect(widgetNames(node)).toStrictEqual([
|
||||
'g',
|
||||
'g.__row__0',
|
||||
'g.0.a',
|
||||
'g.__row__1',
|
||||
'g.1.a'
|
||||
])
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('add row appends a new row up to max', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 2 })
|
||||
expect(widgetNames(node)).toStrictEqual(['g'])
|
||||
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a'])
|
||||
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
|
||||
// At max, further adds are ignored.
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('remove row renumbers later rows', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
|
||||
const row0Field = widgetNamed(node, 'g.0.a')
|
||||
const row2Field = widgetNamed(node, 'g.2.a')
|
||||
|
||||
widgetNamed(node, 'g.__row__1').callback?.(undefined)
|
||||
|
||||
expect(widgetNames(node)).toStrictEqual([
|
||||
'g',
|
||||
'g.__row__0',
|
||||
'g.0.a',
|
||||
'g.__row__1',
|
||||
'g.1.a'
|
||||
])
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
// Row 0 is untouched; the former row 2 shifts down into row 1.
|
||||
expect(widgetNamed(node, 'g.0.a')).toBe(row0Field)
|
||||
expect(widgetNamed(node, 'g.1.a')).toBe(row2Field)
|
||||
})
|
||||
|
||||
test('rows below min are not removable', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 1, max: 5 })
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
|
||||
expect(widgetNamed(node, 'g.__row__0').options?.removable).toBe(false)
|
||||
expect(widgetNamed(node, 'g.__row__1').options?.removable).toBe(true)
|
||||
|
||||
// Attempting to remove a protected row is a no-op.
|
||||
widgetNamed(node, 'g.__row__0').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('canvas click removes a row only on the remove hit target', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
|
||||
const header = widgetNamed(node, 'g.__row__1')
|
||||
const up = { type: 'pointerup' } as CanvasPointerEvent
|
||||
const down = { type: 'pointerdown' } as CanvasPointerEvent
|
||||
const xCenter = node.size[0] - 15 - LiteGraph.NODE_WIDGET_HEIGHT * 0.5
|
||||
|
||||
// Releasing away from the remove target does nothing.
|
||||
header.mouse?.(up, [0, 0] as Point, node)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
|
||||
// A pointerdown on the target does nothing (only release acts).
|
||||
header.mouse?.(down, [xCenter, 0] as Point, node)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
|
||||
// Releasing on the target removes the row.
|
||||
header.mouse?.(up, [xCenter, 0] as Point, node)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,10 +2,12 @@ import { remove } from 'es-toolkit'
|
||||
import { shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
ISlotType,
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
INodeOutputSlot,
|
||||
Point
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -13,11 +15,14 @@ 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'
|
||||
@@ -28,6 +33,15 @@ 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>> }
|
||||
@@ -77,6 +91,7 @@ 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
|
||||
@@ -99,7 +114,10 @@ function dynamicComboWidget(
|
||||
const newSpec = value ? options[value] : undefined
|
||||
|
||||
const removedInputs = remove(node.inputs, isInGroup)
|
||||
for (const widget of remove(node.widgets, isInGroup)) widget.onRemove?.()
|
||||
for (const widget of remove(node.widgets, isInGroup)) {
|
||||
widget.onRemove?.()
|
||||
if (widget.widgetId) deleteWidget(widget.widgetId)
|
||||
}
|
||||
|
||||
if (!newSpec) return
|
||||
|
||||
@@ -210,7 +228,321 @@ function dynamicComboWidget(
|
||||
return { widget, minWidth, minHeight }
|
||||
}
|
||||
|
||||
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }
|
||||
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
|
||||
}
|
||||
const dynamicInputs: Record<
|
||||
string,
|
||||
(node: LGraphNode, inputSpec: InputSpecV2) => void
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d', () => ({}))
|
||||
vi.mock('@/extensions/core/load3dAdvanced', () => ({}))
|
||||
vi.mock('@/extensions/core/load3dPreviewExtensions', () => ({}))
|
||||
vi.mock('@/extensions/core/saveMesh', () => ({}))
|
||||
|
||||
|
||||
@@ -246,3 +246,37 @@ describe('Comfy.UploadAudio AUDIOUPLOAD widget', () => {
|
||||
expect(mockFetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
type AudioUIWidget = (node: LGraphNode, inputName: string) => unknown
|
||||
|
||||
async function loadAudioUIWidget() {
|
||||
vi.resetModules()
|
||||
mockRegisterExtension.mockClear()
|
||||
await import('./uploadAudio')
|
||||
const extension = mockRegisterExtension.mock.calls
|
||||
.map(([extension]) => extension as ComfyExtension)
|
||||
.find((extension) => extension.name === 'Comfy.AudioWidget')
|
||||
if (!extension)
|
||||
throw new Error('Comfy.AudioWidget extension was not registered')
|
||||
const widgets = await extension.getCustomWidgets!(fromAny({}))
|
||||
return (widgets as Record<string, AudioUIWidget>).AUDIO_UI
|
||||
}
|
||||
|
||||
describe('Comfy.AudioWidget AUDIO_UI widget', () => {
|
||||
it('excludes the audio player from workflow and prompt serialization', async () => {
|
||||
const AUDIO_UI = await loadAudioUIWidget()
|
||||
const domWidget = {
|
||||
serialize: true,
|
||||
options: {} as Record<string, unknown>
|
||||
}
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
addDOMWidget: vi.fn(() => domWidget),
|
||||
constructor: { nodeData: { output_node: false } }
|
||||
})
|
||||
|
||||
AUDIO_UI(node, 'audioUI')
|
||||
|
||||
expect(domWidget.serialize).toBe(false)
|
||||
expect(domWidget.options.serialize).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -128,6 +128,7 @@ app.registerExtension({
|
||||
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
|
||||
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
|
||||
audioUIWidget.serialize = false
|
||||
audioUIWidget.options.serialize = false
|
||||
const { nodeData } = node.constructor
|
||||
if (nodeData == null) throw new TypeError('nodeData is null')
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodeId, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
@@ -529,6 +529,52 @@ describe('Subgraph Definition Garbage Collection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('beforeChange deprecated onBeforeChange shim', () => {
|
||||
beforeEach(() => {
|
||||
LiteGraph.onDeprecationWarning = []
|
||||
LiteGraph.alwaysRepeatWarnings = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LiteGraph.alwaysRepeatWarnings = false
|
||||
})
|
||||
|
||||
it('still invokes a listener assigned to onBeforeChange', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
const onBeforeChange = vi.fn()
|
||||
graph.onBeforeChange = onBeforeChange
|
||||
|
||||
graph.beforeChange(node)
|
||||
|
||||
expect(onBeforeChange).toHaveBeenCalledWith(graph, node)
|
||||
})
|
||||
|
||||
it('warns that onBeforeChange is deprecated when used', () => {
|
||||
const graph = new LGraph()
|
||||
const deprecationCallback = vi.fn()
|
||||
LiteGraph.onDeprecationWarning = [deprecationCallback]
|
||||
graph.onBeforeChange = vi.fn()
|
||||
|
||||
graph.beforeChange()
|
||||
|
||||
expect(deprecationCallback).toHaveBeenCalledWith(
|
||||
expect.stringContaining('LGraph.onBeforeChange is deprecated'),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('does not warn when no listener is assigned', () => {
|
||||
const graph = new LGraph()
|
||||
const deprecationCallback = vi.fn()
|
||||
LiteGraph.onDeprecationWarning = [deprecationCallback]
|
||||
|
||||
graph.beforeChange()
|
||||
|
||||
expect(deprecationCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Legacy LGraph Compatibility Layer', () => {
|
||||
test('can be extended via prototype', ({ expect, minimalGraph }) => {
|
||||
// @ts-expect-error Should always be an error.
|
||||
|
||||
@@ -38,7 +38,6 @@ import type {
|
||||
DefaultConnectionColors,
|
||||
Dictionary,
|
||||
HasBoundingRect,
|
||||
IContextMenuValue,
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
LinkNetwork,
|
||||
@@ -56,6 +55,7 @@ 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'
|
||||
@@ -331,16 +331,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[]
|
||||
@@ -1357,7 +1357,12 @@ export class LGraph
|
||||
|
||||
// used for undo, called before any change is made to the graph
|
||||
beforeChange(info?: LGraphNode): void {
|
||||
this.onBeforeChange?.(this, info)
|
||||
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.canvasAction((c) => c.onBeforeChange?.(this))
|
||||
}
|
||||
|
||||
|
||||
@@ -829,6 +829,7 @@ 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
|
||||
@@ -8641,8 +8642,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
callback: LGraphCanvas.onMenuNodeRemove
|
||||
})
|
||||
|
||||
node.graph?.onGetNodeMenuOptions?.(options, node)
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface IWidgetOptions<TValues = unknown> {
|
||||
|
||||
// Vue widget options
|
||||
disabled?: boolean
|
||||
removable?: boolean
|
||||
useGrouping?: boolean
|
||||
placeholder?: string
|
||||
showThumbnails?: boolean
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"errorUserTokenAccessDenied": "رمز API الخاص بك لا يملك صلاحية الوصول إلى هذا المورد. يرجى التحقق من أذونات الرمز.",
|
||||
"errorUserTokenInvalid": "رمز API المخزن غير صالح أو منتهي الصلاحية. يرجى تحديث الرمز في الإعدادات.",
|
||||
"failedToCreateNode": "فشل إنشاء العقدة. يرجى المحاولة مرة أخرى أو التحقق من وحدة التحكم للحصول على التفاصيل.",
|
||||
"failedToSetModelValue": "تمت إضافة العقدة، لكن لم يتم تعيين النموذج تلقائيًا. تحقق من وحدة التحكم لمزيد من التفاصيل.",
|
||||
"fileFormats": "تنسيقات الملفات",
|
||||
"fileName": "اسم الملف",
|
||||
"fileSize": "حجم الملف",
|
||||
@@ -241,7 +242,8 @@
|
||||
"auth/user-not-found": "لم يتم العثور على حساب بهذا البريد الإلكتروني. هل ترغب في إنشاء حساب جديد؟",
|
||||
"auth/weak-password": "كلمة المرور ضعيفة جداً. يرجى استخدام كلمة مرور أقوى تحتوي على 6 أحرف على الأقل.",
|
||||
"auth/wrong-password": "كلمة المرور التي أدخلتها غير صحيحة. يرجى المحاولة مرة أخرى.",
|
||||
"generic": "حدث خطأ أثناء تسجيل الدخول. يرجى المحاولة مرة أخرى."
|
||||
"generic": "حدث خطأ أثناء تسجيل الدخول. يرجى المحاولة مرة أخرى.",
|
||||
"signupBlocked": "تعذر إنشاء حسابك الآن. يرجى المحاولة لاحقًا. إذا استمرت المشكلة، راسل support@comfy.org."
|
||||
},
|
||||
"login": {
|
||||
"andText": "و",
|
||||
@@ -323,6 +325,11 @@
|
||||
"signUpWithGithub": "إنشاء حساب باستخدام Github",
|
||||
"signUpWithGoogle": "إنشاء حساب باستخدام Google",
|
||||
"title": "إنشاء حساب"
|
||||
},
|
||||
"turnstile": {
|
||||
"expired": "انتهت صلاحية التحقق. يرجى إكمال التحقق مرة أخرى.",
|
||||
"failed": "فشل التحقق. يرجى المحاولة مرة أخرى.",
|
||||
"submitBlockedHint": "يرجى إكمال التحقق أعلاه لتفعيل التسجيل."
|
||||
}
|
||||
},
|
||||
"batch": {
|
||||
@@ -346,6 +353,17 @@
|
||||
"x": "س",
|
||||
"y": "ص"
|
||||
},
|
||||
"boundingBoxes": {
|
||||
"clearAll": "مسح الكل",
|
||||
"clickRegionToEdit": "انقر على منطقة لتعديلها.",
|
||||
"colors": "لوحة الألوان",
|
||||
"descLabel": "الوصف",
|
||||
"descPlaceholder": "وصف هذه المنطقة",
|
||||
"textLabel": "نص",
|
||||
"textPlaceholder": "النص المراد عرضه (كما هو)",
|
||||
"typeObj": "كائن",
|
||||
"typeText": "نص"
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "التطبيق",
|
||||
"blueprint": "المخطط",
|
||||
@@ -755,6 +773,13 @@
|
||||
"creditsAvailable": "الرصيد المتاح",
|
||||
"details": "التفاصيل",
|
||||
"eventType": "نوع الحدث",
|
||||
"eventTypes": {
|
||||
"accountCreated": "تم إنشاء الحساب",
|
||||
"apiNodeUsage": "استخدام عقدة الشريك",
|
||||
"apiUsage": "استخدام API",
|
||||
"creditAdded": "تمت إضافة أرصدة",
|
||||
"gpuUsage": "استخدام GPU"
|
||||
},
|
||||
"faqs": "الأسئلة المتكررة",
|
||||
"invoiceHistory": "تاريخ الفواتير",
|
||||
"lastUpdated": "آخر تحديث",
|
||||
@@ -811,6 +836,7 @@
|
||||
},
|
||||
"dataTypes": {
|
||||
"*": "*",
|
||||
"ARRAY": "مصفوفة",
|
||||
"AUDIO": "صوت",
|
||||
"AUDIO_ENCODER": "مُشَفِّر الصوت",
|
||||
"AUDIO_ENCODER_OUTPUT": "مخرجات مُشَفِّر الصوت",
|
||||
@@ -818,11 +844,13 @@
|
||||
"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",
|
||||
@@ -832,6 +860,7 @@
|
||||
"CURVE": "منحنى",
|
||||
"DA3_GEOMETRY": "DA3_GEOMETRY",
|
||||
"DA3_MODEL": "DA3_MODEL",
|
||||
"DICT": "قاموس",
|
||||
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
|
||||
"FACE_DETECTION_MODEL": "نموذج كشف الوجه",
|
||||
"FACE_LANDMARKS": "معالم الوجه",
|
||||
@@ -1830,6 +1859,35 @@
|
||||
"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": "الإصدارات الأخيرة"
|
||||
@@ -2957,6 +3015,10 @@
|
||||
"uploadError": "فشل في رفع صورة الرسام: {status} - {statusText}",
|
||||
"width": "العرض"
|
||||
},
|
||||
"palette": {
|
||||
"addColor": "إضافة لون",
|
||||
"swatchTitle": "انقر للتعديل · اسحب لإعادة الترتيب · انقر بزر الفأرة الأيمن للإزالة"
|
||||
},
|
||||
"progressToast": {
|
||||
"allDownloadsCompleted": "اكتملت جميع التنزيلات",
|
||||
"downloadingModel": "جاري تنزيل النموذج...",
|
||||
@@ -3041,7 +3103,6 @@
|
||||
"color": "لون العقدة",
|
||||
"editSubgraph": "تعديل الرسم البياني الفرعي",
|
||||
"editTitle": "تعديل العنوان",
|
||||
"enterSubgraph": "دخول الرسم الفرعي",
|
||||
"errorHelp": "للمزيد من المساعدة، {github} أو {support}",
|
||||
"errorHelpGithub": "إرسال مشكلة على GitHub",
|
||||
"errorHelpSupport": "تواصل مع الدعم الفني",
|
||||
@@ -3661,6 +3722,10 @@
|
||||
"addApiCredits": "إضافة رصيد API",
|
||||
"addCredits": "إضافة رصيد",
|
||||
"addCreditsLabel": "أضف المزيد من الرصيد في أي وقت",
|
||||
"additionalCredits": "رصيد إضافي",
|
||||
"additionalCreditsInUse": "قيد الاستخدام",
|
||||
"additionalCreditsInfo": "حول الرصيد الإضافي",
|
||||
"additionalCreditsTooltip": "الرصيد الذي تضيفه فوق خطتك. يُستخدم بعد نفاد الرصيد الشهري. كل رصيد ينتهي بعد سنة من الشراء.",
|
||||
"benefits": {
|
||||
"benefit1": "رصيد شهري للعقد الشريكة - تجديد عند الحاجة",
|
||||
"benefit1FreeTier": "رصيد شهري أكثر، مع إمكانية الشحن في أي وقت",
|
||||
@@ -3682,27 +3747,55 @@
|
||||
"keepSubscription": "الاحتفاظ بالاشتراك",
|
||||
"title": "إلغاء الاشتراك"
|
||||
},
|
||||
"cancelSubscription": "إلغاء الاشتراك",
|
||||
"cancelPlan": "إلغاء الخطة",
|
||||
"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.",
|
||||
@@ -3730,7 +3823,7 @@
|
||||
"inviteUpTo": "ادعُ حتى",
|
||||
"invoiceHistory": "سجل الفواتير",
|
||||
"learnMore": "معرفة المزيد",
|
||||
"managePayment": "إدارة الدفع",
|
||||
"manageBilling": "إدارة الفواتير",
|
||||
"managePlan": "إدارة الخطة",
|
||||
"manageSubscription": "إدارة الاشتراك",
|
||||
"maxDuration": {
|
||||
@@ -3744,50 +3837,99 @@
|
||||
"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": "دورة الفوترة التالية",
|
||||
"nextMonthInvoice": "فاتورة الشهر القادم",
|
||||
"outOfCreditsDescription": "أضف المزيد من الرصيد للمتابعة في التوليد.",
|
||||
"outOfCreditsTitle": "نفد رصيدك. سيتم إعادة التعبئة {date}",
|
||||
"outOfCreditsTitleNoDate": "نفد رصيدك",
|
||||
"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": "الإجمالي المستحق اليوم"
|
||||
"totalDueToday": "الإجمالي المستحق اليوم",
|
||||
"yearlySubscription": "اشتراك سنوي",
|
||||
"youllBeCharged": "سيتم خصم"
|
||||
},
|
||||
"pricingBlurb": "*استنادًا إلى هذا القالب، {seeDetails}. تواصل معنا لـ {questions} أو {enterpriseDiscussions}. لمزيد من تفاصيل الأسعار، {clickHere}.",
|
||||
"pricingBlurbClickHere": "اضغط هنا",
|
||||
"pricingBlurbEnterprise": "مناقشات المؤسسات",
|
||||
"pricingBlurbQuestions": "الاستفسارات",
|
||||
"pricingBlurbSeeDetails": "اعرض التفاصيل",
|
||||
"reactivatePlan": "إعادة تفعيل الخطة",
|
||||
"refillsDate": "إعادة التعبئة {date}",
|
||||
"refillsNextCycle": "إعادة التعبئة في الدورة التالية",
|
||||
"refreshCredits": "تحديث الرصيد",
|
||||
"remaining": "متبقي",
|
||||
"renewsDate": "تجديد في {date}",
|
||||
"renewsOnDate": "يتجدد في {date}",
|
||||
"required": {
|
||||
"pollingFailed": "فشل تفعيل الاشتراك",
|
||||
"pollingSuccess": "تم تفعيل الاشتراك بنجاح!",
|
||||
@@ -3799,7 +3941,11 @@
|
||||
"resubscribe": "إعادة الاشتراك",
|
||||
"resubscribeSuccess": "تمت إعادة تفعيل الاشتراك بنجاح",
|
||||
"resubscribeTo": "إعادة الاشتراك في {plan}",
|
||||
"saveYearly": "وفّر 20%",
|
||||
"saveYearlyUpTo": "وفر حتى ٢٠٪",
|
||||
"soloUseOnly": "للاستخدام الفردي فقط",
|
||||
"subscribe": "اشترك",
|
||||
"subscribeFailed": "فشل الاشتراك",
|
||||
"subscribeForMore": "ترقية",
|
||||
"subscribeNow": "اشترك الآن",
|
||||
"subscribeTo": "اشترك في {plan}",
|
||||
@@ -3807,10 +3953,46 @@
|
||||
"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": {
|
||||
@@ -3820,9 +4002,12 @@
|
||||
"name": "مجاني"
|
||||
},
|
||||
"pro": {
|
||||
"feature1": "مدة تشغيل سير عمل أطول (حتى ساعة واحدة)",
|
||||
"name": "احترافي"
|
||||
},
|
||||
"standard": {
|
||||
"feature1": "حد أقصى لمدة تشغيل سير العمل ٣٠ دقيقة",
|
||||
"feature2": "إضافة المزيد من الرصيد في أي وقت",
|
||||
"name": "قياسي"
|
||||
}
|
||||
},
|
||||
@@ -3835,6 +4020,8 @@
|
||||
"upgradeToAddCredits": "قم بالترقية لإضافة أرصدة",
|
||||
"usdPerMonth": "دولار أمريكي / شهريًا",
|
||||
"usdPerMonthPerMember": "دولار أمريكي / شهر / عضو",
|
||||
"usedAfterMonthly": "يتم استخدامه بعد نفاد الرصيد الشهري",
|
||||
"videoEstimate": "ينتج تقريبًا ~{count} فيديوهات ٥ ثوانٍ*",
|
||||
"videoEstimateExplanation": "هذه التقديرات مبنية على قالب Wan 2.2 لتحويل الصورة إلى فيديو باستخدام الإعدادات الافتراضية (5 ثوانٍ، 640x640، 16 إطار/ثانية، 4 خطوات أخذ عينات).",
|
||||
"videoEstimateHelp": "مزيد من التفاصيل حول هذا القالب",
|
||||
"videoEstimateLabel": "العدد التقريبي لمقاطع الفيديو 5 ثوانٍ التي يتم إنشاؤها باستخدام قالب Wan 2.2 لتحويل الصورة إلى فيديو",
|
||||
@@ -3844,10 +4031,10 @@
|
||||
"viewMoreDetails": "عرض المزيد من التفاصيل",
|
||||
"viewMoreDetailsPlans": "عرض المزيد من التفاصيل حول الخطط والأسعار",
|
||||
"viewUsageHistory": "عرض سجل الاستخدام",
|
||||
"whatsIncluded": "ما يتضمنه:",
|
||||
"workspaceNotSubscribed": "هذه مساحة العمل ليست مشتركة",
|
||||
"yearly": "سنوي",
|
||||
"yearlyCreditsLabel": "إجمالي الرصيد السنوي",
|
||||
"yearlyDiscount": "خصم 20%",
|
||||
"yourPlanIncludes": "خطتك تشمل:"
|
||||
},
|
||||
"tabMenu": {
|
||||
@@ -4138,6 +4325,19 @@
|
||||
}
|
||||
},
|
||||
"workspacePanel": {
|
||||
"changeRoleDialog": {
|
||||
"demoteConfirm": "تخفيض إلى عضو",
|
||||
"demoteMessage": "سيفقد صلاحيات الإدارة.",
|
||||
"demoteTitle": "تخفيض {name} إلى عضو؟",
|
||||
"error": "فشل في تحديث الدور",
|
||||
"promoteConfirm": "جعل مالكًا",
|
||||
"promoteIntro": "سيكون بإمكانه:",
|
||||
"promotePermissionCredits": "إضافة أرصدة إضافية",
|
||||
"promotePermissionManage": "إدارة الأعضاء، طرق الدفع، وإعدادات مساحة العمل",
|
||||
"promotePermissionRoles": "ترقية أو تخفيض مالكين آخرين (باستثناء منشئ مساحة العمل).",
|
||||
"promoteTitle": "جعل {name} مالكًا؟",
|
||||
"success": "تم تحديث الدور"
|
||||
},
|
||||
"createWorkspaceDialog": {
|
||||
"create": "إنشاء",
|
||||
"message": "تتيح مساحات العمل للأعضاء مشاركة رصيد واحد. ستصبح المالك بعد الإنشاء.",
|
||||
@@ -4162,17 +4362,11 @@
|
||||
"inviteLimitReached": "لقد وصلت إلى الحد الأقصى وهو ٥٠ عضواً",
|
||||
"inviteMember": "دعوة عضو",
|
||||
"inviteMemberDialog": {
|
||||
"createLink": "إنشاء الرابط",
|
||||
"linkCopied": "تم النسخ",
|
||||
"linkCopyFailed": "فشل في نسخ الرابط",
|
||||
"linkStep": {
|
||||
"copyLink": "نسخ الرابط",
|
||||
"done": "تم",
|
||||
"message": "تأكد من أن حسابه يستخدم هذا البريد الإلكتروني.",
|
||||
"title": "أرسل هذا الرابط إلى الشخص"
|
||||
},
|
||||
"message": "أنشئ رابط دعوة قابل للمشاركة لإرساله إلى شخص ما",
|
||||
"failedCount": "تعذر إرسال {count} دعوة. حاول مرة أخرى. | تعذر إرسال {count} دعوات. حاول مرة أخرى.",
|
||||
"invalidEmailCount": "{count} عنوان بريد إلكتروني غير صالح | {count} عناوين بريد إلكتروني غير صالحة",
|
||||
"invitedMessage": "تم إرسال دعوة إلى {emails} | تم إرسال دعوات إلى {emails}",
|
||||
"placeholder": "أدخل بريد الشخص الإلكتروني",
|
||||
"seatLimitReached": "يمكنك دعوة حتى {count} زميل. | يمكنك دعوة حتى {count} زملاء.",
|
||||
"title": "دعوة شخص إلى هذه المساحة"
|
||||
},
|
||||
"inviteUpsellDialog": {
|
||||
@@ -4180,8 +4374,7 @@
|
||||
"messageSingleSeat": "خطة Standard تتضمن مقعدًا واحدًا فقط لمالك مساحة العمل. لدعوة أعضاء إضافيين، قم بالترقية إلى خطة Creator أو أعلى لتفعيل المقاعد المتعددة.",
|
||||
"titleNotSubscribed": "الاشتراك مطلوب لدعوة الأعضاء",
|
||||
"titleSingleSeat": "خطتك الحالية تدعم مقعدًا واحدًا فقط",
|
||||
"upgradeToCreator": "الترقية إلى Creator",
|
||||
"viewPlans": "عرض الخطط"
|
||||
"upgradeToTeam": "الترقية إلى فريق"
|
||||
},
|
||||
"leaveDialog": {
|
||||
"leave": "مغادرة",
|
||||
@@ -4190,30 +4383,35 @@
|
||||
},
|
||||
"members": {
|
||||
"actions": {
|
||||
"copyLink": "نسخ رابط الدعوة",
|
||||
"cancelInvite": "إلغاء الدعوة",
|
||||
"changeRole": "تغيير الدور",
|
||||
"removeMember": "إزالة العضو",
|
||||
"revokeInvite": "إلغاء الدعوة"
|
||||
"resendInvite": "إعادة إرسال الدعوة"
|
||||
},
|
||||
"columns": {
|
||||
"expiryDate": "تاريخ الانتهاء",
|
||||
"inviteDate": "تاريخ الدعوة",
|
||||
"joinDate": "تاريخ الانضمام"
|
||||
"role": "الدور"
|
||||
},
|
||||
"createNewWorkspace": "أنشئ واحدة جديدة.",
|
||||
"contactUs": "تواصل معنا",
|
||||
"header": "الأعضاء",
|
||||
"membersCount": "{count}/٥٠ عضواً",
|
||||
"needMoreMembers": "تحتاج إلى المزيد من الأعضاء؟",
|
||||
"noInvites": "لا توجد دعوات معلقة",
|
||||
"noMembers": "لا يوجد أعضاء",
|
||||
"pendingInvitesCount": "{count} دعوة معلقة | {count} دعوات معلقة",
|
||||
"personalWorkspaceMessage": "لا يمكنك دعوة أعضاء آخرين إلى مساحة العمل الشخصية حالياً. لإضافة أعضاء إلى مساحة عمل،",
|
||||
"reactivateTeam": "إعادة تفعيل الفريق",
|
||||
"searchPlaceholder": "بحث...",
|
||||
"tabs": {
|
||||
"active": "نشط",
|
||||
"pendingCount": "معلق ({count})"
|
||||
},
|
||||
"upsellBannerSubscribe": "اشترك في خطة Creator أو أعلى لدعوة أعضاء الفريق إلى مساحة العمل هذه.",
|
||||
"upsellBannerUpgrade": "قم بالترقية إلى خطة Creator أو أعلى لدعوة أعضاء فريق إضافيين.",
|
||||
"viewPlans": "عرض الخطط"
|
||||
"upgradeToTeam": "الترقية إلى فريق",
|
||||
"upsellBanner": "لإضافة زملاء، قم بترقية خطتك.",
|
||||
"upsellBannerReactivate": "لإضافة المزيد من الزملاء، فعّل خطتك من جديد."
|
||||
},
|
||||
"menu": {
|
||||
"creatorCannotLeave": "لا يمكن لمنشئ مساحة العمل مغادرة المساحة التي أنشأها",
|
||||
"deleteWorkspace": "حذف مساحة العمل",
|
||||
"deleteWorkspaceDisabledTooltip": "يرجى إلغاء الاشتراك النشط لمساحة العمل أولاً",
|
||||
"editWorkspace": "تعديل تفاصيل مساحة العمل",
|
||||
@@ -4242,6 +4440,8 @@
|
||||
"failedToFetchWorkspaces": "فشل في تحميل مساحات العمل",
|
||||
"failedToLeaveWorkspace": "فشل في مغادرة مساحة العمل",
|
||||
"failedToUpdateWorkspace": "فشل في تحديث مساحة العمل",
|
||||
"inviteResendFailed": "فشل في إعادة إرسال الدعوة",
|
||||
"inviteResent": "تمت إعادة إرسال الدعوة",
|
||||
"workspaceCreated": {
|
||||
"message": "اشترك في خطة، وادعُ زملاءك، وابدأ التعاون.",
|
||||
"subscribe": "اشترك",
|
||||
|
||||
@@ -730,6 +730,49 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BuildJsonPromptIdeogram": {
|
||||
"description": "إنشاء موجه JSON لنموذج Ideogram 4.",
|
||||
"display_name": "إنشاء موجه JSON (Ideogram)",
|
||||
"inputs": {
|
||||
"aesthetics": {
|
||||
"name": "الجماليات",
|
||||
"tooltip": "كلمات مفتاحية جمالية إلزامية (مثل: مزاجي، سينمائي، باهت الألوان)."
|
||||
},
|
||||
"background": {
|
||||
"name": "الخلفية",
|
||||
"tooltip": "وصف إلزامي لخلفية الصورة أو البيئة المحيطة."
|
||||
},
|
||||
"color_palette": {
|
||||
"name": "لوحة الألوان",
|
||||
"tooltip": "رموز ألوان Hex لتوجيه الألوان السائدة في الصورة. حتى ١٦ إدخالاً."
|
||||
},
|
||||
"element": {
|
||||
"name": "عنصر",
|
||||
"tooltip": "عناصر الموجه من عقدة إنشاء الصناديق المحيطة."
|
||||
},
|
||||
"high_level_description": {
|
||||
"name": "وصف عالي المستوى",
|
||||
"tooltip": "وصف اختياري للصورة في جملة أو جملتين. يُنصح به بشدة."
|
||||
},
|
||||
"lighting": {
|
||||
"name": "الإضاءة",
|
||||
"tooltip": "وصف إلزامي للإضاءة (مثل: ساعة ذهبية، إضاءة حواف، ظلال درامية)."
|
||||
},
|
||||
"medium": {
|
||||
"name": "الوسيط",
|
||||
"tooltip": "نوع الوسيط الإلزامي (مثل: صورة فوتوغرافية، رسم توضيحي، ثلاثي الأبعاد، لوحة، تصميم جرافيكي). عند اختيار النمط = صورة، اختر صورة فوتوغرافية."
|
||||
},
|
||||
"style": {
|
||||
"name": "النمط"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "موجه",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2FirstLastFrameNode": {
|
||||
"description": "إنشاء فيديو باستخدام Seedance 2.0 من صورة الإطار الأول وصورة الإطار الأخير (اختياري).",
|
||||
"display_name": "ByteDance Seedance 2.0 من الإطار الأول/الأخير إلى فيديو",
|
||||
@@ -2633,6 +2676,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConvertArrayToString": {
|
||||
"display_name": "تحويل مصفوفة إلى نص",
|
||||
"inputs": {
|
||||
"array": {
|
||||
"name": "مصفوفة"
|
||||
},
|
||||
"indent": {
|
||||
"name": "المسافة البادئة",
|
||||
"tooltip": "عدد المسافات لكل مستوى مسافة بادئة. ٠ ينتج نصاً مضغوطاً في سطر واحد."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConvertDictionaryToString": {
|
||||
"display_name": "تحويل قاموس إلى نص",
|
||||
"inputs": {
|
||||
"dictionary": {
|
||||
"name": "قاموس"
|
||||
},
|
||||
"indent": {
|
||||
"name": "المسافة البادئة",
|
||||
"tooltip": "عدد المسافات لكل مستوى مسافة بادئة. ٠ ينتج نصاً مضغوطاً في سطر واحد."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"CosmosImageToVideoLatent": {
|
||||
"display_name": "تحويل صورة كوزموس إلى فيديو كامِن",
|
||||
"inputs": {
|
||||
@@ -2695,6 +2772,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateBoundingBoxes": {
|
||||
"description": "رسم صناديق محيطة في لوحة الرسم. يخرج عناصر موجه Ideogram، وصناديق محيطة في مساحة البكسل، وصورة معاينة.",
|
||||
"display_name": "إنشاء صناديق محيطة",
|
||||
"inputs": {
|
||||
"background": {
|
||||
"name": "الخلفية",
|
||||
"tooltip": "صورة اختيارية تُستخدم كخلفية في لوحة الرسم والمعاينة."
|
||||
},
|
||||
"editor_state": {
|
||||
"name": "حالة المحرر",
|
||||
"tooltip": "ارسم الصناديق المحيطة وحدد نوع كل صندوق، النص، الوصف، لوحة الألوان. ابدأ بعنصر الخلفية أولاً والعناصر الأمامية أخيراً."
|
||||
},
|
||||
"height": {
|
||||
"name": "الارتفاع",
|
||||
"tooltip": "ارتفاع لوحة الرسم وشبكة البكسل للصناديق المحيطة."
|
||||
},
|
||||
"width": {
|
||||
"name": "العرض",
|
||||
"tooltip": "عرض لوحة الرسم وشبكة البكسل للصناديق المحيطة."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "معاينة",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "صناديق محيطة",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "عناصر",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateCameraInfo": {
|
||||
"description": "إنشاء camera_info. وضع 'orbit' يوجه الكاميرا حول الهدف باستخدام yaw/pitch/distance؛ وضع 'look_at' يضع الكاميرا في موقع محدد في العالم. الإحداثيات في فضاء العالم للمشاهد (نظام اليد اليمنى، المحور Y للأعلى).",
|
||||
"display_name": "إنشاء معلومات الكاميرا",
|
||||
@@ -12025,6 +12138,131 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ModelMergeKrea2": {
|
||||
"display_name": "ModelMergeKrea2",
|
||||
"inputs": {
|
||||
"blocks_0_": {
|
||||
"name": "الكتل.0."
|
||||
},
|
||||
"blocks_10_": {
|
||||
"name": "الكتل.10."
|
||||
},
|
||||
"blocks_11_": {
|
||||
"name": "الكتل.11."
|
||||
},
|
||||
"blocks_12_": {
|
||||
"name": "الكتل.12."
|
||||
},
|
||||
"blocks_13_": {
|
||||
"name": "الكتل.13."
|
||||
},
|
||||
"blocks_14_": {
|
||||
"name": "الكتل.14."
|
||||
},
|
||||
"blocks_15_": {
|
||||
"name": "الكتل.15."
|
||||
},
|
||||
"blocks_16_": {
|
||||
"name": "الكتل.16."
|
||||
},
|
||||
"blocks_17_": {
|
||||
"name": "الكتل.17."
|
||||
},
|
||||
"blocks_18_": {
|
||||
"name": "الكتل.18."
|
||||
},
|
||||
"blocks_19_": {
|
||||
"name": "الكتل.19."
|
||||
},
|
||||
"blocks_1_": {
|
||||
"name": "الكتل.1."
|
||||
},
|
||||
"blocks_20_": {
|
||||
"name": "الكتل.20."
|
||||
},
|
||||
"blocks_21_": {
|
||||
"name": "الكتل.21."
|
||||
},
|
||||
"blocks_22_": {
|
||||
"name": "الكتل.22."
|
||||
},
|
||||
"blocks_23_": {
|
||||
"name": "الكتل.23."
|
||||
},
|
||||
"blocks_24_": {
|
||||
"name": "الكتل.24."
|
||||
},
|
||||
"blocks_25_": {
|
||||
"name": "الكتل.25."
|
||||
},
|
||||
"blocks_26_": {
|
||||
"name": "الكتل.26."
|
||||
},
|
||||
"blocks_27_": {
|
||||
"name": "الكتل.27."
|
||||
},
|
||||
"blocks_2_": {
|
||||
"name": "الكتل.2."
|
||||
},
|
||||
"blocks_3_": {
|
||||
"name": "الكتل.3."
|
||||
},
|
||||
"blocks_4_": {
|
||||
"name": "الكتل.4."
|
||||
},
|
||||
"blocks_5_": {
|
||||
"name": "الكتل.5."
|
||||
},
|
||||
"blocks_6_": {
|
||||
"name": "الكتل.6."
|
||||
},
|
||||
"blocks_7_": {
|
||||
"name": "الكتل.7."
|
||||
},
|
||||
"blocks_8_": {
|
||||
"name": "الكتل.8."
|
||||
},
|
||||
"blocks_9_": {
|
||||
"name": "الكتل.9."
|
||||
},
|
||||
"first_": {
|
||||
"name": "الأول."
|
||||
},
|
||||
"last_": {
|
||||
"name": "الأخير."
|
||||
},
|
||||
"model1": {
|
||||
"name": "model1"
|
||||
},
|
||||
"model2": {
|
||||
"name": "model2"
|
||||
},
|
||||
"tmlp_": {
|
||||
"name": "tmlp."
|
||||
},
|
||||
"tproj_": {
|
||||
"name": "tproj."
|
||||
},
|
||||
"txtfusion_layerwise_blocks_0_": {
|
||||
"name": "txtfusion.layerwise_blocks.0."
|
||||
},
|
||||
"txtfusion_layerwise_blocks_1_": {
|
||||
"name": "txtfusion.layerwise_blocks.1."
|
||||
},
|
||||
"txtfusion_projector_": {
|
||||
"name": "txtfusion.projector."
|
||||
},
|
||||
"txtfusion_refiner_blocks_0_": {
|
||||
"name": "txtfusion.refiner_blocks.0."
|
||||
},
|
||||
"txtfusion_refiner_blocks_1_": {
|
||||
"name": "txtfusion.refiner_blocks.1."
|
||||
},
|
||||
"txtmlp_": {
|
||||
"name": "txtmlp."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ModelMergeLTXV": {
|
||||
"display_name": "ModelMergeLTXV",
|
||||
"inputs": {
|
||||
@@ -17698,6 +17936,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SeedNode": {
|
||||
"display_name": "البذرة",
|
||||
"inputs": {
|
||||
"fixed": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "البذرة",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SelectCLIPDevice": {
|
||||
"description": "ضع مشفر نص CLIP على جهاز محدد (افتراضي / cpu / gpu:N).\n\n- \"default\" يعيد الجهاز الذي تم تعيينه من قبل أداة التحميل.\n- \"cpu\" يثبت كل من جهاز التحميل والتفريغ على وحدة المعالجة المركزية.\n- \"gpu:N\" يثبت جهاز التحميل على وحدة معالجة الرسومات رقم N المتوفرة.\n\nعندما لا يكون الجهاز المحدد موجودًا على الجهاز الحالي\n(مثلاً سير عمل تم بناؤه على جهاز به وحدتي GPU وتم فتحه على جهاز به وحدة GPU واحدة)،\nتمرر العقدة CLIP كما هو دون تغيير وتقوم بتسجيل رسالة\nبدلاً من الفشل.",
|
||||
"display_name": "اختيار جهاز CLIP",
|
||||
|
||||
@@ -895,8 +895,8 @@
|
||||
"nodes": "Nodes",
|
||||
"models": "Models",
|
||||
"assets": "Assets",
|
||||
"workflows": "Work\u00adflows",
|
||||
"templates": "Tem\u00adplates",
|
||||
"workflows": "Workflows",
|
||||
"templates": "Templates",
|
||||
"console": "Console",
|
||||
"menu": "Menu",
|
||||
"imported": "Imported",
|
||||
@@ -1828,6 +1828,7 @@
|
||||
},
|
||||
"dataTypes": {
|
||||
"*": "*",
|
||||
"ARRAY": "ARRAY",
|
||||
"AUDIO": "AUDIO",
|
||||
"AUDIO_ENCODER": "AUDIO_ENCODER",
|
||||
"AUDIO_ENCODER_OUTPUT": "AUDIO_ENCODER_OUTPUT",
|
||||
@@ -1835,11 +1836,13 @@
|
||||
"BACKGROUND_REMOVAL": "BACKGROUND_REMOVAL",
|
||||
"BOOLEAN": "BOOLEAN",
|
||||
"BOUNDING_BOX": "BOUNDING_BOX",
|
||||
"BOUNDING_BOXES": "BOUNDING_BOXES",
|
||||
"CAMERA_CONTROL": "CAMERA_CONTROL",
|
||||
"CLIP": "CLIP",
|
||||
"CLIP_VISION": "CLIP_VISION",
|
||||
"CLIP_VISION_OUTPUT": "CLIP_VISION_OUTPUT",
|
||||
"COLOR": "COLOR",
|
||||
"COLORS": "COLORS",
|
||||
"COMBO": "COMBO",
|
||||
"COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3",
|
||||
"COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3",
|
||||
@@ -1849,6 +1852,7 @@
|
||||
"CURVE": "CURVE",
|
||||
"DA3_GEOMETRY": "DA3_GEOMETRY",
|
||||
"DA3_MODEL": "DA3_MODEL",
|
||||
"DICT": "DICT",
|
||||
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
|
||||
"FACE_DETECTION_MODEL": "FACE_DETECTION_MODEL",
|
||||
"FACE_LANDMARKS": "FACE_LANDMARKS",
|
||||
@@ -2233,6 +2237,11 @@
|
||||
"slots": "Node Slots Error",
|
||||
"widgets": "Node Widgets Error"
|
||||
},
|
||||
"dynamicGroup": {
|
||||
"addRow": "Add row",
|
||||
"removeRow": "Remove row",
|
||||
"row": "Row {index}"
|
||||
},
|
||||
"oauth": {
|
||||
"consent": {
|
||||
"allow": "Continue",
|
||||
@@ -2347,6 +2356,11 @@
|
||||
"personalDataConsentLabel": "I agree to the processing of my personal data.",
|
||||
"emailNotEligibleForFreeTier": "Email sign-up is not eligible for Free Tier."
|
||||
},
|
||||
"turnstile": {
|
||||
"failed": "Verification failed. Please try again.",
|
||||
"expired": "Verification expired. Please complete the challenge again.",
|
||||
"submitBlockedHint": "Complete the verification challenge above to enable sign up."
|
||||
},
|
||||
"signOut": {
|
||||
"signOut": "Log Out",
|
||||
"success": "Signed out successfully",
|
||||
@@ -2507,13 +2521,18 @@
|
||||
"creditSliderSave": "Save {percent}% ({amount})",
|
||||
"renewsDate": "Renews {date}",
|
||||
"expiresDate": "Expires {date}",
|
||||
"renewsOnDate": "Renews on {date}",
|
||||
"endsOnDate": "Ends on {date}",
|
||||
"manageSubscription": "Manage subscription",
|
||||
"managePayment": "Manage Payment",
|
||||
"cancelSubscription": "Cancel Subscription",
|
||||
"manageBilling": "Manage billing",
|
||||
"changePlan": "Change plan",
|
||||
"cancelPlan": "Cancel plan",
|
||||
"canceled": "Canceled",
|
||||
"resubscribe": "Resubscribe",
|
||||
"reactivatePlan": "Reactivate plan",
|
||||
"resubscribeTo": "Resubscribe to {plan}",
|
||||
"resubscribeSuccess": "Subscription reactivated successfully",
|
||||
"subscribeFailed": "Failed to subscribe",
|
||||
"canceledCard": {
|
||||
"title": "Your subscription has been canceled",
|
||||
"description": "You won't be charged again. Your features remain active until {date}."
|
||||
@@ -2553,16 +2572,48 @@
|
||||
"creditsRemainingThisMonth": "Included (Refills {date})",
|
||||
"creditsRemainingThisYear": "Included (Refills {date})",
|
||||
"creditsYouveAdded": "Additional",
|
||||
"remaining": "remaining",
|
||||
"refillsDate": "Refills {date}",
|
||||
"refillsNextCycle": "Refills next cycle",
|
||||
"creditsUsed": "{used} used",
|
||||
"creditsLeftOfTotal": "{remaining} left of {total}",
|
||||
"monthlyUsageProgress": "{used} of {total} monthly credits used",
|
||||
"additionalCreditsInfo": "About additional credits",
|
||||
"additionalCredits": "Additional credits",
|
||||
"additionalCreditsInUse": "In use",
|
||||
"usedAfterMonthly": "Used after monthly runs out",
|
||||
"monthlyCreditsUsedUpTitle": "Monthly credits are used up. Refills {date}",
|
||||
"monthlyCreditsUsedUpTitleNoDate": "Monthly credits are used up",
|
||||
"monthlyCreditsUsedUpDescription": "You're now spending additional credits.",
|
||||
"outOfCreditsTitle": "You're out of credits. Credits refill {date}",
|
||||
"outOfCreditsTitleNoDate": "You're out of credits",
|
||||
"outOfCreditsDescription": "Add more credits to continue generating.",
|
||||
"additionalCreditsTooltip": "Credits you add on top of your plan. Used after monthly credits run out. Each expires one year after purchase.",
|
||||
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
|
||||
"viewMoreDetailsPlans": "View more details about plans & pricing",
|
||||
"nextBillingCycle": "next billing cycle",
|
||||
"yourPlanIncludes": "Your plan includes:",
|
||||
"whatsIncluded": "What's included:",
|
||||
"planLoadError": "We couldn't load your plan details.",
|
||||
"planLoadErrorRetry": "Try again",
|
||||
"teamPlanName": "Team",
|
||||
"teamPlanIncludes": "Your plan includes everything in {plan}, plus:",
|
||||
"teamPerks": {
|
||||
"inviteMembers": "Invite members",
|
||||
"concurrentRuns": "Members can run workflows concurrently",
|
||||
"sharedCreditPool": "Shared credit pool for all members",
|
||||
"rolePermissions": "Role-based permissions"
|
||||
},
|
||||
"freePerks": {
|
||||
"maxRuntime": "{duration} max runtime"
|
||||
},
|
||||
"viewMoreDetails": "View more details",
|
||||
"learnMore": "Learn more",
|
||||
"billedMonthly": "Billed monthly",
|
||||
"billedYearly": "{total} Billed yearly",
|
||||
"monthly": "Monthly",
|
||||
"yearly": "Yearly",
|
||||
"saveYearly": "Save 20%",
|
||||
"tierNameYearly": "{name} Yearly",
|
||||
"messageSupport": "Message support",
|
||||
"invoiceHistory": "Invoice history",
|
||||
@@ -2573,7 +2624,6 @@
|
||||
"benefit2": "Up to 1 hour runtime per job on Pro",
|
||||
"benefit3": "Bring your own models (Creator & Pro)"
|
||||
},
|
||||
"yearlyDiscount": "20% DISCOUNT",
|
||||
"tiers": {
|
||||
"free": {
|
||||
"name": "Free"
|
||||
@@ -2614,6 +2664,7 @@
|
||||
"subscribeToRunFull": "Subscribe to Run",
|
||||
"subscribeForMore": "Upgrade",
|
||||
"upgradeToAddCredits": "Upgrade to add credits",
|
||||
"subscribe": "Subscribe",
|
||||
"subscribeNow": "Subscribe Now",
|
||||
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
|
||||
"workspaceNotSubscribed": "This workspace is not on a subscription",
|
||||
@@ -2632,11 +2683,9 @@
|
||||
"teamHeaderLearnMore": "Learn more",
|
||||
"personalHeader": "Personal plans are for individual use only. {action}",
|
||||
"personalHeaderAction": "To add teammates, subscribe to the team plan.",
|
||||
"whatsIncluded": "What's included:",
|
||||
"everythingInPlus": "Everything in {plan}, plus:",
|
||||
"monthlyCredits": "monthly credits",
|
||||
"videoEstimate": "Generates ~{count} 5s videos*",
|
||||
"saveYearly": "Save 20%",
|
||||
"saveYearlyUpTo": "Save up to 20%",
|
||||
"teamPlan": {
|
||||
"name": "Team Plan",
|
||||
@@ -2650,9 +2699,9 @@
|
||||
"perkProjectAssets": "Project & asset management",
|
||||
"cta": "Subscribe to Team Yearly",
|
||||
"ctaMonthly": "Subscribe to Team Monthly",
|
||||
"unavailable": "This team plan is not available right now.",
|
||||
"changePlan": "Change plan",
|
||||
"currentPlan": "Current plan",
|
||||
"checkoutComingSoon": "Team plan checkout is coming soon."
|
||||
"currentPlan": "Current plan"
|
||||
},
|
||||
"enterprise": {
|
||||
"name": "Enterprise",
|
||||
@@ -2682,6 +2731,7 @@
|
||||
"upgradeCta": "View plans"
|
||||
},
|
||||
"partnerNodesCredits": "Partner nodes pricing",
|
||||
"partnerNodesPricingTable": "Partner Nodes pricing table",
|
||||
"plansAndPricing": "Plans & pricing",
|
||||
"managePlan": "Manage plan",
|
||||
"upgrade": "UPGRADE",
|
||||
@@ -2692,8 +2742,6 @@
|
||||
"monthlyCreditsPerMemberLabel": "Monthly credits / member",
|
||||
"maxMembersLabel": "Max. members",
|
||||
"yearlyCreditsLabel": "Total yearly credits",
|
||||
"membersLabel": "Up to {count} members",
|
||||
"nextMonthInvoice": "Next month invoice",
|
||||
"memberCount": "{count} member | {count} members",
|
||||
"maxDurationLabel": "Max run duration",
|
||||
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
||||
@@ -2708,7 +2756,7 @@
|
||||
"upgradeTo": "Upgrade to {plan}",
|
||||
"changeTo": "Change to {plan}",
|
||||
"maxDuration": {
|
||||
"free": "30 min",
|
||||
"free": "10 min",
|
||||
"standard": "30 min",
|
||||
"creator": "30 min",
|
||||
"pro": "1 hr",
|
||||
@@ -2721,10 +2769,11 @@
|
||||
"preview": {
|
||||
"confirmPayment": "Confirm your payment",
|
||||
"confirmPlanChange": "Confirm your plan change",
|
||||
"startingToday": "Starting today",
|
||||
"startingToday": "Starts today",
|
||||
"starting": "Starting {date}",
|
||||
"ends": "Ends {date}",
|
||||
"eachMonthCreditsRefill": "Each month credits refill to",
|
||||
"eachYearCreditsRefill": "Each year credits refill to",
|
||||
"everyMonthStarting": "Every month starting {date}",
|
||||
"creditsRefillTo": "Credits refill to",
|
||||
"youllBeCharged": "You'll be charged",
|
||||
@@ -2735,6 +2784,24 @@
|
||||
"proratedCharge": "Prorated charge for {plan}",
|
||||
"totalDueToday": "Total due today",
|
||||
"nextPaymentDue": "Next payment due {date}. Cancel anytime.",
|
||||
"confirmUpgradeTitle": "Confirm your upgrade",
|
||||
"confirmUpgradeCta": "Confirm upgrade",
|
||||
"confirmChange": "Confirm change",
|
||||
"confirmChangeTitle": "Review your scheduled change",
|
||||
"paymentPopupBlocked": "Couldn't open the payment page — please allow popups and try again.",
|
||||
"switchesToday": "Switches today",
|
||||
"startsOn": "Starts {date}",
|
||||
"yearlySubscription": "Yearly subscription",
|
||||
"newMonthlySubscription": "New monthly subscription",
|
||||
"creditFromCurrent": "Credit from current {plan}",
|
||||
"currentMonthly": "monthly plan",
|
||||
"commitment": "commitment",
|
||||
"creditsYoullGetToday": "Credits you'll get today",
|
||||
"refillReplacesNote": "Replaces your monthly refill. Existing balance is kept.",
|
||||
"afterThat": "After that",
|
||||
"creditsRefillMonthlyTo": "Credits refill monthly to",
|
||||
"billedEachMonth": "{amount} billed each month. Cancel anytime.",
|
||||
"stayOnUntil": "You'll stay on {plan} until {date}.",
|
||||
"termsAgreement": "By continuing, you agree to Comfy Org's {terms} and {privacy}.",
|
||||
"terms": "Terms",
|
||||
"privacyPolicy": "Privacy Policy",
|
||||
@@ -2746,8 +2813,12 @@
|
||||
},
|
||||
"success": {
|
||||
"allSet": "You're all set",
|
||||
"inviteEmailsPlaceholder": "Enter emails separated by commas",
|
||||
"inviteSubtext": "You can also invite people later from Settings",
|
||||
"inviteTitle": "Invite your team",
|
||||
"planUpdated": "Your plan has been successfully updated.",
|
||||
"receiptEmailed": "A receipt has been emailed to you."
|
||||
"receiptEmailed": "A receipt has been emailed to you.",
|
||||
"sendInvites": "Send invites"
|
||||
}
|
||||
},
|
||||
"userSettings": {
|
||||
@@ -2763,7 +2834,7 @@
|
||||
"workspacePanel": {
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of 50 members",
|
||||
"inviteLimitReached": "You've reached the maximum of {count} members",
|
||||
"tabs": {
|
||||
"dashboard": "Dashboard",
|
||||
"planCredits": "Plan & Credits",
|
||||
@@ -2773,7 +2844,8 @@
|
||||
"placeholder": "Dashboard workspace settings"
|
||||
},
|
||||
"members": {
|
||||
"membersCount": "{count}/{maxSeats} Members",
|
||||
"header": "Members",
|
||||
"membersCount": "{count} of {maxSeats} members",
|
||||
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
|
||||
"tabs": {
|
||||
"active": "Active",
|
||||
@@ -2782,26 +2854,30 @@
|
||||
"columns": {
|
||||
"inviteDate": "Invite date",
|
||||
"expiryDate": "Expiry date",
|
||||
"joinDate": "Join date"
|
||||
"role": "Role"
|
||||
},
|
||||
"actions": {
|
||||
"copyLink": "Copy invite link",
|
||||
"revokeInvite": "Revoke invite",
|
||||
"resendInvite": "Resend invite",
|
||||
"cancelInvite": "Cancel invite",
|
||||
"changeRole": "Change role",
|
||||
"removeMember": "Remove member"
|
||||
},
|
||||
"upsellBannerSubscribe": "Subscribe to the Creator plan or above to invite team members to this workspace.",
|
||||
"upsellBannerUpgrade": "Upgrade to the Creator plan or above to invite additional team members.",
|
||||
"viewPlans": "View plans",
|
||||
"upsellBanner": "To add teammates, upgrade your plan.",
|
||||
"upsellBannerReactivate": "To add more teammates, reactivate your plan.",
|
||||
"upgradeToTeam": "Upgrade to Team",
|
||||
"reactivateTeam": "Reactivate Team",
|
||||
"needMoreMembers": "Need more members?",
|
||||
"contactUs": "Contact us",
|
||||
"noInvites": "No pending invites",
|
||||
"noMembers": "No members",
|
||||
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
|
||||
"createNewWorkspace": "create a new one."
|
||||
"searchPlaceholder": "Search..."
|
||||
},
|
||||
"menu": {
|
||||
"editWorkspace": "Edit workspace details",
|
||||
"leaveWorkspace": "Leave Workspace",
|
||||
"deleteWorkspace": "Delete Workspace",
|
||||
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first"
|
||||
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
|
||||
"creatorCannotLeave": "The workspace creator can't leave the workspace they created"
|
||||
},
|
||||
"editWorkspaceDialog": {
|
||||
"title": "Edit workspace details",
|
||||
@@ -2825,32 +2901,38 @@
|
||||
"success": "Member removed",
|
||||
"error": "Failed to remove member"
|
||||
},
|
||||
"changeRoleDialog": {
|
||||
"promoteTitle": "Make {name} an owner?",
|
||||
"promoteIntro": "They'll be able to:",
|
||||
"promotePermissionCredits": "Add additional credits",
|
||||
"promotePermissionManage": "Manage members, payment methods, and workspace settings",
|
||||
"promotePermissionRoles": "Promote and demote other owners (except the workspace creator).",
|
||||
"promoteConfirm": "Make owner",
|
||||
"demoteTitle": "Demote {name} to member?",
|
||||
"demoteMessage": "They'll lose admin access.",
|
||||
"demoteConfirm": "Demote to member",
|
||||
"success": "Role updated",
|
||||
"error": "Failed to update role"
|
||||
},
|
||||
"revokeInviteDialog": {
|
||||
"title": "Uninvite this person?",
|
||||
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
|
||||
"revoke": "Uninvite"
|
||||
},
|
||||
"inviteUpsellDialog": {
|
||||
"titleNotSubscribed": "A subscription is required to invite members",
|
||||
"titleNotSubscribed": "A Team plan is required to invite members",
|
||||
"titleSingleSeat": "Your current plan supports a single seat",
|
||||
"messageNotSubscribed": "To add team members to this workspace, you need a Creator plan or above. The Standard plan supports only a single seat (the owner).",
|
||||
"messageSingleSeat": "The Standard plan includes one seat for the workspace owner. To invite additional members, upgrade to the Creator plan or above to unlock multiple seats.",
|
||||
"viewPlans": "View Plans",
|
||||
"upgradeToCreator": "Upgrade to Creator"
|
||||
"messageNotSubscribed": "To add teammates to this workspace, upgrade to a Team plan.",
|
||||
"messageSingleSeat": "Your current plan includes one seat for the workspace owner. To add teammates, upgrade to a Team plan.",
|
||||
"upgradeToTeam": "Upgrade to Team"
|
||||
},
|
||||
"inviteMemberDialog": {
|
||||
"title": "Invite a person to this workspace",
|
||||
"message": "Create a shareable invite link to send to someone",
|
||||
"placeholder": "Enter the person's email",
|
||||
"createLink": "Create link",
|
||||
"linkStep": {
|
||||
"title": "Send this link to the person",
|
||||
"message": "Make sure their account uses this email.",
|
||||
"copyLink": "Copy Link",
|
||||
"done": "Done"
|
||||
},
|
||||
"linkCopied": "Copied",
|
||||
"linkCopyFailed": "Failed to copy link"
|
||||
"title": "Invite members to this workspace",
|
||||
"placeholder": "Enter emails separated by commas",
|
||||
"invalidEmailCount": "{count} invalid email address | {count} invalid email addresses",
|
||||
"failedCount": "Couldn't send {count} invite. Try again. | Couldn't send {count} invites. Try again.",
|
||||
"invitedMessage": "An invite was sent to {emails} | Invites were sent to {emails}",
|
||||
"seatLimitReached": "You can invite up to {count} teammate. | You can invite up to {count} teammates."
|
||||
},
|
||||
"createWorkspaceDialog": {
|
||||
"title": "Create a new workspace",
|
||||
@@ -2877,6 +2959,8 @@
|
||||
"title": "Left workspace",
|
||||
"message": "You have left the workspace."
|
||||
},
|
||||
"inviteResent": "Invite resent",
|
||||
"inviteResendFailed": "Failed to resend invite",
|
||||
"failedToUpdateWorkspace": "Failed to update workspace",
|
||||
"failedToCreateWorkspace": "Failed to create workspace",
|
||||
"failedToDeleteWorkspace": "Failed to delete workspace",
|
||||
@@ -2990,7 +3074,7 @@
|
||||
"share": "Share"
|
||||
},
|
||||
"shortcuts": {
|
||||
"shortcuts": "Short\u00adcuts",
|
||||
"shortcuts": "Shortcuts",
|
||||
"essentials": "Essential",
|
||||
"viewControls": "View Controls",
|
||||
"manageShortcuts": "Manage Shortcuts",
|
||||
@@ -3787,7 +3871,6 @@
|
||||
"errorLog": "Error log",
|
||||
"findOnGithubTooltip": "Search GitHub issues for related problems",
|
||||
"getHelpTooltip": "Report this error and we'll help you resolve it",
|
||||
"enterSubgraph": "Enter subgraph",
|
||||
"seeError": "See Error",
|
||||
"errorHelp": "For more help, {github} or {support}",
|
||||
"errorHelpGithub": "submit a GitHub issue",
|
||||
|
||||
@@ -730,13 +730,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BuildJsonPromptIdeogram": {
|
||||
"display_name": "Build JSON Prompt (Ideogram)",
|
||||
"description": "Build a JSON prompt for the Ideogram 4 model.",
|
||||
"inputs": {
|
||||
"element": {
|
||||
"name": "element",
|
||||
"tooltip": "Prompt elements from the node Create Bounding Boxes."
|
||||
},
|
||||
"high_level_description": {
|
||||
"name": "high_level_description",
|
||||
"tooltip": "Optional description of the image in one or two sentences. Strongly recommended."
|
||||
},
|
||||
"background": {
|
||||
"name": "background",
|
||||
"tooltip": "Mandatory description of the image background or environment."
|
||||
},
|
||||
"style": {
|
||||
"name": "style"
|
||||
},
|
||||
"aesthetics": {
|
||||
"name": "aesthetics",
|
||||
"tooltip": "Mandatory aesthetic keywords (e.g. moody, cinematic, desaturated)."
|
||||
},
|
||||
"lighting": {
|
||||
"name": "lighting",
|
||||
"tooltip": "Mandatory lighting description (e.g. golden hour, rim light, dramatic shadows)."
|
||||
},
|
||||
"medium": {
|
||||
"name": "medium",
|
||||
"tooltip": "Mandatory medium type (e.g. photograph, illustration, 3d_render, painting, graphic_design). When style = photo, set to photograph."
|
||||
},
|
||||
"color_palette": {
|
||||
"name": "color_palette",
|
||||
"tooltip": "Hex color codes that steer the image's dominant colors. Up to 16 entries."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "prompt",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2FirstLastFrameNode": {
|
||||
"display_name": "ByteDance Seedance 2.0 First-Last-Frame to Video",
|
||||
"description": "Generate video using Seedance 2.0 from a first frame image and optional last frame image.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization."
|
||||
"tooltip": "Seedance 2.0 for maximum quality; Fast for speed optimization; Mini for the fastest, lowest-cost generation."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
@@ -793,7 +836,7 @@
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization."
|
||||
"tooltip": "Seedance 2.0 for maximum quality; Fast for speed optimization; Mini for the fastest, lowest-cost generation."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
@@ -840,7 +883,7 @@
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization."
|
||||
"tooltip": "Seedance 2.0 for maximum quality; Fast for speed optimization; Mini for the fastest, lowest-cost generation."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
@@ -2633,6 +2676,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConvertArrayToString": {
|
||||
"display_name": "Convert Array to String",
|
||||
"inputs": {
|
||||
"array": {
|
||||
"name": "array"
|
||||
},
|
||||
"indent": {
|
||||
"name": "indent",
|
||||
"tooltip": "Spaces per indent level. 0 produces compact single-line string."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConvertDictionaryToString": {
|
||||
"display_name": "Convert Dictionary to String",
|
||||
"inputs": {
|
||||
"dictionary": {
|
||||
"name": "dictionary"
|
||||
},
|
||||
"indent": {
|
||||
"name": "indent",
|
||||
"tooltip": "Spaces per indent level. 0 produces compact single-line string."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"CosmosImageToVideoLatent": {
|
||||
"display_name": "CosmosImageToVideoLatent",
|
||||
"inputs": {
|
||||
@@ -2695,6 +2772,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateBoundingBoxes": {
|
||||
"display_name": "Create Bounding Boxes",
|
||||
"description": "Draw bounding boxes in a canvas. Outputs Ideogram prompt elements, pixel-space bounding boxes, and a preview image.",
|
||||
"inputs": {
|
||||
"width": {
|
||||
"name": "width",
|
||||
"tooltip": "Width of the canvas and the pixel grid for the bounding boxes."
|
||||
},
|
||||
"height": {
|
||||
"name": "height",
|
||||
"tooltip": "Height of the canvas and the pixel grid for the bounding boxes."
|
||||
},
|
||||
"editor_state": {
|
||||
"name": "editor_state",
|
||||
"tooltip": "Draw bounding boxes and set each box type, text, description, color palette. Start with background element first and foreground last."
|
||||
},
|
||||
"background": {
|
||||
"name": "background",
|
||||
"tooltip": "Optional image used as background in the canvas and preview."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "preview",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "bboxes",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "elements",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateCameraInfo": {
|
||||
"display_name": "Create Camera Info",
|
||||
"description": "Build a camera_infoMode 'orbit' aims with yaw/pitch/distance around the target; 'look_at' places the camera at world position. Coordinates are the viewer's world space (right-handed,Y-up).",
|
||||
@@ -5419,7 +5532,7 @@
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution",
|
||||
"tooltip": "The resolution of the output video."
|
||||
"tooltip": "The resolution of the output video. 1080p is only available for grok-imagine-video-1.5."
|
||||
},
|
||||
"aspect_ratio": {
|
||||
"name": "aspect_ratio",
|
||||
@@ -11905,6 +12018,131 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ModelMergeKrea2": {
|
||||
"display_name": "ModelMergeKrea2",
|
||||
"inputs": {
|
||||
"model1": {
|
||||
"name": "model1"
|
||||
},
|
||||
"model2": {
|
||||
"name": "model2"
|
||||
},
|
||||
"first_": {
|
||||
"name": "first."
|
||||
},
|
||||
"tmlp_": {
|
||||
"name": "tmlp."
|
||||
},
|
||||
"txtmlp_": {
|
||||
"name": "txtmlp."
|
||||
},
|
||||
"tproj_": {
|
||||
"name": "tproj."
|
||||
},
|
||||
"txtfusion_layerwise_blocks_0_": {
|
||||
"name": "txtfusion.layerwise_blocks.0."
|
||||
},
|
||||
"txtfusion_layerwise_blocks_1_": {
|
||||
"name": "txtfusion.layerwise_blocks.1."
|
||||
},
|
||||
"txtfusion_projector_": {
|
||||
"name": "txtfusion.projector."
|
||||
},
|
||||
"txtfusion_refiner_blocks_0_": {
|
||||
"name": "txtfusion.refiner_blocks.0."
|
||||
},
|
||||
"txtfusion_refiner_blocks_1_": {
|
||||
"name": "txtfusion.refiner_blocks.1."
|
||||
},
|
||||
"blocks_0_": {
|
||||
"name": "blocks.0."
|
||||
},
|
||||
"blocks_1_": {
|
||||
"name": "blocks.1."
|
||||
},
|
||||
"blocks_2_": {
|
||||
"name": "blocks.2."
|
||||
},
|
||||
"blocks_3_": {
|
||||
"name": "blocks.3."
|
||||
},
|
||||
"blocks_4_": {
|
||||
"name": "blocks.4."
|
||||
},
|
||||
"blocks_5_": {
|
||||
"name": "blocks.5."
|
||||
},
|
||||
"blocks_6_": {
|
||||
"name": "blocks.6."
|
||||
},
|
||||
"blocks_7_": {
|
||||
"name": "blocks.7."
|
||||
},
|
||||
"blocks_8_": {
|
||||
"name": "blocks.8."
|
||||
},
|
||||
"blocks_9_": {
|
||||
"name": "blocks.9."
|
||||
},
|
||||
"blocks_10_": {
|
||||
"name": "blocks.10."
|
||||
},
|
||||
"blocks_11_": {
|
||||
"name": "blocks.11."
|
||||
},
|
||||
"blocks_12_": {
|
||||
"name": "blocks.12."
|
||||
},
|
||||
"blocks_13_": {
|
||||
"name": "blocks.13."
|
||||
},
|
||||
"blocks_14_": {
|
||||
"name": "blocks.14."
|
||||
},
|
||||
"blocks_15_": {
|
||||
"name": "blocks.15."
|
||||
},
|
||||
"blocks_16_": {
|
||||
"name": "blocks.16."
|
||||
},
|
||||
"blocks_17_": {
|
||||
"name": "blocks.17."
|
||||
},
|
||||
"blocks_18_": {
|
||||
"name": "blocks.18."
|
||||
},
|
||||
"blocks_19_": {
|
||||
"name": "blocks.19."
|
||||
},
|
||||
"blocks_20_": {
|
||||
"name": "blocks.20."
|
||||
},
|
||||
"blocks_21_": {
|
||||
"name": "blocks.21."
|
||||
},
|
||||
"blocks_22_": {
|
||||
"name": "blocks.22."
|
||||
},
|
||||
"blocks_23_": {
|
||||
"name": "blocks.23."
|
||||
},
|
||||
"blocks_24_": {
|
||||
"name": "blocks.24."
|
||||
},
|
||||
"blocks_25_": {
|
||||
"name": "blocks.25."
|
||||
},
|
||||
"blocks_26_": {
|
||||
"name": "blocks.26."
|
||||
},
|
||||
"blocks_27_": {
|
||||
"name": "blocks.27."
|
||||
},
|
||||
"last_": {
|
||||
"name": "last."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ModelMergeLTXV": {
|
||||
"display_name": "ModelMergeLTXV",
|
||||
"inputs": {
|
||||
@@ -17577,6 +17815,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SeedNode": {
|
||||
"display_name": "Seed",
|
||||
"inputs": {
|
||||
"seed": {
|
||||
"name": "seed"
|
||||
},
|
||||
"fixed": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "seed",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SelectCLIPDevice": {
|
||||
"display_name": "Select CLIP Device",
|
||||
"description": "Place the CLIP text encoder on a specific device (default / cpu / gpu:N).\n\n- \"default\" restores the device assigned by the loader.\n- \"cpu\" pins both the load and offload device to CPU.\n- \"gpu:N\" pins the load device to the Nth available GPU.\n\nWhen the selected device does not exist on the current machine\n(e.g. a workflow built on a 2-GPU box opened on a 1-GPU box),\nthe node passes the CLIP through unchanged and logs a message\ninstead of failing.",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"errorUserTokenAccessDenied": "Tu token de API no tiene acceso a este recurso. Por favor, revisa los permisos de tu token.",
|
||||
"errorUserTokenInvalid": "Tu token de API almacenado no es válido o ha expirado. Por favor, actualiza tu token en la configuración.",
|
||||
"failedToCreateNode": "No se pudo crear el nodo. Inténtalo de nuevo o revisa la consola para más detalles.",
|
||||
"failedToSetModelValue": "Nodo añadido, pero su modelo no pudo establecerse automáticamente. Consulta la consola para más detalles.",
|
||||
"fileFormats": "Formatos de archivo",
|
||||
"fileName": "Nombre del archivo",
|
||||
"fileSize": "Tamaño del archivo",
|
||||
@@ -241,7 +242,8 @@
|
||||
"auth/user-not-found": "No se encontró ninguna cuenta con este correo electrónico. ¿Te gustaría crear una nueva cuenta?",
|
||||
"auth/weak-password": "La contraseña es demasiado débil. Por favor, usa una contraseña más segura con al menos 6 caracteres.",
|
||||
"auth/wrong-password": "La contraseña que ingresaste es incorrecta. Por favor, inténtalo de nuevo.",
|
||||
"generic": "Ocurrió un error al iniciar sesión. Por favor, inténtalo de nuevo."
|
||||
"generic": "Ocurrió un error al iniciar sesión. Por favor, inténtalo de nuevo.",
|
||||
"signupBlocked": "No pudimos crear tu cuenta en este momento. Por favor, inténtalo de nuevo más tarde. Si el problema persiste, escribe a support@comfy.org."
|
||||
},
|
||||
"login": {
|
||||
"andText": "y",
|
||||
@@ -323,6 +325,11 @@
|
||||
"signUpWithGithub": "Registrarse con Github",
|
||||
"signUpWithGoogle": "Registrarse con Google",
|
||||
"title": "Crea una cuenta"
|
||||
},
|
||||
"turnstile": {
|
||||
"expired": "La verificación ha expirado. Por favor, completa el desafío nuevamente.",
|
||||
"failed": "La verificación falló. Por favor, inténtalo de nuevo.",
|
||||
"submitBlockedHint": "Completa el desafío de verificación arriba para habilitar el registro."
|
||||
}
|
||||
},
|
||||
"batch": {
|
||||
@@ -346,6 +353,17 @@
|
||||
"x": "X",
|
||||
"y": "Y"
|
||||
},
|
||||
"boundingBoxes": {
|
||||
"clearAll": "Borrar todo",
|
||||
"clickRegionToEdit": "Haz clic en una región para editarla.",
|
||||
"colors": "paleta_de_colores",
|
||||
"descLabel": "descripción",
|
||||
"descPlaceholder": "descripción de esta región",
|
||||
"textLabel": "Texto",
|
||||
"textPlaceholder": "texto a renderizar (literal)",
|
||||
"typeObj": "obj",
|
||||
"typeText": "texto"
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "Aplicación",
|
||||
"blueprint": "Plano",
|
||||
@@ -755,6 +773,13 @@
|
||||
"creditsAvailable": "Créditos disponibles",
|
||||
"details": "Detalles",
|
||||
"eventType": "Tipo de evento",
|
||||
"eventTypes": {
|
||||
"accountCreated": "Cuenta creada",
|
||||
"apiNodeUsage": "Uso de nodo asociado",
|
||||
"apiUsage": "Uso de API",
|
||||
"creditAdded": "Créditos añadidos",
|
||||
"gpuUsage": "Uso de GPU"
|
||||
},
|
||||
"faqs": "Preguntas frecuentes",
|
||||
"invoiceHistory": "Historial de facturas",
|
||||
"lastUpdated": "Última actualización",
|
||||
@@ -811,6 +836,7 @@
|
||||
},
|
||||
"dataTypes": {
|
||||
"*": "*",
|
||||
"ARRAY": "ARRAY",
|
||||
"AUDIO": "AUDIO",
|
||||
"AUDIO_ENCODER": "CODIFICADOR_AUDIO",
|
||||
"AUDIO_ENCODER_OUTPUT": "SALIDA_CODIFICADOR_AUDIO",
|
||||
@@ -818,11 +844,13 @@
|
||||
"BACKGROUND_REMOVAL": "ELIMINACIÓN_DE_FONDO",
|
||||
"BOOLEAN": "BOOLEANO",
|
||||
"BOUNDING_BOX": "CUADRO DELIMITADOR",
|
||||
"BOUNDING_BOXES": "CUADROS DELIMITADORES",
|
||||
"CAMERA_CONTROL": "CONTROL DE CÁMARA",
|
||||
"CLIP": "CLIP",
|
||||
"CLIP_VISION": "CLIP_VISION",
|
||||
"CLIP_VISION_OUTPUT": "SALIDA_CLIP_VISION",
|
||||
"COLOR": "COLOR",
|
||||
"COLORS": "COLORES",
|
||||
"COMBO": "COMBO",
|
||||
"COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3",
|
||||
"COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3",
|
||||
@@ -832,6 +860,7 @@
|
||||
"CURVE": "CURVA",
|
||||
"DA3_GEOMETRY": "DA3_GEOMETRY",
|
||||
"DA3_MODEL": "DA3_MODEL",
|
||||
"DICT": "DICCIONARIO",
|
||||
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
|
||||
"FACE_DETECTION_MODEL": "MODELO_DE_DETECCIÓN_DE_CARAS",
|
||||
"FACE_LANDMARKS": "FACE_LANDMARKS",
|
||||
@@ -1830,6 +1859,35 @@
|
||||
"zoomOptions": "Opciones de Zoom",
|
||||
"zoomOut": "Alejar"
|
||||
},
|
||||
"hdrViewer": {
|
||||
"channel": "Canal",
|
||||
"channels": {
|
||||
"a": "Alfa",
|
||||
"b": "B",
|
||||
"g": "G",
|
||||
"luminance": "Luminancia",
|
||||
"r": "R",
|
||||
"rgb": "RGB"
|
||||
},
|
||||
"clipWarnings": "Avisos de recorte",
|
||||
"dither": "Difuminado",
|
||||
"exposure": "Exposición",
|
||||
"failedToLoad": "No se pudo cargar la imagen HDR",
|
||||
"fitView": "Ajustar",
|
||||
"hdrImage": "Imagen HDR",
|
||||
"histogram": "Histograma",
|
||||
"inf": "Inf",
|
||||
"max": "Máx",
|
||||
"mean": "Media",
|
||||
"min": "Mín",
|
||||
"nan": "NaN",
|
||||
"normalizeExposure": "Exposición automática",
|
||||
"openInHdrViewer": "Abrir en el visor HDR",
|
||||
"resolution": "Resolución",
|
||||
"sourceGamut": "Gama de origen",
|
||||
"stdDev": "Desv. estándar",
|
||||
"title": "Visor HDR"
|
||||
},
|
||||
"help": {
|
||||
"helpCenterMenu": "Menú del Centro de Ayuda",
|
||||
"recentReleases": "Lanzamientos recientes"
|
||||
@@ -2957,6 +3015,10 @@
|
||||
"uploadError": "Error al cargar la imagen del pintor: {status} - {statusText}",
|
||||
"width": "Ancho"
|
||||
},
|
||||
"palette": {
|
||||
"addColor": "Agregar un color",
|
||||
"swatchTitle": "Haz clic para editar · arrastra para reordenar · clic derecho para eliminar"
|
||||
},
|
||||
"progressToast": {
|
||||
"allDownloadsCompleted": "Todas las descargas completadas",
|
||||
"downloadingModel": "Descargando modelo...",
|
||||
@@ -3041,7 +3103,6 @@
|
||||
"color": "Color del nodo",
|
||||
"editSubgraph": "Editar subgrafo",
|
||||
"editTitle": "Editar título",
|
||||
"enterSubgraph": "Entrar en subgrafo",
|
||||
"errorHelp": "Para más ayuda, {github} o {support}",
|
||||
"errorHelpGithub": "envía un issue en GitHub",
|
||||
"errorHelpSupport": "contacta con nuestro soporte",
|
||||
@@ -3661,6 +3722,10 @@
|
||||
"addApiCredits": "Agregar créditos de API",
|
||||
"addCredits": "Agregar créditos",
|
||||
"addCreditsLabel": "Agrega más créditos cuando quieras",
|
||||
"additionalCredits": "Créditos adicionales",
|
||||
"additionalCreditsInUse": "En uso",
|
||||
"additionalCreditsInfo": "Acerca de los créditos adicionales",
|
||||
"additionalCreditsTooltip": "Créditos que agregas además de tu plan. Se usan después de agotar los créditos mensuales. Cada uno expira un año después de la compra.",
|
||||
"benefits": {
|
||||
"benefit1": "Créditos mensuales para Nodos de Socio — recarga cuando sea necesario",
|
||||
"benefit1FreeTier": "Más créditos mensuales, recarga en cualquier momento",
|
||||
@@ -3682,27 +3747,55 @@
|
||||
"keepSubscription": "Mantener suscripción",
|
||||
"title": "Cancelar suscripción"
|
||||
},
|
||||
"cancelSubscription": "Cancelar suscripción",
|
||||
"cancelPlan": "Cancelar plan",
|
||||
"cancelSuccess": "Suscripción cancelada correctamente",
|
||||
"canceled": "Cancelada",
|
||||
"canceledCard": {
|
||||
"description": "No se te cobrará de nuevo. Tus funciones seguirán activas hasta {date}.",
|
||||
"title": "Tu suscripción ha sido cancelada"
|
||||
},
|
||||
"changePlan": "Cambiar plan",
|
||||
"changeTo": "Cambiar a {plan}",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyCloudLogo": "Logo de Comfy Cloud",
|
||||
"contactOwnerToSubscribe": "Contacta al propietario del espacio de trabajo para suscribirte",
|
||||
"contactUs": "Contáctanos",
|
||||
"creditSliderSave": "Ahorra {percent}% ({amount})",
|
||||
"creditsLeftOfTotal": "{remaining} de {total} restantes",
|
||||
"creditsRemainingThisMonth": "Créditos restantes este mes",
|
||||
"creditsRemainingThisYear": "Créditos restantes este año",
|
||||
"creditsUsed": "{used} usados",
|
||||
"creditsYouveAdded": "Créditos que has agregado",
|
||||
"currentPlan": "Plan actual",
|
||||
"customLoRAsLabel": "Importa tus propios LoRAs",
|
||||
"description": "Elige el mejor plan para ti",
|
||||
"descriptionWorkspace": "Elige el mejor plan para tu espacio de trabajo",
|
||||
"downgrade": {
|
||||
"body": "Todos los demás miembros de este espacio de trabajo serán eliminados inmediatamente.",
|
||||
"confirm": "Cambiar plan",
|
||||
"confirmationPhrase": "Entiendo",
|
||||
"confirmationPrompt": "Escribe \"{phrase}\" para confirmar.",
|
||||
"failed": "No se pudo cambiar el plan",
|
||||
"failedAfterMemberRemoval": "Los miembros del equipo fueron eliminados, pero el cambio de plan no se completó — por favor, inténtalo de nuevo o contacta con soporte",
|
||||
"memberRemovalFailed": "No se pudo eliminar a {email} del equipo — algunos miembros pueden haber sido eliminados y tu plan no fue cambiado",
|
||||
"notAllowed": "Este cambio de plan no está disponible",
|
||||
"paymentMethodRequired": "Se requiere un método de pago para cambiar de plan",
|
||||
"paymentPageBlocked": "No se pudo abrir la página de pago — por favor, inténtalo de nuevo",
|
||||
"title": "¿Cambiar al plan {plan}?"
|
||||
},
|
||||
"endsOnDate": "Finaliza el {date}",
|
||||
"enterprise": {
|
||||
"cta": "Saber más",
|
||||
"flexibility": "¿Buscas más flexibilidad o funciones personalizadas?",
|
||||
"name": "Enterprise",
|
||||
"needMoreMembers": "¿Necesitas más miembros?",
|
||||
"reachOut": "Contáctanos y agendemos una charla."
|
||||
},
|
||||
"everythingInPlus": "Todo en {plan}, además:",
|
||||
"expiresDate": "Caduca el {date}",
|
||||
"freePerks": {
|
||||
"maxRuntime": "{duration} de tiempo máximo de ejecución"
|
||||
},
|
||||
"freeTier": {
|
||||
"description": "Tu plan gratuito incluye {credits} créditos cada mes para probar Comfy Cloud.",
|
||||
"descriptionGeneric": "Tu plan gratuito incluye una asignación mensual de créditos para probar Comfy Cloud.",
|
||||
@@ -3730,7 +3823,7 @@
|
||||
"inviteUpTo": "Invita hasta",
|
||||
"invoiceHistory": "Historial de facturas",
|
||||
"learnMore": "Más información",
|
||||
"managePayment": "Gestionar pago",
|
||||
"manageBilling": "Gestionar facturación",
|
||||
"managePlan": "Gestionar plan",
|
||||
"manageSubscription": "Gestionar suscripción",
|
||||
"maxDuration": {
|
||||
@@ -3744,50 +3837,99 @@
|
||||
"maxMembersLabel": "Máx. miembros",
|
||||
"member": "miembro",
|
||||
"memberCount": "{count} miembro | {count} miembros",
|
||||
"membersLabel": "Hasta {count} miembros",
|
||||
"messageSupport": "Contactar con soporte",
|
||||
"monthly": "Mensual",
|
||||
"monthlyBonusDescription": "Bono de créditos mensual",
|
||||
"monthlyCredits": "créditos mensuales",
|
||||
"monthlyCreditsInfo": "Estos créditos se renuevan mensualmente y no se acumulan",
|
||||
"monthlyCreditsLabel": "Créditos mensuales",
|
||||
"monthlyCreditsPerMemberLabel": "Créditos mensuales / miembro",
|
||||
"monthlyCreditsRollover": "Estos créditos se transferirán al próximo mes",
|
||||
"monthlyCreditsUsedUpDescription": "Ahora estás usando créditos adicionales.",
|
||||
"monthlyCreditsUsedUpTitle": "Los créditos mensuales se han agotado. Recargas {date}",
|
||||
"monthlyCreditsUsedUpTitleNoDate": "Los créditos mensuales se han agotado",
|
||||
"monthlyUsageProgress": "{used} de {total} créditos mensuales usados",
|
||||
"mostPopular": "Más popular",
|
||||
"needTeamWorkspace": "¿Necesitas un espacio de trabajo en equipo?",
|
||||
"nextBillingCycle": "próximo ciclo de facturación",
|
||||
"nextMonthInvoice": "Factura del próximo mes",
|
||||
"outOfCreditsDescription": "Agrega más créditos para continuar generando.",
|
||||
"outOfCreditsTitle": "Te has quedado sin créditos. Recarga de créditos {date}",
|
||||
"outOfCreditsTitleNoDate": "Te has quedado sin créditos",
|
||||
"partnerNodesBalance": "Saldo de créditos de \"Nodos de Partners\"",
|
||||
"partnerNodesCredits": "Créditos de Nodos de Socio",
|
||||
"partnerNodesDescription": "Para ejecutar modelos comerciales/propietarios",
|
||||
"partnerNodesPricingTable": "Tabla de precios de Partner Nodes",
|
||||
"perMonth": "USD / mes",
|
||||
"personalHeader": "Los planes personales son solo para uso individual. {action}",
|
||||
"personalHeaderAction": "Para agregar compañeros, suscríbete al plan de equipo.",
|
||||
"personalWorkspace": "Espacio de trabajo personal",
|
||||
"planLoadError": "No pudimos cargar los detalles de tu plan.",
|
||||
"planLoadErrorRetry": "Intentar de nuevo",
|
||||
"planScope": {
|
||||
"personal": "Para uso personal",
|
||||
"team": "Para equipos"
|
||||
},
|
||||
"plansAndPricing": "Planes y precios",
|
||||
"plansForWorkspace": "Planes para {workspace}",
|
||||
"prepaidCreditsInfo": "Créditos comprados por separado que no expiran",
|
||||
"prepaidDescription": "Créditos prepagados",
|
||||
"preview": {
|
||||
"addCreditCard": "Agregar tarjeta de crédito",
|
||||
"afterThat": "Después de eso",
|
||||
"backToAllPlans": "Volver a todos los planes",
|
||||
"billedEachMonth": "{amount} facturado cada mes. Cancela en cualquier momento.",
|
||||
"commitment": "compromiso",
|
||||
"confirm": "Confirmar",
|
||||
"confirmChange": "Confirmar cambio",
|
||||
"confirmChangeTitle": "Revisa tu cambio programado",
|
||||
"confirmPayment": "Confirma tu pago",
|
||||
"confirmPlanChange": "Confirma el cambio de plan",
|
||||
"confirmUpgradeCta": "Confirmar actualización",
|
||||
"confirmUpgradeTitle": "Confirma tu actualización",
|
||||
"creditFromCurrent": "Crédito del {plan} actual",
|
||||
"creditsRefillMonthlyTo": "Los créditos se recargan mensualmente a",
|
||||
"creditsRefillTo": "Los créditos se recargan hasta",
|
||||
"creditsYoullGetToday": "Créditos que recibirás hoy",
|
||||
"currentMonthly": "plan mensual",
|
||||
"eachMonthCreditsRefill": "Cada mes los créditos se recargan a",
|
||||
"eachYearCreditsRefill": "Cada año los créditos se recargan a",
|
||||
"ends": "Finaliza el {date}",
|
||||
"everyMonthStarting": "Cada mes a partir del {date}",
|
||||
"hideFeatures": "Ocultar funciones",
|
||||
"newMonthlySubscription": "Nueva suscripción mensual",
|
||||
"nextPaymentDue": "Próximo pago el {date}. Cancela en cualquier momento.",
|
||||
"paymentPopupBlocked": "No se pudo abrir la página de pago — permite las ventanas emergentes e inténtalo de nuevo.",
|
||||
"perMember": "/ miembro",
|
||||
"privacyPolicy": "Política de privacidad",
|
||||
"proratedCharge": "Cargo prorrateado por {plan}",
|
||||
"proratedRefund": "Reembolso prorrateado por {plan}",
|
||||
"refillReplacesNote": "Reemplaza tu recarga mensual. El saldo existente se mantiene.",
|
||||
"showMoreFeatures": "Mostrar más funciones",
|
||||
"starting": "Comienza el {date}",
|
||||
"startingToday": "Comienza hoy",
|
||||
"startsOn": "Comienza el {date}",
|
||||
"stayOnUntil": "Permanecerás en {plan} hasta el {date}.",
|
||||
"subscribeToPlan": "Suscribirse al plan {plan}",
|
||||
"switchToPlan": "Cambiar al plan {plan}",
|
||||
"switchesToday": "Cambios hoy",
|
||||
"terms": "Términos",
|
||||
"termsAgreement": "Al continuar, aceptas los {terms} y la {privacy} de Comfy Org.",
|
||||
"totalDueToday": "Total a pagar hoy"
|
||||
"totalDueToday": "Total a pagar hoy",
|
||||
"yearlySubscription": "Suscripción anual",
|
||||
"youllBeCharged": "Se te cobrará"
|
||||
},
|
||||
"pricingBlurb": "*Basado en esta plantilla, {seeDetails}. Contáctanos para {questions} o {enterpriseDiscussions}. Para más detalles sobre precios, {clickHere}.",
|
||||
"pricingBlurbClickHere": "haz clic aquí",
|
||||
"pricingBlurbEnterprise": "discusiones enterprise",
|
||||
"pricingBlurbQuestions": "preguntas",
|
||||
"pricingBlurbSeeDetails": "ver detalles",
|
||||
"reactivatePlan": "Reactivar plan",
|
||||
"refillsDate": "Recargas {date}",
|
||||
"refillsNextCycle": "Recargas en el próximo ciclo",
|
||||
"refreshCredits": "Actualizar créditos",
|
||||
"remaining": "restante",
|
||||
"renewsDate": "Se renueva el {date}",
|
||||
"renewsOnDate": "Renueva el {date}",
|
||||
"required": {
|
||||
"pollingFailed": "Error al activar la suscripción",
|
||||
"pollingSuccess": "¡Suscripción activada correctamente!",
|
||||
@@ -3799,7 +3941,11 @@
|
||||
"resubscribe": "Volver a suscribirse",
|
||||
"resubscribeSuccess": "¡Suscripción reactivada correctamente!",
|
||||
"resubscribeTo": "Volver a suscribirse a {plan}",
|
||||
"saveYearly": "Ahorra 20%",
|
||||
"saveYearlyUpTo": "Ahorra hasta un 20%",
|
||||
"soloUseOnly": "Solo para uso individual",
|
||||
"subscribe": "Suscribirse",
|
||||
"subscribeFailed": "No se pudo suscribir",
|
||||
"subscribeForMore": "Mejorar",
|
||||
"subscribeNow": "Suscribirse Ahora",
|
||||
"subscribeTo": "Suscribirse a {plan}",
|
||||
@@ -3807,10 +3953,46 @@
|
||||
"subscribeToRun": "Suscribirse",
|
||||
"subscribeToRunFull": "Suscribirse a Ejecutar",
|
||||
"subscriptionRequiredMessage": "Se requiere una suscripción para que los miembros ejecuten flujos de trabajo en la nube",
|
||||
"success": {
|
||||
"allSet": "Todo listo",
|
||||
"inviteEmailsPlaceholder": "Introduce correos electrónicos separados por comas",
|
||||
"inviteSubtext": "También puedes invitar personas más tarde desde Configuración",
|
||||
"inviteTitle": "Invita a tu equipo",
|
||||
"planUpdated": "Tu plan se ha actualizado correctamente.",
|
||||
"receiptEmailed": "Se ha enviado un recibo a tu correo electrónico.",
|
||||
"sendInvites": "Enviar invitaciones"
|
||||
},
|
||||
"teamHeader": "Para equipos que desean colaborar. ¿Necesitas más miembros? {learnMore} sobre enterprise.",
|
||||
"teamHeaderLearnMore": "Saber más",
|
||||
"teamPerks": {
|
||||
"concurrentRuns": "Los miembros pueden ejecutar flujos de trabajo simultáneamente",
|
||||
"inviteMembers": "Invitar miembros",
|
||||
"rolePermissions": "Permisos basados en roles",
|
||||
"sharedCreditPool": "Bolsa de créditos compartida para todos los miembros"
|
||||
},
|
||||
"teamPlan": {
|
||||
"changePlan": "Cambiar plan",
|
||||
"comingSoonLabel": "Próximamente:",
|
||||
"cta": "Suscribirse al plan anual de equipo",
|
||||
"ctaMonthly": "Suscribirse al plan mensual de equipo",
|
||||
"currentPlan": "Plan actual",
|
||||
"detailsTitle": "Detalles",
|
||||
"name": "Plan de equipo",
|
||||
"perkConcurrentRuns": "Los miembros pueden ejecutar flujos de trabajo simultáneamente",
|
||||
"perkInviteMembers": "Invita a miembros del equipo",
|
||||
"perkProjectAssets": "Gestión de proyectos y recursos",
|
||||
"perkRolePermissions": "Permisos basados en roles",
|
||||
"perkSharedPool": "Bolsa de créditos compartida para todos los miembros",
|
||||
"tagline": "Elige tu propia suscripción mensual de créditos. Obtén un mayor descuento con una suscripción de más créditos.",
|
||||
"unavailable": "Este plan de equipo no está disponible en este momento."
|
||||
},
|
||||
"teamPlanIncludes": "Tu plan incluye todo en {plan}, además de:",
|
||||
"teamPlanName": "Equipo",
|
||||
"teamWorkspace": "Espacio de trabajo en equipo",
|
||||
"tierNameYearly": "{name} Anual",
|
||||
"tiers": {
|
||||
"creator": {
|
||||
"feature1": "Importa tus propios modelos",
|
||||
"name": "Creador"
|
||||
},
|
||||
"founder": {
|
||||
@@ -3820,9 +4002,12 @@
|
||||
"name": "Gratis"
|
||||
},
|
||||
"pro": {
|
||||
"feature1": "Mayor tiempo de ejecución del flujo de trabajo (hasta 1 hora)",
|
||||
"name": "Pro"
|
||||
},
|
||||
"standard": {
|
||||
"feature1": "Tiempo máximo de ejecución del flujo de trabajo de 30 minutos",
|
||||
"feature2": "Agrega más créditos en cualquier momento",
|
||||
"name": "Estándar"
|
||||
}
|
||||
},
|
||||
@@ -3835,6 +4020,8 @@
|
||||
"upgradeToAddCredits": "Mejorar para añadir créditos",
|
||||
"usdPerMonth": "USD / mes",
|
||||
"usdPerMonthPerMember": "USD / mes / miembro",
|
||||
"usedAfterMonthly": "Usados después de agotar los mensuales",
|
||||
"videoEstimate": "Genera ~{count} videos de 5s*",
|
||||
"videoEstimateExplanation": "Estas estimaciones se basan en la plantilla Wan 2.2 Imagen a Video usando la configuración predeterminada (5 segundos, 640x640, 16fps, muestreo de 4 pasos).",
|
||||
"videoEstimateHelp": "Más detalles sobre esta plantilla",
|
||||
"videoEstimateLabel": "Cantidad aprox. de videos de 5s generados con la plantilla Wan 2.2 Imagen a Video",
|
||||
@@ -3844,10 +4031,10 @@
|
||||
"viewMoreDetails": "Ver más detalles",
|
||||
"viewMoreDetailsPlans": "Ver más detalles sobre planes y precios",
|
||||
"viewUsageHistory": "Ver historial de uso",
|
||||
"whatsIncluded": "Qué está incluido:",
|
||||
"workspaceNotSubscribed": "Este espacio de trabajo no tiene una suscripción",
|
||||
"yearly": "Anual",
|
||||
"yearlyCreditsLabel": "Total de créditos anuales",
|
||||
"yearlyDiscount": "20% DESCUENTO",
|
||||
"yourPlanIncludes": "Tu plan incluye:"
|
||||
},
|
||||
"tabMenu": {
|
||||
@@ -4138,6 +4325,19 @@
|
||||
}
|
||||
},
|
||||
"workspacePanel": {
|
||||
"changeRoleDialog": {
|
||||
"demoteConfirm": "Degradar a miembro",
|
||||
"demoteMessage": "Perderá acceso de administrador.",
|
||||
"demoteTitle": "¿Degradar a {name} a miembro?",
|
||||
"error": "No se pudo actualizar el rol",
|
||||
"promoteConfirm": "Hacer propietario",
|
||||
"promoteIntro": "Podrá:",
|
||||
"promotePermissionCredits": "Agregar créditos adicionales",
|
||||
"promotePermissionManage": "Gestionar miembros, métodos de pago y la configuración del espacio de trabajo",
|
||||
"promotePermissionRoles": "Promocionar y degradar a otros propietarios (excepto al creador del espacio de trabajo).",
|
||||
"promoteTitle": "¿Hacer que {name} sea propietario?",
|
||||
"success": "Rol actualizado"
|
||||
},
|
||||
"createWorkspaceDialog": {
|
||||
"create": "Crear",
|
||||
"message": "Los espacios de trabajo permiten a los miembros compartir un único fondo de créditos. Te convertirás en el propietario después de crearlo.",
|
||||
@@ -4162,17 +4362,11 @@
|
||||
"inviteLimitReached": "Has alcanzado el máximo de 50 miembros",
|
||||
"inviteMember": "Invitar miembro",
|
||||
"inviteMemberDialog": {
|
||||
"createLink": "Crear enlace",
|
||||
"linkCopied": "Copiado",
|
||||
"linkCopyFailed": "No se pudo copiar el enlace",
|
||||
"linkStep": {
|
||||
"copyLink": "Copiar enlace",
|
||||
"done": "Listo",
|
||||
"message": "Asegúrate de que su cuenta use este correo electrónico.",
|
||||
"title": "Envía este enlace a la persona"
|
||||
},
|
||||
"message": "Crea un enlace de invitación para compartir y envíalo a alguien",
|
||||
"failedCount": "No se pudo enviar {count} invitación. Intenta de nuevo. | No se pudieron enviar {count} invitaciones. Intenta de nuevo.",
|
||||
"invalidEmailCount": "{count} dirección de correo electrónico no válida | {count} direcciones de correo electrónico no válidas",
|
||||
"invitedMessage": "Se envió una invitación a {emails} | Se enviaron invitaciones a {emails}",
|
||||
"placeholder": "Introduce el correo electrónico de la persona",
|
||||
"seatLimitReached": "Puedes invitar hasta {count} compañero de equipo. | Puedes invitar hasta {count} compañeros de equipo.",
|
||||
"title": "Invitar a una persona a este espacio de trabajo"
|
||||
},
|
||||
"inviteUpsellDialog": {
|
||||
@@ -4180,8 +4374,7 @@
|
||||
"messageSingleSeat": "El plan Standard incluye un asiento para el propietario del espacio de trabajo. Para invitar a miembros adicionales, actualiza al plan Creator o superior para desbloquear múltiples asientos.",
|
||||
"titleNotSubscribed": "Se requiere una suscripción para invitar miembros",
|
||||
"titleSingleSeat": "Tu plan actual admite un solo usuario",
|
||||
"upgradeToCreator": "Actualizar a Creator",
|
||||
"viewPlans": "Ver planes"
|
||||
"upgradeToTeam": "Actualizar a Team"
|
||||
},
|
||||
"leaveDialog": {
|
||||
"leave": "Abandonar",
|
||||
@@ -4190,30 +4383,35 @@
|
||||
},
|
||||
"members": {
|
||||
"actions": {
|
||||
"copyLink": "Copiar enlace de invitación",
|
||||
"cancelInvite": "Cancelar invitación",
|
||||
"changeRole": "Cambiar rol",
|
||||
"removeMember": "Eliminar miembro",
|
||||
"revokeInvite": "Revocar invitación"
|
||||
"resendInvite": "Reenviar invitación"
|
||||
},
|
||||
"columns": {
|
||||
"expiryDate": "Fecha de vencimiento",
|
||||
"inviteDate": "Fecha de invitación",
|
||||
"joinDate": "Fecha de ingreso"
|
||||
"role": "Rol"
|
||||
},
|
||||
"createNewWorkspace": "crea uno nuevo.",
|
||||
"contactUs": "Contáctanos",
|
||||
"header": "Miembros",
|
||||
"membersCount": "{count}/50 Miembros",
|
||||
"needMoreMembers": "¿Necesitas más miembros?",
|
||||
"noInvites": "No hay invitaciones pendientes",
|
||||
"noMembers": "No hay miembros",
|
||||
"pendingInvitesCount": "{count} invitación pendiente | {count} invitaciones pendientes",
|
||||
"personalWorkspaceMessage": "No puedes invitar a otros miembros a tu espacio de trabajo personal en este momento. Para agregar miembros a un espacio de trabajo,",
|
||||
"reactivateTeam": "Reactivar Team",
|
||||
"searchPlaceholder": "Buscar...",
|
||||
"tabs": {
|
||||
"active": "Activo",
|
||||
"pendingCount": "Pendientes ({count})"
|
||||
},
|
||||
"upsellBannerSubscribe": "Suscríbete al plan Creator o superior para invitar a miembros del equipo a este espacio de trabajo.",
|
||||
"upsellBannerUpgrade": "Actualiza al plan Creator o superior para invitar a miembros adicionales al equipo.",
|
||||
"viewPlans": "Ver planes"
|
||||
"upgradeToTeam": "Actualizar a Team",
|
||||
"upsellBanner": "Para agregar compañeros de equipo, actualiza tu plan.",
|
||||
"upsellBannerReactivate": "Para agregar más compañeros de equipo, reactiva tu plan."
|
||||
},
|
||||
"menu": {
|
||||
"creatorCannotLeave": "El creador del espacio de trabajo no puede abandonar el espacio que creó",
|
||||
"deleteWorkspace": "Eliminar espacio de trabajo",
|
||||
"deleteWorkspaceDisabledTooltip": "Primero cancela la suscripción activa de tu espacio de trabajo",
|
||||
"editWorkspace": "Editar detalles del espacio de trabajo",
|
||||
@@ -4242,6 +4440,8 @@
|
||||
"failedToFetchWorkspaces": "No se pudieron cargar los espacios de trabajo",
|
||||
"failedToLeaveWorkspace": "No se pudo abandonar el espacio de trabajo",
|
||||
"failedToUpdateWorkspace": "No se pudo actualizar el espacio de trabajo",
|
||||
"inviteResendFailed": "No se pudo reenviar la invitación",
|
||||
"inviteResent": "Invitación reenviada",
|
||||
"workspaceCreated": {
|
||||
"message": "Suscríbete a un plan, invita a compañeros y comienza a colaborar.",
|
||||
"subscribe": "Suscribirse",
|
||||
|
||||
@@ -730,6 +730,49 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BuildJsonPromptIdeogram": {
|
||||
"description": "Construye un prompt JSON para el modelo Ideogram 4.",
|
||||
"display_name": "Construir prompt JSON (Ideogram)",
|
||||
"inputs": {
|
||||
"aesthetics": {
|
||||
"name": "estética",
|
||||
"tooltip": "Palabras clave estéticas obligatorias (ej. melancólico, cinematográfico, desaturado)."
|
||||
},
|
||||
"background": {
|
||||
"name": "fondo",
|
||||
"tooltip": "Descripción obligatoria del fondo o entorno de la imagen."
|
||||
},
|
||||
"color_palette": {
|
||||
"name": "paleta_de_colores",
|
||||
"tooltip": "Códigos de color hexadecimales que guían los colores dominantes de la imagen. Hasta 16 entradas."
|
||||
},
|
||||
"element": {
|
||||
"name": "elemento",
|
||||
"tooltip": "Elementos del prompt desde el nodo Crear Cajas Delimitadoras."
|
||||
},
|
||||
"high_level_description": {
|
||||
"name": "descripción_general",
|
||||
"tooltip": "Descripción opcional de la imagen en una o dos frases. Altamente recomendado."
|
||||
},
|
||||
"lighting": {
|
||||
"name": "iluminación",
|
||||
"tooltip": "Descripción obligatoria de la iluminación (ej. hora dorada, luz de contorno, sombras dramáticas)."
|
||||
},
|
||||
"medium": {
|
||||
"name": "medio",
|
||||
"tooltip": "Tipo de medio obligatorio (ej. fotografía, ilustración, 3d_render, pintura, diseño_gráfico). Cuando estilo = foto, establecer en fotografía."
|
||||
},
|
||||
"style": {
|
||||
"name": "estilo"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "prompt",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2FirstLastFrameNode": {
|
||||
"description": "Genera un video usando Seedance 2.0 a partir de una imagen del primer fotograma y, opcionalmente, una imagen del último fotograma.",
|
||||
"display_name": "ByteDance Seedance 2.0 Primer-Último Fotograma a Video",
|
||||
@@ -2633,6 +2676,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConvertArrayToString": {
|
||||
"display_name": "Convertir Array a Cadena",
|
||||
"inputs": {
|
||||
"array": {
|
||||
"name": "array"
|
||||
},
|
||||
"indent": {
|
||||
"name": "sangría",
|
||||
"tooltip": "Espacios por nivel de sangría. 0 produce una cadena compacta en una sola línea."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConvertDictionaryToString": {
|
||||
"display_name": "Convertir Diccionario a Cadena",
|
||||
"inputs": {
|
||||
"dictionary": {
|
||||
"name": "diccionario"
|
||||
},
|
||||
"indent": {
|
||||
"name": "sangría",
|
||||
"tooltip": "Espacios por nivel de sangría. 0 produce una cadena compacta en una sola línea."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"CosmosImageToVideoLatent": {
|
||||
"display_name": "CosmosImageToVideoLatent",
|
||||
"inputs": {
|
||||
@@ -2695,6 +2772,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateBoundingBoxes": {
|
||||
"description": "Dibuja cajas delimitadoras en un lienzo. Genera elementos de prompt Ideogram, cajas delimitadoras en espacio de píxeles y una imagen de vista previa.",
|
||||
"display_name": "Crear Cajas Delimitadoras",
|
||||
"inputs": {
|
||||
"background": {
|
||||
"name": "fondo",
|
||||
"tooltip": "Imagen opcional utilizada como fondo en el lienzo y la vista previa."
|
||||
},
|
||||
"editor_state": {
|
||||
"name": "estado_del_editor",
|
||||
"tooltip": "Dibuja cajas delimitadoras y asigna a cada caja su tipo, texto, descripción y paleta de colores. Comienza con el elemento de fondo y termina con el primer plano."
|
||||
},
|
||||
"height": {
|
||||
"name": "alto",
|
||||
"tooltip": "Alto del lienzo y de la cuadrícula de píxeles para las cajas delimitadoras."
|
||||
},
|
||||
"width": {
|
||||
"name": "ancho",
|
||||
"tooltip": "Ancho del lienzo y de la cuadrícula de píxeles para las cajas delimitadoras."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "vista_previa",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "cajas_delimitadoras",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "elementos",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateCameraInfo": {
|
||||
"description": "Construye una camera_info. El modo 'orbit' apunta con yaw/pitch/distancia alrededor del objetivo; 'look_at' coloca la cámara en una posición del mundo. Las coordenadas están en el espacio mundial del espectador (mano derecha, Y-arriba).",
|
||||
"display_name": "Crear información de cámara",
|
||||
@@ -12025,6 +12138,131 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ModelMergeKrea2": {
|
||||
"display_name": "ModelMergeKrea2",
|
||||
"inputs": {
|
||||
"blocks_0_": {
|
||||
"name": "bloques.0."
|
||||
},
|
||||
"blocks_10_": {
|
||||
"name": "bloques.10."
|
||||
},
|
||||
"blocks_11_": {
|
||||
"name": "bloques.11."
|
||||
},
|
||||
"blocks_12_": {
|
||||
"name": "bloques.12."
|
||||
},
|
||||
"blocks_13_": {
|
||||
"name": "bloques.13."
|
||||
},
|
||||
"blocks_14_": {
|
||||
"name": "bloques.14."
|
||||
},
|
||||
"blocks_15_": {
|
||||
"name": "bloques.15."
|
||||
},
|
||||
"blocks_16_": {
|
||||
"name": "bloques.16."
|
||||
},
|
||||
"blocks_17_": {
|
||||
"name": "bloques.17."
|
||||
},
|
||||
"blocks_18_": {
|
||||
"name": "bloques.18."
|
||||
},
|
||||
"blocks_19_": {
|
||||
"name": "bloques.19."
|
||||
},
|
||||
"blocks_1_": {
|
||||
"name": "bloques.1."
|
||||
},
|
||||
"blocks_20_": {
|
||||
"name": "bloques.20."
|
||||
},
|
||||
"blocks_21_": {
|
||||
"name": "bloques.21."
|
||||
},
|
||||
"blocks_22_": {
|
||||
"name": "bloques.22."
|
||||
},
|
||||
"blocks_23_": {
|
||||
"name": "bloques.23."
|
||||
},
|
||||
"blocks_24_": {
|
||||
"name": "bloques.24."
|
||||
},
|
||||
"blocks_25_": {
|
||||
"name": "bloques.25."
|
||||
},
|
||||
"blocks_26_": {
|
||||
"name": "bloques.26."
|
||||
},
|
||||
"blocks_27_": {
|
||||
"name": "bloques.27."
|
||||
},
|
||||
"blocks_2_": {
|
||||
"name": "bloques.2."
|
||||
},
|
||||
"blocks_3_": {
|
||||
"name": "bloques.3."
|
||||
},
|
||||
"blocks_4_": {
|
||||
"name": "bloques.4."
|
||||
},
|
||||
"blocks_5_": {
|
||||
"name": "bloques.5."
|
||||
},
|
||||
"blocks_6_": {
|
||||
"name": "bloques.6."
|
||||
},
|
||||
"blocks_7_": {
|
||||
"name": "bloques.7."
|
||||
},
|
||||
"blocks_8_": {
|
||||
"name": "bloques.8."
|
||||
},
|
||||
"blocks_9_": {
|
||||
"name": "bloques.9."
|
||||
},
|
||||
"first_": {
|
||||
"name": "primero."
|
||||
},
|
||||
"last_": {
|
||||
"name": "último."
|
||||
},
|
||||
"model1": {
|
||||
"name": "model1"
|
||||
},
|
||||
"model2": {
|
||||
"name": "model2"
|
||||
},
|
||||
"tmlp_": {
|
||||
"name": "tmlp."
|
||||
},
|
||||
"tproj_": {
|
||||
"name": "tproj."
|
||||
},
|
||||
"txtfusion_layerwise_blocks_0_": {
|
||||
"name": "txtfusion.layerwise_blocks.0."
|
||||
},
|
||||
"txtfusion_layerwise_blocks_1_": {
|
||||
"name": "txtfusion.layerwise_blocks.1."
|
||||
},
|
||||
"txtfusion_projector_": {
|
||||
"name": "txtfusion.projector."
|
||||
},
|
||||
"txtfusion_refiner_blocks_0_": {
|
||||
"name": "txtfusion.refiner_blocks.0."
|
||||
},
|
||||
"txtfusion_refiner_blocks_1_": {
|
||||
"name": "txtfusion.refiner_blocks.1."
|
||||
},
|
||||
"txtmlp_": {
|
||||
"name": "txtmlp."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ModelMergeLTXV": {
|
||||
"display_name": "ModelMergeLTXV",
|
||||
"inputs": {
|
||||
@@ -17698,6 +17936,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SeedNode": {
|
||||
"display_name": "Semilla",
|
||||
"inputs": {
|
||||
"fixed": {
|
||||
"name": "controlar después de generar"
|
||||
},
|
||||
"seed": {
|
||||
"name": "semilla"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "semilla",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SelectCLIPDevice": {
|
||||
"description": "Coloca el codificador de texto CLIP en un dispositivo específico (default / cpu / gpu:N).\n\n- \"default\" restaura el dispositivo asignado por el cargador.\n- \"cpu\" fija tanto la carga como la descarga en la CPU.\n- \"gpu:N\" fija el dispositivo de carga en la N-ésima GPU disponible.\n\nCuando el dispositivo seleccionado no existe en la máquina actual\n(por ejemplo, un flujo de trabajo creado en una máquina con 2 GPU abierto en una con 1 GPU),\nel nodo pasa el CLIP sin cambios y registra un mensaje\nen lugar de fallar.",
|
||||
"display_name": "Seleccionar Dispositivo CLIP",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"errorUserTokenAccessDenied": "توکن API شما به این منبع دسترسی ندارد. لطفاً مجوزهای توکن خود را بررسی کنید.",
|
||||
"errorUserTokenInvalid": "توکن API ذخیرهشده شما نامعتبر یا منقضی شده است. لطفاً توکن خود را در تنظیمات بهروزرسانی کنید.",
|
||||
"failedToCreateNode": "ایجاد node ناموفق بود. لطفاً دوباره تلاش کنید یا کنسول را بررسی کنید.",
|
||||
"failedToSetModelValue": "Node اضافه شد، اما مدل آن بهصورت خودکار تنظیم نشد. برای جزئیات بیشتر کنسول را بررسی کنید.",
|
||||
"fileFormats": "فرمتهای فایل",
|
||||
"fileName": "نام فایل",
|
||||
"fileSize": "اندازه فایل",
|
||||
@@ -241,7 +242,8 @@
|
||||
"auth/user-not-found": "حسابی با این ایمیل یافت نشد. مایل به ایجاد حساب جدید هستید؟",
|
||||
"auth/weak-password": "رمز عبور خیلی ضعیف است. لطفاً از رمز عبور قویتر با حداقل ۶ کاراکتر استفاده کنید.",
|
||||
"auth/wrong-password": "رمز عبور وارد شده نادرست است. لطفاً دوباره تلاش کنید.",
|
||||
"generic": "در ورود شما مشکلی پیش آمد. لطفاً دوباره تلاش کنید."
|
||||
"generic": "در ورود شما مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
|
||||
"signupBlocked": "در حال حاضر امکان ایجاد حساب کاربری وجود ندارد. لطفاً بعداً دوباره تلاش کنید. اگر این مشکل ادامه داشت، با support@comfy.org تماس بگیرید."
|
||||
},
|
||||
"login": {
|
||||
"andText": "و",
|
||||
@@ -323,6 +325,11 @@
|
||||
"signUpWithGithub": "ثبتنام با Github",
|
||||
"signUpWithGoogle": "ثبتنام با Google",
|
||||
"title": "ایجاد حساب کاربری"
|
||||
},
|
||||
"turnstile": {
|
||||
"expired": "اعتبار تأییدیه به پایان رسیده است. لطفاً دوباره چالش را تکمیل کنید.",
|
||||
"failed": "تأییدیه ناموفق بود. لطفاً دوباره تلاش کنید.",
|
||||
"submitBlockedHint": "برای فعالسازی ثبتنام، ابتدا چالش تأییدیه بالا را تکمیل کنید."
|
||||
}
|
||||
},
|
||||
"batch": {
|
||||
@@ -346,6 +353,17 @@
|
||||
"x": "ایکس",
|
||||
"y": "وای"
|
||||
},
|
||||
"boundingBoxes": {
|
||||
"clearAll": "پاکسازی همه",
|
||||
"clickRegionToEdit": "برای ویرایش، روی یک ناحیه کلیک کنید.",
|
||||
"colors": "پالت رنگ",
|
||||
"descLabel": "توضیحات",
|
||||
"descPlaceholder": "توضیحات این ناحیه",
|
||||
"textLabel": "متن",
|
||||
"textPlaceholder": "متن برای نمایش (عیناً)",
|
||||
"typeObj": "obj",
|
||||
"typeText": "متن"
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"app": "برنامه",
|
||||
"blueprint": "نقشه راه",
|
||||
@@ -755,6 +773,13 @@
|
||||
"creditsAvailable": "اعتبار موجود",
|
||||
"details": "جزئیات",
|
||||
"eventType": "نوع رویداد",
|
||||
"eventTypes": {
|
||||
"accountCreated": "حساب کاربری ایجاد شد",
|
||||
"apiNodeUsage": "استفاده از Node شریک",
|
||||
"apiUsage": "استفاده از API",
|
||||
"creditAdded": "اعتبار افزوده شد",
|
||||
"gpuUsage": "استفاده از GPU"
|
||||
},
|
||||
"faqs": "سؤالات متداول",
|
||||
"invoiceHistory": "تاریخچه فاکتورها",
|
||||
"lastUpdated": "آخرین بهروزرسانی",
|
||||
@@ -811,6 +836,7 @@
|
||||
},
|
||||
"dataTypes": {
|
||||
"*": "*",
|
||||
"ARRAY": "آرایه",
|
||||
"AUDIO": "صوت",
|
||||
"AUDIO_ENCODER": "رمزگذار صوت",
|
||||
"AUDIO_ENCODER_OUTPUT": "خروجی رمزگذار صوت",
|
||||
@@ -818,11 +844,13 @@
|
||||
"BACKGROUND_REMOVAL": "حذف پسزمینه",
|
||||
"BOOLEAN": "بولی",
|
||||
"BOUNDING_BOX": "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",
|
||||
@@ -832,6 +860,7 @@
|
||||
"CURVE": "CURVE",
|
||||
"DA3_GEOMETRY": "DA3_GEOMETRY",
|
||||
"DA3_MODEL": "DA3_MODEL",
|
||||
"DICT": "دیکشنری",
|
||||
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
|
||||
"FACE_DETECTION_MODEL": "مدل تشخیص چهره",
|
||||
"FACE_LANDMARKS": "FACE_LANDMARKS",
|
||||
@@ -1830,6 +1859,35 @@
|
||||
"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": "NaN",
|
||||
"normalizeExposure": "نوردهی خودکار",
|
||||
"openInHdrViewer": "باز کردن در نمایشگر HDR",
|
||||
"resolution": "وضوح",
|
||||
"sourceGamut": "گاموت منبع",
|
||||
"stdDev": "انحراف معیار",
|
||||
"title": "نمایشگر HDR"
|
||||
},
|
||||
"help": {
|
||||
"helpCenterMenu": "منوی مرکز راهنما",
|
||||
"recentReleases": "انتشارهای اخیر"
|
||||
@@ -2957,6 +3015,10 @@
|
||||
"uploadError": "بارگذاری تصویر painter ناموفق بود: {status} - {statusText}",
|
||||
"width": "عرض"
|
||||
},
|
||||
"palette": {
|
||||
"addColor": "افزودن رنگ",
|
||||
"swatchTitle": "برای ویرایش کلیک کنید · برای جابجایی بکشید · برای حذف راستکلیک کنید"
|
||||
},
|
||||
"progressToast": {
|
||||
"allDownloadsCompleted": "همه دانلودها تکمیل شدند",
|
||||
"downloadingModel": "در حال دانلود مدل...",
|
||||
@@ -3041,7 +3103,6 @@
|
||||
"color": "رنگ نود",
|
||||
"editSubgraph": "ویرایش زیرگراف",
|
||||
"editTitle": "ویرایش عنوان",
|
||||
"enterSubgraph": "ورود به زیرگراف",
|
||||
"errorHelp": "برای دریافت کمک بیشتر، {github} یا {support}",
|
||||
"errorHelpGithub": "ثبت یک issue در GitHub",
|
||||
"errorHelpSupport": "تماس با پشتیبانی ما",
|
||||
@@ -3673,6 +3734,10 @@
|
||||
"addApiCredits": "افزودن اعتبار API",
|
||||
"addCredits": "افزودن اعتبار",
|
||||
"addCreditsLabel": "هر زمان اعتبار بیشتری اضافه کنید",
|
||||
"additionalCredits": "اعتبارهای اضافی",
|
||||
"additionalCreditsInUse": "در حال استفاده",
|
||||
"additionalCreditsInfo": "درباره اعتبارهای اضافی",
|
||||
"additionalCreditsTooltip": "اعتبارهایی که علاوه بر طرح خود اضافه میکنید. پس از اتمام اعتبار ماهانه مصرف میشوند. هر یک تا یک سال پس از خرید منقضی میشود.",
|
||||
"benefits": {
|
||||
"benefit1": "۱۰ دلار اعتبار ماهانه برای Partner Nodes — در صورت نیاز شارژ کنید",
|
||||
"benefit1FreeTier": "اعتبار ماهانه بیشتر، شارژ مجدد در هر زمان",
|
||||
@@ -3694,27 +3759,55 @@
|
||||
"keepSubscription": "حفظ اشتراک",
|
||||
"title": "لغو اشتراک"
|
||||
},
|
||||
"cancelSubscription": "لغو اشتراک",
|
||||
"cancelPlan": "لغو پلن",
|
||||
"cancelSuccess": "اشتراک با موفقیت لغو شد",
|
||||
"canceled": "لغو شد",
|
||||
"canceledCard": {
|
||||
"description": "دیگر هزینهای از شما دریافت نمیشود. امکانات شما تا تاریخ {date} فعال خواهد بود.",
|
||||
"title": "اشتراک شما لغو شده است"
|
||||
},
|
||||
"changePlan": "تغییر پلن",
|
||||
"changeTo": "تغییر به {plan}",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyCloudLogo": "لوگوی Comfy Cloud",
|
||||
"contactOwnerToSubscribe": "برای فعالسازی اشتراک با مالک محیط کاری تماس بگیرید",
|
||||
"contactUs": "تماس با ما",
|
||||
"creditSliderSave": "ذخیره {percent}٪ ({amount})",
|
||||
"creditsLeftOfTotal": "{remaining} از {total} باقیمانده",
|
||||
"creditsRemainingThisMonth": "شامل شده (شارژ مجدد {date})",
|
||||
"creditsRemainingThisYear": "شامل شده (شارژ مجدد {date})",
|
||||
"creditsUsed": "{used} مصرف شده",
|
||||
"creditsYouveAdded": "اضافه شده",
|
||||
"currentPlan": "طرح فعلی",
|
||||
"customLoRAsLabel": "LoRAهای خود را وارد کنید",
|
||||
"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 است.",
|
||||
@@ -3742,7 +3835,7 @@
|
||||
"inviteUpTo": "دعوت تا سقف",
|
||||
"invoiceHistory": "تاریخچه فاکتورها",
|
||||
"learnMore": "اطلاعات بیشتر",
|
||||
"managePayment": "مدیریت پرداخت",
|
||||
"manageBilling": "مدیریت صورتحساب",
|
||||
"managePlan": "مدیریت طرح",
|
||||
"manageSubscription": "مدیریت اشتراک",
|
||||
"maxDuration": {
|
||||
@@ -3756,50 +3849,99 @@
|
||||
"maxMembersLabel": "حداکثر اعضا",
|
||||
"member": "عضو",
|
||||
"memberCount": "{count} عضو",
|
||||
"membersLabel": "تا {count} عضو",
|
||||
"messageSupport": "پیام به پشتیبانی",
|
||||
"monthly": "ماهانه",
|
||||
"monthlyBonusDescription": "پاداش ماهانه اعتبار",
|
||||
"monthlyCredits": "اعتبار ماهانه",
|
||||
"monthlyCreditsInfo": "این اعتبارها هر ماه شارژ میشوند و منتقل نمیشوند",
|
||||
"monthlyCreditsLabel": "اعتبار ماهانه",
|
||||
"monthlyCreditsPerMemberLabel": "اعتبار ماهانه / هر عضو",
|
||||
"monthlyCreditsRollover": "این اعتبارها به ماه بعد منتقل میشوند",
|
||||
"monthlyCreditsUsedUpDescription": "اکنون در حال استفاده از اعتبارهای اضافی هستید.",
|
||||
"monthlyCreditsUsedUpTitle": "اعتبار ماهانه تمام شده است. شارژ مجدد در {date}",
|
||||
"monthlyCreditsUsedUpTitleNoDate": "اعتبار ماهانه تمام شده است",
|
||||
"monthlyUsageProgress": "{used} از {total} اعتبار ماهانه مصرف شده است",
|
||||
"mostPopular": "محبوبترین",
|
||||
"needTeamWorkspace": "به فضای کاری تیمی نیاز دارید؟",
|
||||
"nextBillingCycle": "چرخه صورتحساب بعدی",
|
||||
"nextMonthInvoice": "صورتحساب ماه آینده",
|
||||
"outOfCreditsDescription": "برای ادامه تولید، اعتبار بیشتری اضافه کنید.",
|
||||
"outOfCreditsTitle": "اعتبار شما تمام شده است. شارژ مجدد در {date}",
|
||||
"outOfCreditsTitleNoDate": "اعتبار شما تمام شده است",
|
||||
"partnerNodesBalance": "اعتبار «Partner Nodes»",
|
||||
"partnerNodesCredits": "قیمتگذاری Partner Nodes",
|
||||
"partnerNodesDescription": "برای اجرای مدلهای تجاری/اختصاصی",
|
||||
"partnerNodesPricingTable": "جدول قیمتگذاری Partner Nodeها",
|
||||
"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": "تا {date} در {plan} باقی خواهید ماند.",
|
||||
"subscribeToPlan": "اشتراک پلن {plan}",
|
||||
"switchToPlan": "تغییر به پلن {plan}",
|
||||
"switchesToday": "تغییرات امروز",
|
||||
"terms": "شرایط",
|
||||
"termsAgreement": "با ادامه، شما با {terms} و {privacy} Comfy Org موافقت میکنید.",
|
||||
"totalDueToday": "مبلغ قابل پرداخت امروز"
|
||||
"totalDueToday": "مبلغ قابل پرداخت امروز",
|
||||
"yearlySubscription": "اشتراک سالانه",
|
||||
"youllBeCharged": "شما مبلغ زیر را پرداخت خواهید کرد"
|
||||
},
|
||||
"pricingBlurb": "*بر اساس این قالب، {seeDetails}. برای {questions} یا {enterpriseDiscussions} با ما تماس بگیرید. برای جزئیات بیشتر قیمتگذاری، {clickHere}.",
|
||||
"pricingBlurbClickHere": "اینجا کلیک کنید",
|
||||
"pricingBlurbEnterprise": "بحثهای سازمانی",
|
||||
"pricingBlurbQuestions": "سؤالات",
|
||||
"pricingBlurbSeeDetails": "جزئیات را ببینید",
|
||||
"reactivatePlan": "فعالسازی مجدد پلن",
|
||||
"refillsDate": "شارژ مجدد در {date}",
|
||||
"refillsNextCycle": "شارژ مجدد در چرخه بعدی",
|
||||
"refreshCredits": "بهروزرسانی اعتبارها",
|
||||
"remaining": "باقیمانده",
|
||||
"renewsDate": "تمدید در {date}",
|
||||
"renewsOnDate": "تمدید در تاریخ {date}",
|
||||
"required": {
|
||||
"pollingFailed": "فعالسازی اشتراک ناموفق بود",
|
||||
"pollingSuccess": "اشتراک با موفقیت فعال شد!",
|
||||
@@ -3811,7 +3953,11 @@
|
||||
"resubscribe": "تمدید اشتراک",
|
||||
"resubscribeSuccess": "اشتراک با موفقیت فعال شد",
|
||||
"resubscribeTo": "تمدید اشتراک {plan}",
|
||||
"saveYearly": "٪۲۰ صرفهجویی",
|
||||
"saveYearlyUpTo": "تا ۲۰٪ صرفهجویی کنید",
|
||||
"soloUseOnly": "فقط برای استفاده فردی",
|
||||
"subscribe": "اشتراک",
|
||||
"subscribeFailed": "اشتراکگذاری ناموفق بود",
|
||||
"subscribeForMore": "ارتقاء",
|
||||
"subscribeNow": "هماکنون اشتراک بگیرید",
|
||||
"subscribeTo": "اشتراک در {plan}",
|
||||
@@ -3819,10 +3965,46 @@
|
||||
"subscribeToRun": "اشتراک",
|
||||
"subscribeToRunFull": "اشتراک برای اجرا",
|
||||
"subscriptionRequiredMessage": "برای اجرای workflowها در Cloud، اشتراک لازم است.",
|
||||
"success": {
|
||||
"allSet": "همه چیز آماده است",
|
||||
"inviteEmailsPlaceholder": "ایمیلها را با ویرگول جدا کنید",
|
||||
"inviteSubtext": "میتوانید بعداً از بخش تنظیمات نیز افراد را دعوت کنید",
|
||||
"inviteTitle": "تیم خود را دعوت کنید",
|
||||
"planUpdated": "پلن شما با موفقیت بهروزرسانی شد.",
|
||||
"receiptEmailed": "رسید به ایمیل شما ارسال شد.",
|
||||
"sendInvites": "ارسال دعوتنامه"
|
||||
},
|
||||
"teamHeader": "برای تیمهایی که قصد همکاری دارند. به اعضای بیشتری نیاز دارید؟ {learnMore} درباره پلن سازمانی.",
|
||||
"teamHeaderLearnMore": "بیشتر بدانید",
|
||||
"teamPerks": {
|
||||
"concurrentRuns": "اعضا میتوانند workflowها را به صورت همزمان اجرا کنند",
|
||||
"inviteMembers": "دعوت اعضا",
|
||||
"rolePermissions": "دسترسی مبتنی بر نقش",
|
||||
"sharedCreditPool": "استخر اعتبار مشترک برای همه اعضا"
|
||||
},
|
||||
"teamPlan": {
|
||||
"changePlan": "تغییر پلن",
|
||||
"comingSoonLabel": "بهزودی:",
|
||||
"cta": "اشتراک سالانه تیمی",
|
||||
"ctaMonthly": "اشتراک ماهانه تیمی",
|
||||
"currentPlan": "پلن فعلی",
|
||||
"detailsTitle": "جزئیات",
|
||||
"name": "پلن تیمی",
|
||||
"perkConcurrentRuns": "اعضا میتوانند workflowها را بهصورت همزمان اجرا کنند",
|
||||
"perkInviteMembers": "دعوت اعضای تیم",
|
||||
"perkProjectAssets": "مدیریت پروژه و داراییها",
|
||||
"perkRolePermissions": "دسترسی مبتنی بر نقش",
|
||||
"perkSharedPool": "استخر اعتبار مشترک برای همه اعضا",
|
||||
"tagline": "اشتراک اعتباری ماهانه دلخواه خود را انتخاب کنید. با اعتبار بیشتر، تخفیف بیشتری دریافت کنید.",
|
||||
"unavailable": "این طرح تیمی در حال حاضر در دسترس نیست."
|
||||
},
|
||||
"teamPlanIncludes": "پلن شما شامل همه امکانات {plan} و همچنین:",
|
||||
"teamPlanName": "تیمی",
|
||||
"teamWorkspace": "فضای کاری تیمی",
|
||||
"tierNameYearly": "{name} سالانه",
|
||||
"tiers": {
|
||||
"creator": {
|
||||
"feature1": "امکان وارد کردن مدلهای شخصی",
|
||||
"name": "خالق"
|
||||
},
|
||||
"founder": {
|
||||
@@ -3832,9 +4014,12 @@
|
||||
"name": "رایگان"
|
||||
},
|
||||
"pro": {
|
||||
"feature1": "زمان اجرای workflow طولانیتر (تا ۱ ساعت)",
|
||||
"name": "حرفهای"
|
||||
},
|
||||
"standard": {
|
||||
"feature1": "حداکثر زمان اجرای workflow: ۳۰ دقیقه",
|
||||
"feature2": "امکان افزودن اعتبار بیشتر در هر زمان",
|
||||
"name": "استاندارد"
|
||||
}
|
||||
},
|
||||
@@ -3847,6 +4032,8 @@
|
||||
"upgradeToAddCredits": "برای افزودن اعتبار ارتقاء دهید",
|
||||
"usdPerMonth": "دلار آمریکا / ماه",
|
||||
"usdPerMonthPerMember": "دلار آمریکا / ماه / هر عضو",
|
||||
"usedAfterMonthly": "پس از اتمام اعتبار ماهانه مصرف میشود",
|
||||
"videoEstimate": "تولید حدود ~{count} ویدیو ۵ ثانیهای*",
|
||||
"videoEstimateExplanation": "این تخمینها بر اساس قالب Wan 2.2 Image-to-Video با تنظیمات پیشفرض (۵ ثانیه، ۶۴۰×۶۴۰، ۱۶ فریم بر ثانیه، ۴ مرحله نمونهگیری) است.",
|
||||
"videoEstimateHelp": "جزئیات بیشتر درباره این قالب",
|
||||
"videoEstimateLabel": "تخمین تعداد ویدیوهای ۵ ثانیهای تولید شده با قالب Wan 2.2 Image-to-Video",
|
||||
@@ -3856,10 +4043,10 @@
|
||||
"viewMoreDetails": "مشاهده جزئیات بیشتر",
|
||||
"viewMoreDetailsPlans": "مشاهده جزئیات بیشتر درباره طرحها و قیمتها",
|
||||
"viewUsageHistory": "مشاهده تاریخچه استفاده",
|
||||
"whatsIncluded": "شامل موارد زیر:",
|
||||
"workspaceNotSubscribed": "این محیط کاری اشتراک فعال ندارد",
|
||||
"yearly": "سالانه",
|
||||
"yearlyCreditsLabel": "کل اعتبار سالانه",
|
||||
"yearlyDiscount": "٪۲۰ تخفیف",
|
||||
"yourPlanIncludes": "طرح شما شامل:"
|
||||
},
|
||||
"tabMenu": {
|
||||
@@ -4150,6 +4337,19 @@
|
||||
}
|
||||
},
|
||||
"workspacePanel": {
|
||||
"changeRoleDialog": {
|
||||
"demoteConfirm": "تنزل به عضو",
|
||||
"demoteMessage": "دسترسی مدیریتی را از دست خواهد داد.",
|
||||
"demoteTitle": "آیا {name} را به عضو عادی تنزل دهید؟",
|
||||
"error": "بهروزرسانی نقش ناموفق بود",
|
||||
"promoteConfirm": "تبدیل به مالک",
|
||||
"promoteIntro": "او قادر خواهد بود:",
|
||||
"promotePermissionCredits": "افزودن اعتبار اضافی",
|
||||
"promotePermissionManage": "مدیریت اعضا، روشهای پرداخت و تنظیمات workspace",
|
||||
"promotePermissionRoles": "ارتقاء یا تنزل سایر مالکان (به جز سازنده workspace).",
|
||||
"promoteTitle": "آیا {name} را مالک کنید؟",
|
||||
"success": "نقش با موفقیت بهروزرسانی شد"
|
||||
},
|
||||
"createWorkspaceDialog": {
|
||||
"create": "ایجاد",
|
||||
"message": "محیطهای کاری به اعضا اجازه میدهند از یک اعتبار مشترک استفاده کنند. پس از ایجاد، شما مالک خواهید بود.",
|
||||
@@ -4174,17 +4374,11 @@
|
||||
"inviteLimitReached": "شما به حداکثر تعداد ۵۰ عضو رسیدهاید",
|
||||
"inviteMember": "دعوت عضو",
|
||||
"inviteMemberDialog": {
|
||||
"createLink": "ایجاد لینک",
|
||||
"linkCopied": "کپی شد",
|
||||
"linkCopyFailed": "کپی لینک ناموفق بود",
|
||||
"linkStep": {
|
||||
"copyLink": "کپی لینک",
|
||||
"done": "انجام شد",
|
||||
"message": "اطمینان حاصل کنید که حساب کاربری او از این ایمیل استفاده میکند.",
|
||||
"title": "این لینک را برای شخص ارسال کنید"
|
||||
},
|
||||
"message": "یک لینک دعوت قابل اشتراکگذاری برای ارسال به شخص ایجاد کنید",
|
||||
"failedCount": "ارسال {count} دعوتنامه ناموفق بود. دوباره تلاش کنید. | ارسال {count} دعوتنامه ناموفق بود. دوباره تلاش کنید.",
|
||||
"invalidEmailCount": "{count} آدرس ایمیل نامعتبر | {count} آدرس ایمیل نامعتبر",
|
||||
"invitedMessage": "دعوتنامه به {emails} ارسال شد | دعوتنامهها به {emails} ارسال شدند",
|
||||
"placeholder": "ایمیل شخص را وارد کنید",
|
||||
"seatLimitReached": "شما میتوانید تا {count} همکار دعوت کنید. | شما میتوانید تا {count} همکار دعوت کنید.",
|
||||
"title": "دعوت یک نفر به این فضای کاری"
|
||||
},
|
||||
"inviteUpsellDialog": {
|
||||
@@ -4192,8 +4386,7 @@
|
||||
"messageSingleSeat": "پلن Standard فقط یک صندلی برای مالک workspace دارد. برای دعوت اعضای بیشتر، به پلن Creator یا بالاتر ارتقا دهید تا امکان افزودن چندین نفر فعال شود.",
|
||||
"titleNotSubscribed": "برای دعوت اعضا نیاز به اشتراک دارید",
|
||||
"titleSingleSeat": "پلن فعلی شما فقط یک نفر را پشتیبانی میکند",
|
||||
"upgradeToCreator": "ارتقا به Creator",
|
||||
"viewPlans": "مشاهده پلنها"
|
||||
"upgradeToTeam": "ارتقاء به تیم"
|
||||
},
|
||||
"leaveDialog": {
|
||||
"leave": "خروج",
|
||||
@@ -4202,30 +4395,35 @@
|
||||
},
|
||||
"members": {
|
||||
"actions": {
|
||||
"copyLink": "کپی لینک دعوت",
|
||||
"cancelInvite": "لغو دعوتنامه",
|
||||
"changeRole": "تغییر نقش",
|
||||
"removeMember": "حذف عضو",
|
||||
"revokeInvite": "لغو دعوت"
|
||||
"resendInvite": "ارسال مجدد دعوتنامه"
|
||||
},
|
||||
"columns": {
|
||||
"expiryDate": "تاریخ انقضا",
|
||||
"inviteDate": "تاریخ دعوت",
|
||||
"joinDate": "تاریخ پیوستن"
|
||||
"role": "نقش"
|
||||
},
|
||||
"createNewWorkspace": "یک فضای کاری جدید ایجاد کنید.",
|
||||
"contactUs": "با ما تماس بگیرید",
|
||||
"header": "اعضا",
|
||||
"membersCount": "{count}/۵۰ عضو",
|
||||
"needMoreMembers": "به اعضای بیشتری نیاز دارید؟",
|
||||
"noInvites": "هیچ دعوتنامهای در انتظار نیست",
|
||||
"noMembers": "هیچ عضوی وجود ندارد",
|
||||
"pendingInvitesCount": "{count} دعوتنامه در انتظار | {count} دعوتنامه در انتظار",
|
||||
"personalWorkspaceMessage": "در حال حاضر نمیتوانید اعضای دیگری به فضای کاری شخصی خود دعوت کنید. برای افزودن اعضا به یک فضای کاری،",
|
||||
"reactivateTeam": "فعالسازی مجدد تیم",
|
||||
"searchPlaceholder": "جستجو...",
|
||||
"tabs": {
|
||||
"active": "فعال",
|
||||
"pendingCount": "در انتظار ({count})"
|
||||
},
|
||||
"upsellBannerSubscribe": "برای دعوت اعضای تیم به این workspace، به پلن Creator یا بالاتر اشتراک تهیه کنید.",
|
||||
"upsellBannerUpgrade": "برای دعوت اعضای بیشتر به این workspace، به پلن Creator یا بالاتر ارتقا دهید.",
|
||||
"viewPlans": "مشاهده پلنها"
|
||||
"upgradeToTeam": "ارتقاء به تیم",
|
||||
"upsellBanner": "برای افزودن همکاران، پلن خود را ارتقاء دهید.",
|
||||
"upsellBannerReactivate": "برای افزودن همکاران بیشتر، پلن خود را فعالسازی مجدد کنید."
|
||||
},
|
||||
"menu": {
|
||||
"creatorCannotLeave": "سازنده workspace نمیتواند workspace ایجادشده را ترک کند.",
|
||||
"deleteWorkspace": "حذف محیط کاری",
|
||||
"deleteWorkspaceDisabledTooltip": "ابتدا اشتراک فعال محیط کاری خود را لغو کنید",
|
||||
"editWorkspace": "ویرایش جزئیات محیط کاری",
|
||||
@@ -4254,6 +4452,8 @@
|
||||
"failedToFetchWorkspaces": "بارگذاری فضاهای کاری ناموفق بود",
|
||||
"failedToLeaveWorkspace": "خروج از محیط کاری ناموفق بود",
|
||||
"failedToUpdateWorkspace": "بهروزرسانی محیط کاری ناموفق بود",
|
||||
"inviteResendFailed": "ارسال مجدد دعوتنامه ناموفق بود",
|
||||
"inviteResent": "دعوتنامه مجدداً ارسال شد",
|
||||
"workspaceCreated": {
|
||||
"message": "برای یک طرح اشتراک ثبتنام کنید، همتیمیها را دعوت کنید و همکاری را آغاز نمایید.",
|
||||
"subscribe": "اشتراک",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user