Compare commits

..

5 Commits

Author SHA1 Message Date
huang47
0c6b8a64e6 test: remove default-state and delegation-echo tests from useCurrentUser 2026-07-06 12:05:12 -07:00
huang47
b2ad43746d test: replace fromAny and vi.mock('vue-i18n') with lint-compliant equivalents
- authStore.test.ts: remove vi.mock('vue-i18n'), add vi.mock('@/i18n') for key passthrough; replace double-casts with fromPartial
- appModeStore.test.ts: remove fromAny import; replace LGraphNode casts with createMockLGraphNode; use fromPartial for workflow/widget mocks; restructure createWorkflowWithLinearData to avoid PartialDeep type conflict
- domWidgetStore.test.ts: replace Partial<LGraphNode> double-cast with createMockLGraphNode
2026-07-03 09:39:36 -07:00
huang47
817354af0c refactor: dedupe Firebase mock credential casts in authStore.test.ts
Isolated cleanup, no test-behavior change: extracts the repeated
{ user } as Partial<UserCredential> as UserCredential double-cast
into an asUserCredential() helper.
2026-07-02 14:30:46 -07:00
huang47
95257dbba7 test: keep authStore coverage additions free of unrelated refactor
Coverage-only commit: reverts the incidental asUserCredential() helper
extraction so this PR's diff carries no deletions beyond the one
necessary trailing comma. The helper returns as its own commit next.
2026-07-02 14:28:49 -07:00
huang47
08648920af test: cover session UI stores 2026-07-02 09:00:38 -07:00
236 changed files with 2211 additions and 19425 deletions

86
.claude/settings.json Normal file
View File

@@ -0,0 +1,86 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc directly.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(vue-tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running vue-tsc directly.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc via npx.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc via pnpx.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpm exec tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of `pnpm exec tsc`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx eslint *)",
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of npx eslint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx eslint *)",
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of pnpx eslint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx prettier *)",
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx prettier *)",
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx oxlint *)",
"command": "echo 'Use `pnpm oxlint` instead of npx oxlint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx stylelint *)",
"command": "echo 'Use `pnpm stylelint` instead of npx stylelint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx knip *)",
"command": "echo 'Use `pnpm knip` instead of npx knip.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx knip *)",
"command": "echo 'Use `pnpm knip` instead of pnpx knip.' >&2 && exit 2"
}
]
}
]
}
}

View File

@@ -134,27 +134,6 @@ jobs:
fi
echo '✅ No Customer.io references found'
- name: Scan dist for Syft telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for Syft references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e '(?i)syft' \
-e '(?i)sy-d\.io' \
dist; then
echo '❌ ERROR: Syft references found in dist assets!'
echo 'Syft must be properly tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
echo '2. Call telemetry via useTelemetry() hook'
echo '3. Use conditional dynamic imports behind isCloud checks'
exit 1
fi
echo '✅ No Syft references found'
- name: Scan dist for Cloudflare Turnstile sitekey references
run: |
set -euo pipefail

View File

@@ -121,7 +121,7 @@ jobs:
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1 \
--ignore-errors source,unmapped \
--ignore-errors source,unmapped,range \
--synthesize-missing
- name: Upload HTML report artifact

View File

@@ -95,7 +95,6 @@ jobs:
if: |
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& startsWith(github.head_ref, 'version-bump-')
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'

View File

@@ -55,3 +55,6 @@ jobs:
flags: unit
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Enforce critical coverage gate
run: pnpm test:coverage:critical

View File

@@ -30,7 +30,7 @@ concurrency:
jobs:
deploy-preview:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read

View File

@@ -67,15 +67,7 @@ jobs:
- name: Deploy report to Cloudflare
id: deploy
if: >-
${{
always() &&
!cancelled() &&
(
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork == false
)
}}
if: always() && !cancelled()
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

View File

@@ -32,13 +32,12 @@ jobs:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(github.event_name != 'pull_request' ||
(github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))))
(github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
runs-on: ubuntu-latest
steps:
- name: Build client payload

View File

@@ -21,7 +21,6 @@ jobs:
# - Preview label specifically removed
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'closed' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,3 +1,3 @@
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
</svg>

Before

Width:  |  Height:  |  Size: 279 B

After

Width:  |  Height:  |  Size: 380 B

View File

@@ -56,7 +56,7 @@ const columnClass: Record<ColumnCount, string> = {
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader max-width="xl" :label="eyebrow" align="start">
<SectionHeader :label="eyebrow" align="start">
{{ heading }}
<template v-if="subtitle" #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">

View File

@@ -33,41 +33,36 @@ useHeroAnimation({
</script>
<template>
<section
ref="sectionRef"
class="px-4 py-20 lg:flex lg:gap-16 lg:px-20 lg:py-24"
>
<section ref="sectionRef" class="px-4 py-20 lg:flex lg:px-20 lg:py-24">
<!-- Left column: intro + image -->
<div class="lg:w-1/2">
<div class="lg:max-w-xl">
<SectionLabel ref="badgeRef">
{{ t(tk('badge'), locale) }}
</SectionLabel>
<SectionLabel ref="badgeRef">
{{ t(tk('badge'), locale) }}
</SectionLabel>
<h1
ref="headingRef"
class="mt-4 text-3xl font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl"
>
{{ t(tk('heading'), locale) }}
</h1>
<h1
ref="headingRef"
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line lg:text-5xl"
>
{{ t(tk('heading'), locale) }}
</h1>
<div ref="descRef">
<p class="mt-4 text-sm text-primary-comfy-canvas">
{{ t(tk('description'), locale) }}
</p>
<div ref="descRef">
<p class="text-primary-comfy-canvas mt-4 text-sm">
{{ t(tk('description'), locale) }}
</p>
<p class="mt-4 text-sm text-primary-comfy-canvas">
{{ t(tk('supportLink'), locale) }}
<a
href="https://docs.comfy.org/"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow underline"
>
{{ t(tk('supportLinkCta'), locale) }}
</a>
</p>
</div>
<p class="text-primary-comfy-canvas mt-4 text-sm">
{{ t(tk('supportLink'), locale) }}
<a
href="https://docs.comfy.org/"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow underline"
>
{{ t(tk('supportLinkCta'), locale) }}
</a>
</p>
</div>
<div ref="imageRef" class="mt-8 overflow-hidden rounded-2xl lg:-ml-20">

View File

@@ -40,13 +40,13 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{
label: t('nav.products', locale),
featured: {
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
imageSrc: 'https://media.comfy.org/website/nav/featured-model-card.jpg',
imageAlt: t('nav.featuredProductsAlt', locale),
title: t('nav.featuredProductsTitle', locale),
cta: {
label: t('cta.getStarted', locale),
label: t('cta.tryWorkflow', locale),
ariaLabel: t('nav.featuredProductsCtaAria', locale),
href: routes.mcp
href: 'https://comfy.org/workflows/api_seedance2_0_r2v-64f4db9e3e33/'
}
},
columns: [

View File

@@ -26,10 +26,6 @@ const translations = {
en: 'Try Workflow',
'zh-CN': '试用工作流'
},
'cta.getStarted': {
en: 'GET STARTED',
'zh-CN': '快速开始'
},
'cta.watchNow': {
en: 'Watch Now',
'zh-CN': '立即观看'
@@ -2200,16 +2196,16 @@ const translations = {
// Featured dropdown cards — keys are keyed by parent nav item, not card content,
// so the copy can be swapped without renaming the key.
'nav.featuredProductsTitle': {
en: 'NEW: COMFY MCP',
'zh-CN': '全新发布:Comfy MCP'
en: 'New Release: Seedance 2.0',
'zh-CN': '全新发布:Seedance 2.0'
},
'nav.featuredProductsAlt': {
en: 'Comfy MCP feature image',
'zh-CN': 'Comfy MCP 精选图片'
en: 'Seedance 2.0 release feature image',
'zh-CN': 'Seedance 2.0 发布精选图片'
},
'nav.featuredProductsCtaAria': {
en: 'Get started with Comfy MCP',
'zh-CN': '开始使用 Comfy MCP'
en: 'Try the Seedance 2.0 workflow',
'zh-CN': '试用 Seedance 2.0 工作流'
},
'nav.featuredCommunityTitle': {
en: 'Sky Replacement',

View File

@@ -537,6 +537,7 @@ export const comfyPageFixture = base.extend<{
'Comfy.TutorialCompleted': true,
'Comfy.Queue.MaxHistoryItems': 64,
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
'Comfy.VueNodes.AutoScaleLayout': false,
// Disable toast warning about version compatibility, as they may or
// may not appear - depending on upstream ComfyUI dependencies
'Comfy.VersionCompatibility.DisableWarnings': true,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -46,7 +46,6 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage, maskEditor }) => {
const { nodeId } = await maskEditor.loadImageOnNode()
await comfyPage.canvasOps.pan({ x: 0, y: 40 }, { x: 300, y: 300 })
const nodeHeader = comfyPage.vueNodes
.getNodeLocator(nodeId)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -691,8 +691,7 @@ test(
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
await comfyPage.canvas.hover({ position: emptySlotPos })
await comfyPage.page.mouse.down()
const { width, height } = (await stepsSlot.boundingBox())!
await stepsSlot.hover({ position: { x: (width * 3) / 4, y: height / 2 } })
await stepsSlot.hover()
await expect.poll(hasSnap).toBe(true)
await comfyPage.page.mouse.up()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -1238,7 +1238,7 @@ test(
{ tag: '@vue-nodes' },
async ({ comfyMouse, comfyPage }) => {
async function performDisconnect(slot: Locator, isFast: boolean) {
await comfyMouse.dragElementBy(slot, { x: isFast ? -30 : -80 })
await comfyMouse.dragElementBy(slot, { x: isFast ? -25 : -80 })
if (!isFast) {
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
@@ -1251,7 +1251,7 @@ test(
const ksamplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
const ksampler = new VueNodeFixture(ksamplerLocator)
await comfyMouse.dragElementBy(ksampler.title, { x: 100 })
await comfyMouse.dragElementBy(ksamplerLocator, { x: 100 })
await test.step('Disconnection with normal links', async () => {
await performDisconnect(ksampler.getSlot('model'), true)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -234,8 +234,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await comfyPage.page
.context()
.grantPermissions(['clipboard-read', 'clipboard-write'])
await comfyPage.nodeOps.clearGraph()
await comfyPage.searchBoxV2.addNode('Load Image')
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes(1)
await comfyPage.page
.locator('[data-node-id] img')

View File

@@ -14,8 +14,7 @@ const wstest = mergeTests(test, webSocketFixture)
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
async function loadImageOnNode(comfyPage: ComfyPage) {
await comfyPage.nodeOps.clearGraph()
await comfyPage.searchBoxV2.addNode('Load Image')
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -12,14 +12,14 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
const getHeaderPos = async (
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number }> => {
): Promise<{ x: number; y: number; width: number; height: number }> => {
const box = await comfyPage.vueNodes
.getNodeByTitle(title)
.getByTestId('node-title')
.first()
.boundingBox()
if (!box) throw new Error(`${title} header not found`)
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
return box
}
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) =>
@@ -84,27 +84,29 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await comfyPage.idleFrames(2)
}
test('should allow moving nodes by dragging', async ({
comfyPage,
comfyMouse
}) => {
const initialHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
await comfyMouse.dragElementBy(node.header, { x: 100, y: 100 })
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
x: 256,
y: 256
})
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(initialHeaderPos, newHeaderPos)
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
})
test('should not move node when pointer moves less than drag threshold', async ({
comfyPage,
comfyMouse
comfyPage
}) => {
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
await comfyMouse.dragElementBy(node.header, { x: 2, y: 1 })
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
steps: 5
})
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
@@ -293,12 +295,14 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(3)
// Re-fetch drag source after clicks in case the header reflowed.
const headerPos = await getHeaderPos(comfyPage, 'Load Checkpoint')
const dragSrc = await getHeaderPos(comfyPage, 'Load Checkpoint')
const centerX = dragSrc.x + dragSrc.width / 2
const centerY = dragSrc.y + dragSrc.height / 2
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(headerPos.x + dx, headerPos.y + dy, {
await comfyPage.page.mouse.move(centerX + dx, centerY + dy, {
steps: 20
})
await comfyPage.page.mouse.up()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -42,10 +42,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
await expect(pinIndicator2).toBeHidden()
})
test('should not allow dragging pinned nodes', async ({
comfyMouse,
comfyPage
}) => {
test('should not allow dragging pinned nodes', async ({ comfyPage }) => {
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
await checkpointNodeHeader.click()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
@@ -53,7 +50,10 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
// Try to drag the node
const headerPos = await checkpointNodeHeader.boundingBox()
if (!headerPos) throw new Error('Failed to get header position')
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
await comfyPage.canvasOps.dragAndDrop(
{ x: headerPos.x, y: headerPos.y },
{ x: headerPos.x + 256, y: headerPos.y + 256 }
)
// Verify the node is not dragged (same position before and after click-and-drag)
await expect
@@ -64,7 +64,11 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
await checkpointNodeHeader.click()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
// Try to drag the node again
await comfyPage.canvasOps.dragAndDrop(
{ x: headerPos.x, y: headerPos.y },
{ x: headerPos.x + 256, y: headerPos.y + 256 }
)
// Verify the node is dragged
await expect

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -5,7 +5,12 @@ import {
test.describe('Widget copy button', { tag: ['@ui', '@vue-nodes'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.searchBoxV2.addNode('Preview as Text')
// Add a PreviewAny node which has a read-only textarea with a copy button
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('PreviewAny')
window.app!.graph.add(node)
})
await comfyPage.vueNodes.waitForNodes()
})

25
global.d.ts vendored
View File

@@ -41,29 +41,6 @@ interface GtagFunction {
(...args: unknown[]): void
}
type SyftDataTraits = Record<string, string | number | null | undefined>
interface SyftDataPendingFetch {
args: unknown[]
resolve: (value: unknown) => void
reject: (reason?: unknown) => void
}
interface SyftDataClient {
identify(email: string, traits?: SyftDataTraits): void
signup(email: string, traits?: SyftDataTraits): void
track(event: string, traits?: SyftDataTraits): void
page(...args: unknown[]): void
q?: unknown[][]
fi?: SyftDataPendingFetch[]
fetchID?: (...args: unknown[]) => Promise<unknown>
}
/** Installed by the Syft UMD instead of SyftDataClient when telemetry is opted out */
interface SyftDisabledClient {
enable: () => void
}
interface Window {
__CONFIG__: {
gtm_container_id?: string
@@ -101,8 +78,6 @@ interface Window {
}
dataLayer?: Array<Record<string, unknown>>
gtag?: GtagFunction
syft?: SyftDataClient | SyftDisabledClient
syftc?: { sourceId?: string; enabled?: boolean }
ire_o?: string
ire?: ImpactQueueFunction
rewardful?: RewardfulQueueFunction

View File

@@ -53,6 +53,7 @@
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:coverage": "vitest run --coverage",
"test:coverage:critical": "cross-env COVERAGE_CRITICAL=true vitest run --coverage",
"test:unit": "vitest run",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",

View File

@@ -197,7 +197,7 @@
--node-component-executing: var(--color-blue-500);
--node-component-header: var(--fg-color);
--node-component-header-icon: var(--color-ash-800);
--node-component-header-surface: var(--color-smoke-200);
--node-component-header-surface: var(--color-smoke-400);
--node-component-outline: var(--color-black);
--node-component-ring: rgb(from var(--color-smoke-500) r g b / 50%);
--node-component-slot-dot-outline-opacity-mult: 1;
@@ -343,7 +343,7 @@
--node-component-border-executing: var(--color-blue-500);
--node-component-border-selected: var(--color-charcoal-200);
--node-component-header-icon: var(--color-smoke-800);
--node-component-header-surface: var(--color-charcoal-700);
--node-component-header-surface: var(--color-charcoal-800);
--node-component-outline: var(--color-white);
--node-component-ring: rgb(var(--color-smoke-500) / 20%);
--node-component-slot-dot-outline-opacity: 10%;
@@ -727,14 +727,14 @@ body {
/* Shared markdown content styling for consistent rendering across components */
.comfy-markdown-content {
/* Typography */
font-size: var(--comfy-textarea-font-size);
font-size: 0.875rem; /* text-sm */
line-height: 1.6;
word-wrap: break-word;
}
/* Headings */
.comfy-markdown-content h1 {
font-size: calc(22 / 14 * var(--comfy-textarea-font-size));
font-size: 22px; /* text-[22px] */
font-weight: 700; /* font-bold */
margin-top: 2rem; /* mt-8 */
margin-bottom: 1rem; /* mb-4 */
@@ -745,7 +745,7 @@ body {
}
.comfy-markdown-content h2 {
font-size: calc(18 / 14 * var(--comfy-textarea-font-size));
font-size: 18px; /* text-[18px] */
font-weight: 700; /* font-bold */
margin-top: 2rem; /* mt-8 */
margin-bottom: 1rem; /* mb-4 */
@@ -756,7 +756,7 @@ body {
}
.comfy-markdown-content h3 {
font-size: calc(16 / 14 * var(--comfy-textarea-font-size));
font-size: 16px; /* text-[16px] */
font-weight: 700; /* font-bold */
margin-top: 2rem; /* mt-8 */
margin-bottom: 1rem; /* mb-4 */

View File

@@ -1,75 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('runWhenGlobalIdle', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('falls back to a timeout when idle callbacks are unavailable', async () => {
vi.useFakeTimers()
vi.stubGlobal('requestIdleCallback', undefined)
vi.stubGlobal('cancelIdleCallback', undefined)
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
const disposable = runWhenGlobalIdle(runner)
await vi.runAllTimersAsync()
expect(runner).toHaveBeenCalledOnce()
const deadline = runner.mock.calls[0][0]
expect(deadline.didTimeout).toBe(true)
expect(deadline.timeRemaining()).toBeGreaterThanOrEqual(0)
disposable.dispose()
disposable.dispose()
})
it('cancels fallback idle work before it runs', async () => {
vi.useFakeTimers()
vi.stubGlobal('requestIdleCallback', undefined)
vi.stubGlobal('cancelIdleCallback', undefined)
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
runWhenGlobalIdle(runner).dispose()
await vi.runAllTimersAsync()
expect(runner).not.toHaveBeenCalled()
})
it('uses native idle callbacks when available', async () => {
const requestIdleCallback = vi.fn(() => 42)
const cancelIdleCallback = vi.fn()
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
vi.stubGlobal('cancelIdleCallback', cancelIdleCallback)
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
const disposable = runWhenGlobalIdle(runner, 250)
expect(requestIdleCallback).toHaveBeenCalledWith(runner, { timeout: 250 })
disposable.dispose()
disposable.dispose()
expect(cancelIdleCallback).toHaveBeenCalledOnce()
expect(cancelIdleCallback).toHaveBeenCalledWith(42)
})
it('omits native idle timeout options when no timeout is supplied', async () => {
const requestIdleCallback = vi.fn(() => 7)
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
vi.stubGlobal('cancelIdleCallback', vi.fn())
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
runWhenGlobalIdle(runner)
expect(requestIdleCallback).toHaveBeenCalledWith(runner, undefined)
})
})

View File

@@ -1,4 +1,4 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
@@ -122,22 +122,6 @@ describe('downloadUtil', () => {
expect(createObjectURLSpy).not.toHaveBeenCalled()
})
it('throws for an empty URL', () => {
expect(() => downloadFile('')).toThrow(
'Invalid URL provided for download'
)
expect(fetchMock).not.toHaveBeenCalled()
expect(createObjectURLSpy).not.toHaveBeenCalled()
})
it('throws for a whitespace URL', () => {
expect(() => downloadFile(' ')).toThrow(
'Invalid URL provided for download'
)
expect(fetchMock).not.toHaveBeenCalled()
expect(createObjectURLSpy).not.toHaveBeenCalled()
})
it('should prefer custom filename over extracted filename', () => {
const testUrl =
'https://example.com/api/file?filename=extracted-image.jpg'
@@ -355,7 +339,7 @@ describe('downloadUtil', () => {
const testUrl = 'https://storage.googleapis.com/bucket/image.png'
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(fromPartial<Window>(mockTab))
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,
@@ -375,7 +359,7 @@ describe('downloadUtil', () => {
mockIsCloud.value = true
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(fromPartial<Window>(mockTab))
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,
@@ -395,7 +379,7 @@ describe('downloadUtil', () => {
const testUrl = 'https://storage.googleapis.com/bucket/missing.png'
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(fromPartial<Window>(mockTab))
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
fetchMock.mockResolvedValue(
fromPartial<Response>({ ok: false, status: 404 })
)
@@ -411,7 +395,7 @@ describe('downloadUtil', () => {
mockIsCloud.value = true
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: true, close: vi.fn() }
windowOpenSpy.mockReturnValue(fromPartial<Window>(mockTab))
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,

View File

@@ -4,7 +4,6 @@ import {
CREDITS_PER_USD,
COMFY_CREDIT_RATE_CENTS,
centsToCredits,
clampUsd,
creditsToCents,
creditsToUsd,
formatCredits,
@@ -44,21 +43,4 @@ describe('comfyCredits helpers', () => {
expect(formatCreditsFromUsd({ usd: 1, locale })).toBe('211.00')
expect(formatUsd({ value: 4.2, locale })).toBe('4.20')
})
test('formats with compatible fraction digit bounds', () => {
expect(
formatCredits({
value: 12.345,
locale: 'en-US',
numberOptions: { minimumFractionDigits: 4, maximumFractionDigits: 2 }
})
).toBe('12.35')
})
test('clamps USD purchase values into the supported range', () => {
expect(clampUsd(Number.NaN)).toBe(0)
expect(clampUsd(-5)).toBe(1)
expect(clampUsd(42)).toBe(42)
expect(clampUsd(5000)).toBe(1000)
})
})

View File

@@ -13,7 +13,7 @@
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div
v-if="managerState.shouldShowManagerButtons.value || isCloud"
v-if="managerState.shouldShowManagerButtons.value"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<Button
@@ -163,7 +163,6 @@ import {
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useManagerSurveyDialog } from '@/workbench/extensions/manager/composables/useManagerSurveyDialog'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { cn } from '@comfyorg/tailwind-utils'
@@ -171,7 +170,6 @@ const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const managerSurveyDialog = useManagerSurveyDialog()
const { flags } = useFeatureFlags()
const { isLoggedIn } = useCurrentUser()
const { t } = useI18n()
@@ -339,10 +337,6 @@ onBeforeUnmount(() => {
})
const openCustomNodeManager = async () => {
if (isCloud) {
managerSurveyDialog.show()
return
}
try {
await managerState.openManager({
initialTab: ManagerTab.All,

View File

@@ -22,7 +22,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { HideLayoutFieldKey, WidgetHeightKey } from '@/types/widgetTypes'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
import { promptRenameWidget } from '@/utils/widgetUtil'
@@ -50,7 +50,6 @@ const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
)
provide(HideLayoutFieldKey, true)
provide(WidgetHeightKey, mobile ? 'h-10' : 'h-7')
const resolvedInputs = useResolvedSelectedInputs()
@@ -237,7 +236,7 @@ defineExpose({ handleDragDrop })
:node-data
:class="
cn(
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1',
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
nodeData.hasErrors && 'ring-2 ring-node-stroke-error ring-inset'
)
"

View File

@@ -1,26 +1,20 @@
<template>
<div
ref="container"
:class="
cn(
'flex overflow-hidden rounded-md bg-component-node-widget-background text-xs text-component-node-foreground',
useWidgetHeight()
)
"
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
>
<slot name="background" />
<Button
v-if="!hideButtons"
:aria-label="t('g.decrement')"
data-testid="decrement"
class="aspect-square h-full rounded-none p-0 hover:bg-component-node-widget-background-hovered disabled:opacity-30"
class="aspect-8/7 h-full rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
size="unset"
:disabled="!canDecrement"
tabindex="-1"
@click="modelValue = clamp(modelValue - step)"
>
<i class="icon-[lucide--minus]" />
<i class="pi pi-minus" />
</Button>
<div class="relative my-0.25 min-w-[4ch] flex-1 py-1.5">
<input
@@ -30,7 +24,7 @@
:disabled
:class="
cn(
'absolute inset-0 truncate border-0 bg-transparent p-1 text-xs focus:outline-0'
'absolute inset-0 truncate border-0 bg-transparent p-1 text-sm focus:outline-0'
)
"
inputmode="decimal"
@@ -60,14 +54,13 @@
v-if="!hideButtons"
:aria-label="t('g.increment')"
data-testid="increment"
class="aspect-square h-full rounded-none p-0 hover:bg-component-node-widget-background-hovered disabled:opacity-30"
class="aspect-8/7 h-full rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
size="unset"
:disabled="!canIncrement"
tabindex="-1"
@click="modelValue = clamp(modelValue + step)"
>
<i class="icon-[lucide--plus]" />
<i class="pi pi-plus" />
</Button>
</div>
</template>
@@ -78,7 +71,6 @@ import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useWidgetHeight } from '@/types/widgetTypes'
import { cn } from '@comfyorg/tailwind-utils'
const {

View File

@@ -158,8 +158,8 @@ import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import { useExternalLink } from '@/composables/useExternalLink'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
@@ -178,7 +178,7 @@ const settingsDialog = useSettingsDialog()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { shouldUseWorkspaceBilling } = useBillingRouting()
const { flags } = useFeatureFlags()
const { isSubscriptionEnabled } = useSubscription()
// Constants
@@ -260,9 +260,9 @@ async function handleBuy() {
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
handleClose(false)
// On the consolidated (workspace) billing flow, show the workspace settings
// panel; otherwise show the legacy subscription/credits panel.
const settingsPanel = shouldUseWorkspaceBilling.value
// In workspace mode (personal workspace), show workspace settings panel
// Otherwise, show legacy subscription/credits panel
const settingsPanel = flags.teamWorkspacesEnabled
? 'workspace'
: isSubscriptionEnabled()
? 'subscription'

View File

@@ -2,11 +2,12 @@ import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, onMounted, ref } from 'vue'
import { defineComponent, onMounted, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { render, screen, waitFor } from '@testing-library/vue'
import type * as DistributionTypes from '@/platform/distribution/types'
import type { AuditLog } from '@/services/customerEventsService'
import { EventType } from '@/services/customerEventsService'
@@ -34,29 +35,19 @@ vi.mock('@/services/customerEventsService', () => ({
}
}))
const mockTelemetry = vi.hoisted(() => ({
checkForCompletedTopup: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => mockTelemetry
useTelemetry: () => null
}))
const mockBillingRouting = vi.hoisted(() => ({
shouldUseWorkspaceBilling: false
const mockFlags = vi.hoisted(() => ({ teamWorkspacesEnabled: false }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: mockFlags })
}))
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
...(await importOriginal<typeof DistributionTypes>()),
isCloud: true
}))
vi.mock('@/composables/billing/useBillingRouting', async () => {
const { ref } = await import('vue')
const shouldUseWorkspaceBilling = ref(false)
Object.defineProperty(mockBillingRouting, 'shouldUseWorkspaceBilling', {
get: () => shouldUseWorkspaceBilling.value,
set: (value: boolean) => {
shouldUseWorkspaceBilling.value = value
}
})
return {
useBillingRouting: () => ({ shouldUseWorkspaceBilling })
}
})
const mockWorkspaceApi = vi.hoisted(() => ({
getBillingEvents: vi.fn()
@@ -77,10 +68,7 @@ const i18n = createI18n({
additionalInfo: 'Additional Info',
added: 'Added',
accountInitialized: 'Account initialized',
model: 'Model',
loadEventsError: 'Failed to load activity. Please try again.',
loadEventsUnknownError:
'Something went wrong while loading activity. Please refresh and try again.'
model: 'Model'
}
}
}
@@ -107,11 +95,6 @@ const AutoRefreshWrapper = defineComponent({
template: '<UsageLogsTable ref="tableRef" />'
})
async function flushMicrotasks() {
await new Promise((resolve) => setTimeout(resolve, 0))
await nextTick()
}
function makeEventsResponse(
events: Partial<AuditLog>[],
overrides: Record<string, unknown> = {}
@@ -154,7 +137,7 @@ describe('UsageLogsTable', () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
mockWorkspaceApi.getBillingEvents.mockResolvedValue(mockEventsResponse)
mockBillingRouting.shouldUseWorkspaceBilling = false
mockFlags.teamWorkspacesEnabled = false
mockCustomerEventsService.formatEventType.mockImplementation(
(type: string) => {
switch (type) {
@@ -245,7 +228,7 @@ describe('UsageLogsTable', () => {
})
})
it('shows a localized fallback instead of a raw Error message', async () => {
it('shows error message when service throws', async () => {
mockCustomerEventsService.getMyEvents.mockRejectedValue(
new Error('Network error')
)
@@ -253,25 +236,7 @@ describe('UsageLogsTable', () => {
renderWithAutoRefresh()
await waitFor(() => {
expect(
screen.getByText(
'Something went wrong while loading activity. Please refresh and try again.'
)
).toBeInTheDocument()
})
expect(screen.queryByText('Network error')).not.toBeInTheDocument()
})
it('shows a localized fallback when the service reports no message', async () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(null)
mockCustomerEventsService.error.value = null
renderWithAutoRefresh()
await waitFor(() => {
expect(
screen.getByText('Failed to load activity. Please try again.')
).toBeInTheDocument()
expect(screen.getByText('Network error')).toBeInTheDocument()
})
})
@@ -376,8 +341,8 @@ describe('UsageLogsTable', () => {
})
describe('billing events source', () => {
it('uses workspaceApi.getBillingEvents on the workspace billing flow', async () => {
mockBillingRouting.shouldUseWorkspaceBilling = true
it('uses workspaceApi.getBillingEvents when teamWorkspacesEnabled is on', async () => {
mockFlags.teamWorkspacesEnabled = true
await renderLoaded()
@@ -387,90 +352,6 @@ describe('UsageLogsTable', () => {
})
expect(mockCustomerEventsService.getMyEvents).not.toHaveBeenCalled()
})
it('discards a stale legacy response when routing flips mid-fetch', async () => {
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
mockCustomerEventsService.getMyEvents.mockReturnValue(
new Promise((resolve) => {
resolveLegacy = resolve
})
)
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
makeEventsResponse([
{
event_id: 'workspace-1',
event_type: EventType.API_USAGE_COMPLETED,
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
createdAt: '2024-02-01T10:00:00Z'
}
])
)
renderWithAutoRefresh()
mockBillingRouting.shouldUseWorkspaceBilling = true
await waitFor(() => {
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
})
resolveLegacy(
makeEventsResponse([
{
event_id: 'legacy-1',
event_type: EventType.API_USAGE_COMPLETED,
params: { api_name: 'LegacyAPI', model: 'legacy-model' },
createdAt: '2024-01-01T10:00:00Z'
}
])
)
await flushMicrotasks()
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
expect(screen.queryByText('LegacyAPI')).not.toBeInTheDocument()
})
it('runs top-up completion telemetry for a superseded response', async () => {
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
mockCustomerEventsService.getMyEvents.mockReturnValue(
new Promise((resolve) => {
resolveLegacy = resolve
})
)
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
makeEventsResponse([
{
event_id: 'workspace-1',
event_type: EventType.API_USAGE_COMPLETED,
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
createdAt: '2024-02-01T10:00:00Z'
}
])
)
renderWithAutoRefresh()
mockBillingRouting.shouldUseWorkspaceBilling = true
await waitFor(() => {
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
})
const legacyResponse = makeEventsResponse([
{
event_id: 'legacy-1',
event_type: EventType.CREDIT_ADDED,
params: { amount: 1000 },
createdAt: '2024-01-01T10:00:00Z'
}
])
resolveLegacy(legacyResponse)
await waitFor(() => {
expect(mockTelemetry.checkForCompletedTopup).toHaveBeenCalledWith(
legacyResponse.events
)
})
})
})
describe('EventType integration', () => {

View File

@@ -96,11 +96,11 @@ import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import type { AuditLog } from '@/services/customerEventsService'
@@ -109,15 +109,14 @@ import {
useCustomerEventsService
} from '@/services/customerEventsService'
const { t } = useI18n()
const events = ref<AuditLog[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const customerEventService = useCustomerEventsService()
const { shouldUseWorkspaceBilling } = useBillingRouting()
const { flags } = useFeatureFlags()
const useBillingApi = computed(() => isCloud && flags.teamWorkspacesEnabled)
const pagination = ref({
page: 1,
@@ -140,12 +139,7 @@ const tooltipContentMap = computed(() => {
return map
})
// A billing-route flip can overlap two loads against different backends; only
// the latest may mutate state, so a superseded response is discarded.
let latestLoadToken = 0
const loadEvents = async () => {
const loadToken = ++latestLoadToken
loading.value = true
error.value = null
@@ -154,17 +148,10 @@ const loadEvents = async () => {
page: pagination.value.page,
limit: pagination.value.limit
}
const response = shouldUseWorkspaceBilling.value
const response = useBillingApi.value
? await workspaceApi.getBillingEvents(params)
: await customerEventService.getMyEvents(params)
// Completion telemetry must run even when a mid-checkout route flip
// supersedes this load, since legacy and workspace backends emit different
// top-up events and the winning fetch may not carry the completion yet.
useTelemetry()?.checkForCompletedTopup(response?.events)
if (loadToken !== latestLoadToken) return
if (response) {
if (response.events) {
events.value = response.events
@@ -178,25 +165,24 @@ const loadEvents = async () => {
pagination.value.limit = response.limit
}
if (response.total != null) {
if (response.total) {
pagination.value.total = response.total
}
if (response.totalPages != null) {
if (response.totalPages) {
pagination.value.totalPages = response.totalPages
}
// Check if a pending top-up has completed
useTelemetry()?.checkForCompletedTopup(response.events)
} else {
const legacyError = shouldUseWorkspaceBilling.value
? null
: customerEventService.error.value
error.value = legacyError || t('credits.loadEventsError')
error.value = customerEventService.error.value || 'Failed to load events'
}
} catch (err) {
if (loadToken !== latestLoadToken) return
error.value = t('credits.loadEventsUnknownError')
error.value = err instanceof Error ? err.message : 'Unknown error'
console.error('Error loading events:', err)
} finally {
if (loadToken === latestLoadToken) loading.value = false
loading.value = false
}
}
@@ -212,12 +198,6 @@ const refresh = async () => {
await loadEvents()
}
watch(shouldUseWorkspaceBilling, () => {
refresh().catch((error) => {
console.error('Error loading events:', error)
})
})
defineExpose({
refresh
})

View File

@@ -42,34 +42,22 @@ function withStrictMillisecondParser<T>(run: () => T): T {
}
const mockSubscription = vi.hoisted(() => ({
value: null as {
endDate: string | null
duration?: 'ANNUAL' | 'MONTHLY' | null
} | null
value: null as { endDate: string | null } | null
}))
const mockCancelSubscription = vi.hoisted(() => vi.fn())
const mockFetchStatus = vi.hoisted(() => vi.fn())
const mockCloseDialog = vi.hoisted(() => vi.fn())
const mockToastAdd = vi.hoisted(() => vi.fn())
const mockTier = vi.hoisted(() => ({ value: 'STANDARD' as string | null }))
const mockTrackCancellation = vi.hoisted(() => vi.fn())
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
cancelSubscription: mockCancelSubscription,
fetchStatus: mockFetchStatus,
subscription: mockSubscription,
tier: mockTier
subscription: mockSubscription
}))
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackSubscriptionCancellation: mockTrackCancellation
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: vi.fn(() => ({
closeDialog: mockCloseDialog
@@ -106,95 +94,6 @@ function renderComponent(props: { cancelAt?: string } = {}) {
describe('CancelSubscriptionDialogContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTier.value = 'STANDARD'
})
describe('cancellation telemetry', () => {
it('tracks flow_opened with tier and end date when the dialog mounts', () => {
mockSubscription.value = { endDate: '2026-08-01T00:00:00.000Z' }
renderComponent()
expect(mockTrackCancellation).toHaveBeenCalledWith('flow_opened', {
source: 'cancel_plan_menu',
current_tier: 'standard',
end_date: '2026-08-01T00:00:00.000Z'
})
})
it('tracks confirmed before the cancel request and no abandoned on success', async () => {
mockSubscription.value = null
mockCancelSubscription.mockResolvedValueOnce(undefined)
const { unmount } = renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /^cancel subscription$/i })
)
await waitFor(() => expect(mockCloseDialog).toHaveBeenCalled())
unmount()
expect(mockTrackCancellation).toHaveBeenCalledWith(
'confirmed',
expect.objectContaining({ current_tier: 'standard' })
)
expect(mockTrackCancellation).not.toHaveBeenCalledWith(
'abandoned',
expect.anything()
)
})
it('tracks confirmed and failed with message-carrying rejection values', async () => {
mockSubscription.value = null
mockCancelSubscription.mockRejectedValueOnce({ message: 'timed out' })
renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /^cancel subscription$/i })
)
await waitFor(() =>
expect(mockTrackCancellation).toHaveBeenCalledWith(
'failed',
expect.objectContaining({ error_message: 'timed out' })
)
)
expect(mockTrackCancellation).toHaveBeenCalledWith(
'confirmed',
expect.anything()
)
})
it('tracks abandoned when the user keeps the subscription', async () => {
mockSubscription.value = null
const { unmount } = renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /keep subscription/i })
)
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'cancel-subscription'
})
unmount()
expect(mockTrackCancellation).toHaveBeenCalledWith(
'abandoned',
expect.objectContaining({ current_tier: 'standard' })
)
expect(mockCancelSubscription).not.toHaveBeenCalled()
})
it('tracks abandoned when the dialog is dismissed by the shell', () => {
mockSubscription.value = null
const { unmount } = renderComponent()
mockTrackCancellation.mockClear()
unmount()
expect(mockTrackCancellation).toHaveBeenCalledWith(
'abandoned',
expect.objectContaining({ current_tier: 'standard' })
)
})
})
describe('cancel flow', () => {
@@ -239,35 +138,6 @@ describe('CancelSubscriptionDialogContent', () => {
expect.objectContaining({ severity: 'success' })
)
})
it('does not track cancellation failure when status refresh fails after cancellation succeeds', async () => {
mockSubscription.value = null
mockCancelSubscription.mockResolvedValueOnce(undefined)
mockFetchStatus.mockRejectedValueOnce(new Error('Refresh failed'))
const { unmount } = renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /^cancel subscription$/i })
)
await waitFor(() =>
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
)
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'cancel-subscription'
})
expect(
mockTrackCancellation.mock.calls.some(([stage]) => stage === 'failed')
).toBe(false)
unmount()
expect(mockTrackCancellation).not.toHaveBeenCalledWith(
'abandoned',
expect.anything()
)
})
})
describe('formattedEndDate fallbacks', () => {

View File

@@ -45,16 +45,13 @@
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useTelemetry } from '@/platform/telemetry'
import type { SubscriptionCancellationMetadata } from '@/platform/telemetry/types'
import { useDialogStore } from '@/stores/dialogStore'
import { parseIsoDateSafe } from '@/utils/dateTimeUtil'
import { getErrorMessage } from '@/utils/errorUtil'
const props = defineProps<{
cancelAt?: string
@@ -63,41 +60,9 @@ const props = defineProps<{
const { t } = useI18n()
const dialogStore = useDialogStore()
const toast = useToast()
const { cancelSubscription, fetchStatus, subscription, tier } =
useBillingContext()
const telemetry = useTelemetry()
const { cancelSubscription, fetchStatus, subscription } = useBillingContext()
const isLoading = ref(false)
const didCancelSucceed = ref(false)
function cancellationMetadata(): SubscriptionCancellationMetadata {
const endDate = props.cancelAt ?? subscription.value?.endDate
return {
source: 'cancel_plan_menu' as const,
current_tier: tier.value?.toLowerCase(),
...(subscription.value?.duration
? {
cycle:
subscription.value.duration === 'ANNUAL'
? ('yearly' as const)
: ('monthly' as const)
}
: {}),
...(endDate ? { end_date: endDate } : {})
}
}
onMounted(() => {
telemetry?.trackSubscriptionCancellation(
'flow_opened',
cancellationMetadata()
)
})
onUnmounted(() => {
if (didCancelSucceed.value || isLoading.value) return
telemetry?.trackSubscriptionCancellation('abandoned', cancellationMetadata())
})
const formattedEndDate = computed(() => {
const date = parseIsoDateSafe(props.cancelAt ?? subscription.value?.endDate)
@@ -119,37 +84,24 @@ function onClose() {
}
async function onConfirmCancel() {
telemetry?.trackSubscriptionCancellation('confirmed', cancellationMetadata())
isLoading.value = true
try {
await cancelSubscription()
} catch (error) {
const errorMessage = getErrorMessage(error)
telemetry?.trackSubscriptionCancellation('failed', {
...cancellationMetadata(),
error_message: errorMessage ?? String(error)
await fetchStatus()
dialogStore.closeDialog({ key: 'cancel-subscription' })
toast.add({
severity: 'success',
summary: t('subscription.cancelSuccess'),
life: 5000
})
} catch (error) {
toast.add({
severity: 'error',
summary: t('subscription.cancelDialog.failed'),
detail: errorMessage ?? t('g.unknownError')
detail: error instanceof Error ? error.message : t('g.unknownError')
})
} finally {
isLoading.value = false
return
}
didCancelSucceed.value = true
try {
await fetchStatus()
} catch {
// Cancellation already succeeded; stale local subscription status should not report failure.
}
dialogStore.closeDialog({ key: 'cancel-subscription' })
toast.add({
severity: 'success',
summary: t('subscription.cancelSuccess'),
life: 5000
})
isLoading.value = false
}
</script>

View File

@@ -31,7 +31,7 @@ import { getWidgetDefaultValue } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import { HideLayoutFieldKey, WidgetHeightKey } from '@/types/widgetTypes'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { GetNodeParentGroupKey } from '../shared'
import WidgetItem from './WidgetItem.vue'
@@ -135,7 +135,6 @@ watchDebounced(
onBeforeUnmount(() => draggableList.value?.dispose())
provide(HideLayoutFieldKey, true)
provide(WidgetHeightKey, 'h-7')
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()

View File

@@ -8,7 +8,16 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
type ModifiedWorkflow = Pick<ComfyWorkflow, 'path' | 'isModified'>
const mockAuthStore = vi.hoisted(() => ({
logout: vi.fn().mockResolvedValue(undefined)
logout: vi.fn().mockResolvedValue(undefined),
sendPasswordReset: vi.fn().mockResolvedValue(undefined),
initiateCreditPurchase: vi.fn(),
accessBillingPortal: vi.fn(),
fetchBalance: vi.fn(),
loginWithGoogle: vi.fn(),
loginWithGithub: vi.fn(),
login: vi.fn(),
register: vi.fn(),
updatePassword: vi.fn().mockResolvedValue(undefined)
}))
const mockToastStore = vi.hoisted(() => ({
@@ -29,6 +38,16 @@ const mockDialogService = vi.hoisted(() => ({
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const mockBillingContext = vi.hoisted(() => ({
isActiveSubscription: { value: false },
isFreeTier: { value: true },
type: { value: 'free' }
}))
const mockTelemetry = vi.hoisted(() => ({
startTopupTracking: vi.fn()
}))
const knownAuthErrorCodes = new Set([
'auth/invalid-credential',
'auth/email-already-in-use'
@@ -48,7 +67,7 @@ vi.mock('@/platform/distribution/types', () => ({
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => undefined)
useTelemetry: vi.fn(() => mockTelemetry)
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
@@ -72,11 +91,7 @@ vi.mock('@/stores/authStore', () => ({
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: { value: false },
isFreeTier: { value: true },
type: { value: 'free' }
}))
useBillingContext: vi.fn(() => mockBillingContext)
}))
vi.mock('@/composables/useErrorHandling', () => ({
@@ -97,6 +112,7 @@ describe('useAuthActions.logout', () => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockWorkflowStore.modifiedWorkflows = []
mockBillingContext.isActiveSubscription.value = false
})
it('logs out without prompting when no workflows are modified', async () => {
@@ -281,4 +297,158 @@ describe('useAuthActions.reportError', () => {
expect(mockToastErrorHandler).toHaveBeenCalledWith(networkError)
expect(mockToastStore.add).not.toHaveBeenCalled()
})
it('shows the unauthorized-domain access error message', () => {
const { reportError, accessError } = useAuthActions()
reportError(new FirebaseError('auth/unauthorized-domain', 'blocked'))
expect(accessError.value).toBe(true)
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'toastMessages.unauthorizedDomain'
})
})
})
describe('useAuthActions account actions', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockBillingContext.isActiveSubscription.value = false
vi.stubGlobal(
'open',
vi.fn(() => ({}))
)
})
it('sends password reset emails and shows success toast', async () => {
const { sendPasswordReset } = useAuthActions()
await sendPasswordReset('user@example.com')
expect(mockAuthStore.sendPasswordReset).toHaveBeenCalledWith(
'user@example.com'
)
expect(mockToastStore.add).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'success',
summary: 'auth.login.passwordResetSent'
})
)
})
it('does not purchase credits without an active subscription', async () => {
const { purchaseCredits } = useAuthActions()
await purchaseCredits(25)
expect(mockAuthStore.initiateCreditPurchase).not.toHaveBeenCalled()
expect(window.open).not.toHaveBeenCalled()
})
it('opens checkout and tracks top-up starts for credit purchases', async () => {
mockBillingContext.isActiveSubscription.value = true
mockAuthStore.initiateCreditPurchase.mockResolvedValueOnce({
checkout_url: 'https://checkout.example.test'
})
const { purchaseCredits } = useAuthActions()
await purchaseCredits(25)
expect(mockAuthStore.initiateCreditPurchase).toHaveBeenCalledWith({
amount_micros: 25000000,
currency: 'usd'
})
expect(mockTelemetry.startTopupTracking).toHaveBeenCalledOnce()
expect(window.open).toHaveBeenCalledWith(
'https://checkout.example.test',
'_blank'
)
})
it('throws when credit checkout URL is missing', async () => {
mockBillingContext.isActiveSubscription.value = true
mockAuthStore.initiateCreditPurchase.mockResolvedValueOnce({})
const { purchaseCredits } = useAuthActions()
await expect(purchaseCredits(10)).rejects.toThrow(
'toastMessages.failedToPurchaseCredits'
)
})
it('opens the billing portal in a new tab by default', async () => {
mockAuthStore.accessBillingPortal.mockResolvedValueOnce({
billing_portal_url: 'https://billing.example.test'
})
const { accessBillingPortal } = useAuthActions()
await expect(accessBillingPortal('pro')).resolves.toBe(true)
expect(mockAuthStore.accessBillingPortal).toHaveBeenCalledWith('pro')
expect(window.open).toHaveBeenCalledWith(
'https://billing.example.test',
'_blank'
)
})
it('throws when billing portal URL is missing', async () => {
mockAuthStore.accessBillingPortal.mockResolvedValueOnce({})
const { accessBillingPortal } = useAuthActions()
await expect(accessBillingPortal()).rejects.toThrow(
'toastMessages.failedToAccessBillingPortal'
)
})
it('delegates balance and sign-in methods to the auth store', async () => {
mockAuthStore.fetchBalance.mockResolvedValueOnce({ balance: 12 })
mockAuthStore.loginWithGoogle.mockResolvedValueOnce('google')
mockAuthStore.loginWithGithub.mockResolvedValueOnce('github')
mockAuthStore.login.mockResolvedValueOnce('email')
mockAuthStore.register.mockResolvedValueOnce('registered')
const actions = useAuthActions()
await expect(actions.fetchBalance()).resolves.toEqual({ balance: 12 })
await expect(actions.signInWithGoogle({ isNewUser: true })).resolves.toBe(
'google'
)
await expect(actions.signInWithGithub({ isNewUser: false })).resolves.toBe(
'github'
)
await expect(actions.signInWithEmail('u@example.com', 'pw')).resolves.toBe(
'email'
)
await expect(
actions.signUpWithEmail('u@example.com', 'pw', 'turnstile')
).resolves.toBe('registered')
expect(mockAuthStore.loginWithGoogle).toHaveBeenCalledWith({
isNewUser: true
})
expect(mockAuthStore.loginWithGithub).toHaveBeenCalledWith({
isNewUser: false
})
expect(mockAuthStore.login).toHaveBeenCalledWith('u@example.com', 'pw')
expect(mockAuthStore.register).toHaveBeenCalledWith(
'u@example.com',
'pw',
'turnstile'
)
})
it('updates passwords and shows success toast', async () => {
const { updatePassword } = useAuthActions()
await updatePassword('new-password')
expect(mockAuthStore.updatePassword).toHaveBeenCalledWith('new-password')
expect(mockToastStore.add).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'success',
summary: 'auth.passwordUpdate.success'
})
)
})
})

View File

@@ -0,0 +1,191 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive } from 'vue'
import type { User as FirebaseUser } from 'firebase/auth'
import type { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
type FirebaseUserMock = Pick<
FirebaseUser,
'uid' | 'displayName' | 'email' | 'photoURL'
> & {
providerData: Array<Pick<FirebaseUser['providerData'][number], 'providerId'>>
}
type ApiKeyUser = NonNullable<
ReturnType<typeof useApiKeyAuthStore>['currentUser']
>
const mockStores = vi.hoisted(() => ({
authStore: undefined as
| undefined
| {
currentUser: FirebaseUserMock | null
loading: boolean
tokenRefreshTrigger: number
},
apiKeyStore: undefined as
| undefined
| {
isAuthenticated: boolean
currentUser: ApiKeyUser | null
clearStoredApiKey: ReturnType<typeof vi.fn>
},
commandStore: undefined as
| undefined
| {
execute: ReturnType<typeof vi.fn>
}
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => mockStores.authStore
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => mockStores.apiKeyStore
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => mockStores.commandStore
}))
async function setup() {
vi.resetModules()
const authStore = reactive({
currentUser: null as FirebaseUserMock | null,
loading: false,
tokenRefreshTrigger: 0
})
const apiKeyStore = reactive({
isAuthenticated: false,
currentUser: null as ApiKeyUser | null,
clearStoredApiKey: vi.fn()
})
const commandStore = {
execute: vi.fn()
}
mockStores.authStore = authStore
mockStores.apiKeyStore = apiKeyStore
mockStores.commandStore = commandStore
const { useCurrentUser } = await import('./useCurrentUser')
return {
currentUser: useCurrentUser(),
authStore,
apiKeyStore,
commandStore
}
}
function firebaseUser(
providerId: string,
overrides: Partial<FirebaseUserMock> = {}
): FirebaseUserMock {
return {
uid: 'firebase-user',
displayName: 'Firebase User',
email: 'firebase@example.com',
photoURL: 'https://example.com/photo.png',
providerData: [{ providerId }],
...overrides
}
}
describe('useCurrentUser', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('uses API key user identity before firebase identity', async () => {
const { currentUser, authStore, apiKeyStore } = await setup()
expect(currentUser.isLoggedIn.value).toBe(false)
authStore.currentUser = firebaseUser('google.com')
apiKeyStore.isAuthenticated = true
apiKeyStore.currentUser = {
id: 'api-user',
name: 'API User',
email: 'api@example.com'
}
expect(currentUser.isLoggedIn.value).toBe(true)
expect(currentUser.isApiKeyLogin.value).toBe(true)
expect(currentUser.resolvedUserInfo.value).toEqual({ id: 'api-user' })
expect(currentUser.userDisplayName.value).toBe('API User')
expect(currentUser.userEmail.value).toBe('api@example.com')
expect(currentUser.userPhotoUrl.value).toBeNull()
expect(currentUser.providerName.value).toBe('Comfy API Key')
expect(currentUser.providerIcon.value).toBe('pi pi-key')
expect(currentUser.isEmailProvider.value).toBe(false)
})
it('maps firebase provider metadata to display fields', async () => {
const { currentUser, authStore } = await setup()
authStore.currentUser = firebaseUser('google.com')
expect(currentUser.providerName.value).toBe('Google')
expect(currentUser.providerIcon.value).toBe('pi pi-google')
expect(currentUser.userDisplayName.value).toBe('Firebase User')
expect(currentUser.userEmail.value).toBe('firebase@example.com')
expect(currentUser.userPhotoUrl.value).toBe('https://example.com/photo.png')
expect(currentUser.resolvedUserInfo.value).toEqual({ id: 'firebase-user' })
authStore.currentUser = firebaseUser('github.com')
expect(currentUser.providerName.value).toBe('GitHub')
expect(currentUser.providerIcon.value).toBe('pi pi-github')
authStore.currentUser = firebaseUser('password')
expect(currentUser.providerName.value).toBe('password')
expect(currentUser.providerIcon.value).toBe('pi pi-user')
expect(currentUser.isEmailProvider.value).toBe(true)
})
it('routes sign out through the active auth source', async () => {
const { currentUser, apiKeyStore, commandStore } = await setup()
apiKeyStore.isAuthenticated = true
apiKeyStore.currentUser = { id: 'api-user' }
await currentUser.handleSignOut()
expect(apiKeyStore.clearStoredApiKey).toHaveBeenCalledOnce()
apiKeyStore.isAuthenticated = false
await currentUser.handleSignOut()
expect(commandStore.execute).toHaveBeenCalledWith('Comfy.User.SignOut')
})
it('runs user lifecycle callbacks for resolve, token refresh, and logout', async () => {
const { currentUser, authStore } = await setup()
const resolved = vi.fn()
const tokenRefreshed = vi.fn()
const logout = vi.fn()
currentUser.onUserResolved(resolved)
currentUser.onTokenRefreshed(tokenRefreshed)
currentUser.onUserLogout(logout)
authStore.currentUser = firebaseUser('google.com')
await nextTick()
expect(resolved.mock.calls[0][0]).toEqual({ id: 'firebase-user' })
authStore.tokenRefreshTrigger += 1
await nextTick()
expect(tokenRefreshed).toHaveBeenCalledOnce()
authStore.currentUser = null
await nextTick()
expect(logout).toHaveBeenCalledOnce()
})
it('runs onUserResolved immediately when a user already exists', async () => {
const { currentUser, apiKeyStore } = await setup()
apiKeyStore.isAuthenticated = true
apiKeyStore.currentUser = { id: 'api-user' }
const resolved = vi.fn()
currentUser.onUserResolved(resolved)
expect(resolved.mock.calls[0][0]).toEqual({ id: 'api-user' })
})
})

View File

@@ -19,7 +19,6 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
const {
mockTeamWorkspacesEnabled,
mockConsolidatedBillingEnabled,
mockIsPersonal,
mockPlans,
mockPurchaseCredits,
@@ -27,7 +26,6 @@ const {
mockBillingStatus
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockConsolidatedBillingEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn(),
@@ -59,23 +57,11 @@ vi.mock('@/composables/useFeatureFlags', async () => {
teamWorkspacesEnabledRef.value = value
}
})
const consolidatedBillingEnabledRef = ref(
mockConsolidatedBillingEnabled.value
)
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
get: () => consolidatedBillingEnabledRef.value,
set: (value: boolean) => {
consolidatedBillingEnabledRef.value = value
}
})
return {
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
},
get consolidatedBillingEnabled() {
return mockConsolidatedBillingEnabled.value
}
}
})
@@ -165,7 +151,6 @@ describe('useBillingContext', () => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockTeamWorkspacesEnabled.value = false
mockConsolidatedBillingEnabled.value = false
mockIsPersonal.value = true
mockPlans.value = []
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
@@ -177,27 +162,16 @@ describe('useBillingContext', () => {
expect(type.value).toBe('legacy')
})
it('keeps personal on legacy when consolidated billing is disabled', () => {
it('selects workspace type for personal when team workspaces are enabled', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('legacy')
})
it('selects workspace type for personal when consolidated billing is enabled', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = true
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('workspace')
})
it('selects workspace type for team regardless of consolidated billing', () => {
it('selects workspace type for team when team workspaces are enabled', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockIsPersonal.value = false
const { type } = useBillingContext()
@@ -298,7 +272,6 @@ describe('useBillingContext', () => {
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
// Authenticated remote config resolves the flag on for the same workspace
mockConsolidatedBillingEnabled.value = true
mockTeamWorkspacesEnabled.value = true
await vi.waitFor(() => {
@@ -307,27 +280,9 @@ describe('useBillingContext', () => {
})
})
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockIsPersonal.value = true
const { type } = useBillingContext()
await nextTick()
expect(type.value).toBe('legacy')
mockConsolidatedBillingEnabled.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 on the consolidated billing flow', async () => {
it('mirrors subscription for personal workspaces when team workspaces are enabled', async () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = true
mockIsPersonal.value = true
const { initialize } = useBillingContext()
@@ -339,20 +294,6 @@ describe('useBillingContext', () => {
subscriptionPlan: null
})
})
it('never clobbers the list-derived store when a subscription is absent', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
const { initialize } = useBillingContext()
await initialize()
await nextTick()
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalledWith({
isSubscribed: false,
subscriptionPlan: null
})
})
})
describe('getMaxSeats', () => {

View File

@@ -1,6 +1,7 @@
import { computed, ref, shallowRef, toValue, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
KEY_TO_TIER,
getTierFeatures
@@ -17,10 +18,10 @@ import type {
BalanceInfo,
BillingActions,
BillingContext,
BillingType,
BillingState,
SubscriptionInfo
} from './types'
import { useBillingRouting } from './useBillingRouting'
import { useLegacyBilling } from './useLegacyBilling'
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
@@ -34,9 +35,8 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
* Unified billing context that selects the billing implementation by build/flag.
*
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
* - Team workspaces enabled: workspace billing via /api/billing/* for team
* workspaces, and for personal workspaces once consolidated billing is
* enabled; personal workspaces otherwise stay on legacy billing
* - 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.
@@ -69,7 +69,7 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
*/
function useBillingContextInternal(): BillingContext {
const store = useTeamWorkspaceStore()
const { type } = useBillingRouting()
const { flags } = useFeatureFlags()
const legacyBillingRef = shallowRef<(BillingState & BillingActions) | null>(
null
@@ -96,6 +96,16 @@ function useBillingContextInternal(): BillingContext {
const isLoading = ref(false)
const error = ref<string | null>(null)
/**
* Determines which billing type to use, keyed only on the build/flag:
* - Team workspaces feature disabled (OSS/Desktop): legacy (/customers)
* - Team workspaces feature enabled: workspace (/api/billing), for both
* personal (single-seat workspace) and team workspaces
*/
const type = computed<BillingType>(() =>
flags.teamWorkspacesEnabled ? 'workspace' : 'legacy'
)
const activeContext = computed(() =>
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
)
@@ -160,12 +170,9 @@ function useBillingContextInternal(): BillingContext {
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
}
// Sync subscription info to workspace store for display in workspace switcher.
// Subscribed means active AND not cancelled, so the delete button enables
// after cancellation, even before the period ends. A null subscription means
// "not loaded yet" (adapters are discarded on every workspace/type switch);
// skip it so the transient reinit gap can't clobber the list-derived baseline
// (personal workspaces and subscribed teams already read subscribed there).
// Sync subscription info to workspace store for display in workspace switcher
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
// This ensures the delete button is enabled after cancellation, even before the period ends
watch(
subscription,
(sub) => {
@@ -179,27 +186,24 @@ function useBillingContextInternal(): BillingContext {
{ immediate: true }
)
// Discarding the adapter instances forces a fresh fetch and lets an in-flight
// init detect that it was superseded (its captured adapter is no longer the
// active one), so a stale response can't resolve into a ready state for the
// wrong workspace.
function resetBillingState() {
legacyBillingRef.value = null
workspaceBillingRef.value = null
isInitialized.value = false
isLoading.value = false
error.value = null
}
// type flips when the team-workspaces or consolidated-billing flag resolves
// from authenticated config, swapping the active backend. Reset then reinit
// on every workspace-id or type change.
// type 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, () => type.value],
async ([newWorkspaceId]) => {
resetBillingState()
if (!newWorkspaceId) return
if (!newWorkspaceId) {
resetBillingState()
return
}
isInitialized.value = false
try {
await initialize()
} catch (err) {
@@ -212,20 +216,17 @@ function useBillingContextInternal(): BillingContext {
async function initialize(): Promise<void> {
if (isInitialized.value) return
const adapter = activeContext.value
isLoading.value = true
error.value = null
try {
await adapter.initialize()
if (activeContext.value !== adapter) return
await activeContext.value.initialize()
isInitialized.value = true
} catch (err) {
if (activeContext.value !== adapter) return
error.value =
err instanceof Error ? err.message : 'Failed to initialize billing'
throw err
} finally {
if (activeContext.value === adapter) isLoading.value = false
isLoading.value = false
}
}

View File

@@ -1,99 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBillingRouting } from './useBillingRouting'
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
mockFlags: {
teamWorkspacesEnabled: false,
consolidatedBillingEnabled: false
},
mockActiveWorkspace: {
value: null as { id: string; type: 'personal' | 'team' } | null
}
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: mockFlags })
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
get activeWorkspace() {
return mockActiveWorkspace.value
}
})
}))
const personal = { id: 'w-personal', type: 'personal' as const }
const team = { id: 'w-team', type: 'team' as const }
describe('useBillingRouting', () => {
beforeEach(() => {
mockFlags.teamWorkspacesEnabled = false
mockFlags.consolidatedBillingEnabled = false
mockActiveWorkspace.value = personal
})
it('uses legacy billing when team workspaces are disabled', () => {
mockFlags.teamWorkspacesEnabled = false
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
expect(type.value).toBe('legacy')
expect(shouldUseWorkspaceBilling.value).toBe(false)
})
it('keeps personal on legacy when consolidated billing is disabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = false
mockActiveWorkspace.value = personal
const { type } = useBillingRouting()
expect(type.value).toBe('legacy')
})
it('moves personal to workspace billing when consolidated billing is enabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockActiveWorkspace.value = personal
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
expect(type.value).toBe('workspace')
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = false
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
expect(type.value).toBe('workspace')
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
expect(type.value).toBe('workspace')
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('defaults to legacy while the workspace has not loaded', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockActiveWorkspace.value = null
const { type } = useBillingRouting()
expect(type.value).toBe('legacy')
})
})

View File

@@ -1,36 +0,0 @@
import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type { BillingType } from './types'
/**
* Selects the billing backend for the active workspace: legacy user-scoped
* (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
*/
export function useBillingRouting() {
const { flags } = useFeatureFlags()
const workspaceStore = useTeamWorkspaceStore()
const type = computed<BillingType>(() => {
if (!flags.teamWorkspacesEnabled) return 'legacy'
// An unloaded workspace has no type yet; stay legacy so bootstrap never
// eagerly routes to workspace billing.
const workspaceType = workspaceStore.activeWorkspace?.type
if (!workspaceType) return 'legacy'
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
return 'legacy'
}
return 'workspace'
})
const shouldUseWorkspaceBilling = computed(() => type.value === 'workspace')
return { type, shouldUseWorkspaceBilling }
}

View File

@@ -6,12 +6,6 @@ import {
useFeatureFlags
} from '@/composables/useFeatureFlags'
import * as distributionTypes from '@/platform/distribution/types'
import {
cachedConsolidatedBillingEnabled,
cachedTeamWorkspacesEnabled,
remoteConfig,
remoteConfigState
} from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
// Mock the API module
@@ -225,86 +219,6 @@ describe('useFeatureFlags', () => {
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
})
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
vi.mocked(distributionTypes).isCloud = false
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
const { flags } = useFeatureFlags()
expect(flags.consolidatedBillingEnabled).toBe(true)
})
it('consolidatedBillingEnabled is false off-cloud even without an override', () => {
vi.mocked(distributionTypes).isCloud = false
const { flags } = useFeatureFlags()
expect(flags.consolidatedBillingEnabled).toBe(false)
})
})
describe('auth-gated flags on cloud', () => {
beforeEach(() => {
vi.mocked(distributionTypes).isCloud = true
remoteConfigState.value = 'unloaded'
remoteConfig.value = {}
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
localStorage.clear()
})
afterEach(() => {
vi.mocked(distributionTypes).isCloud = false
remoteConfigState.value = 'unloaded'
remoteConfig.value = {}
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
localStorage.clear()
})
it('returns the cached session value during the auth window', () => {
cachedTeamWorkspacesEnabled.value = false
cachedConsolidatedBillingEnabled.value = true
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
expect(flags.consolidatedBillingEnabled).toBe(true)
})
it('defaults to false during the auth window when nothing is cached', () => {
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
expect(flags.consolidatedBillingEnabled).toBe(false)
})
it('prefers authenticated remoteConfig over the server feature fallback', () => {
remoteConfigState.value = 'authenticated'
remoteConfig.value = {
team_workspaces_enabled: true,
consolidated_billing_enabled: true
}
vi.mocked(api.getServerFeature).mockReturnValue(false)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(flags.consolidatedBillingEnabled).toBe(true)
})
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
remoteConfigState.value = 'authenticated'
remoteConfig.value = {}
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(flags.consolidatedBillingEnabled).toBe(true)
})
})
describe('signupTurnstileMode', () => {

View File

@@ -1,9 +1,7 @@
import { computed, reactive, readonly } from 'vue'
import type { Ref } from 'vue'
import { isCloud, isNightly } from '@/platform/distribution/types'
import {
cachedConsolidatedBillingEnabled,
cachedTeamWorkspacesEnabled,
isAuthenticatedConfigLoaded,
remoteConfig
@@ -32,7 +30,6 @@ export enum ServerFeatureFlag {
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
SIGNUP_TURNSTILE = 'signup_turnstile'
}
@@ -49,26 +46,6 @@ function resolveFlag<T>(
return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue)
}
/**
* Resolves a per-user, Cloud-only flag that selects backend behavior. Off the
* Cloud build it is always false; during the auth window it falls back to the
* cached session value so anonymous bootstrap config cannot route the user to
* the wrong backend before authenticated config confirms the flag.
*/
function resolveAuthGatedFlag(
flagKey: string,
remoteConfigValue: boolean | undefined,
cachedValue: Ref<boolean | undefined>
): boolean {
const override = getDevOverride<boolean>(flagKey)
if (override !== undefined) return override
if (!isCloud) return false
if (!isAuthenticatedConfigLoaded.value) return cachedValue.value ?? false
return remoteConfigValue ?? api.getServerFeature(flagKey, false)
}
/**
* Composable for reactive access to server-side feature flags
*/
@@ -127,10 +104,18 @@ export function useFeatureFlags() {
* and prevents race conditions during initialization.
*/
get teamWorkspacesEnabled() {
return resolveAuthGatedFlag(
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED,
remoteConfig.value.team_workspaces_enabled,
cachedTeamWorkspacesEnabled
const override = getDevOverride<boolean>(
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED
)
if (override !== undefined) return override
if (!isCloud) return false
if (!isAuthenticatedConfigLoaded.value)
return cachedTeamWorkspacesEnabled.value ?? false
return (
remoteConfig.value.team_workspaces_enabled ??
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
)
},
get userSecretsEnabled() {
@@ -190,18 +175,6 @@ export function useFeatureFlags() {
false
)
},
/**
* Whether personal workspaces use the consolidated (workspace-scoped)
* billing flow. While false (default), personal workspaces stay on the
* legacy per-user billing flow; team workspaces are unaffected.
*/
get consolidatedBillingEnabled() {
return resolveAuthGatedFlag(
ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED,
remoteConfig.value.consolidated_billing_enabled,
cachedConsolidatedBillingEnabled
)
},
get signupTurnstileMode() {
return resolveFlag(
ServerFeatureFlag.SIGNUP_TURNSTILE,

View File

@@ -260,6 +260,7 @@ useExtensionService().registerExtension({
if (!isLoad3dNode(selectedNode)) return
ComfyApp.copyToClipspace(selectedNode)
// @ts-expect-error clipspace_return_node is an extension property added at runtime
ComfyApp.clipspace_return_node = selectedNode
const props = { node: selectedNode }

View File

@@ -2494,7 +2494,6 @@ export class LGraph
// Deprecated - old schema version, links are arrays
if (Array.isArray(data.links)) {
for (const linkData of data.links) {
if (!linkData) continue
const link = LLink.createFromArray(linkData)
this._links.set(link.id, link)
}

Some files were not shown because too many files have changed in this diff Show More