Compare commits

..

7 Commits

Author SHA1 Message Date
huang47
2a510a8645 test: drop config-echo and default-state change detectors, derive registry route assertions from inputs 2026-07-06 12:05:52 -07:00
huang47
fc840f5d2e test: address review nitpicks on timing constants and reverse lookup assertion 2026-07-03 14:57:15 -07:00
huang47
09634f3c96 chore: revert unintended eslint config change 2026-07-03 13:50:53 -07:00
huang47
4cab820a30 test: replace fromAny/double-casts with type-safe alternatives in store tests
Fixes ESLint `no-double-type-assertions` and `no-fromAny` violations in
comfyRegistryStore, nodeDefStore, assetsStore, assetService, modelToNodeStore,
nodeOutputStore, and useMediaAssetActions tests. Uses fromPartial for plain
object types, direct construction for platform types (Response), real vue-i18n
instance instead of vi.mock, and const-unknown-then-cast for class instances
that lack structural overlap (LGraphNode, SubgraphNode, typeof LGraphNode).

Also widens modelToNodeStore and nodeOutputStore production types to accept
undefined/null inputs where tests revealed missing coverage.
2026-07-03 10:37:03 -07:00
huang47
c1dc9fe074 fix: widen nullable types to match existing defensive guards
- ModelNodeProvider constructor accepts undefined nodeDef since
  existing guards (!!provider.nodeDef, if (!provider.nodeDef)) already
  handle the absent-definition case for extension nodes
- getPacksByIds accepts (string | undefined)[] since the implementation
  already filters undefined ids before the API call
- setNodeOutputs, setOutputsByLocatorId, setNodeOutputsByExecutionId
  accept null node/outputs since existing null checks already guard them
2026-07-03 09:48:57 -07:00
huang47
98b31d1c71 test: cover asset and model stores 2026-07-02 14:31:02 -07:00
huang47
31d1d8d84d refactor: hoist test mocks and drop redundant spy for asset/model store coverage
Converts inline vi.fn() mocks in useMediaAssetActions.test.ts and
nodeOutputStore.test.ts into vi.hoisted() references so individual
tests can reconfigure them, and removes a spyOn override in
comfyRegistryStore.test.ts that masked the store's real null-ID
handling.
2026-07-02 14:30:26 -07:00
150 changed files with 4270 additions and 2005 deletions

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

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

View File

@@ -336,39 +336,6 @@ export default defineConfig([
'testing-library/no-debugging-utils': 'error'
}
},
{
files: ['**/*.test.ts'],
// browser_tests is excluded so this warn-level entry does not override the
// error-level no-restricted-syntax guards defined above for those paths
ignores: ['browser_tests/**'],
rules: {
'no-restricted-syntax': [
'warn',
{
selector: 'TSAsExpression > TSAsExpression.expression',
message:
'Double type assertion. Use fromPartial<T>() from @total-typescript/shoehorn instead.'
},
{
selector:
"ImportDeclaration[source.value='@total-typescript/shoehorn'] > ImportSpecifier[imported.name='fromAny']",
message: 'fromAny erases type checking. Use fromPartial<T>() instead.'
},
{
selector:
"CallExpression[callee.object.name='vi'][callee.property.name='mock'] > Literal[value='vue-i18n']",
message:
'Do not mock vue-i18n. Use a real createI18n instance (see src/components/searchbox/v2/__test__/testUtils.ts).'
},
{
selector:
"CallExpression[callee.object.name='vi'][callee.property.name='mock'] > ImportExpression > Literal[value='vue-i18n']",
message:
'Do not mock vue-i18n. Use a real createI18n instance (see src/components/searchbox/v2/__test__/testUtils.ts).'
}
]
}
},
{
files: ['scripts/**/*.js'],
languageOptions: {

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

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

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

@@ -101,7 +101,7 @@
"color": "Color",
"error": "Error",
"enter": "Enter",
"enterSubgraph": "Enter subgraph",
"enterSubgraph": "Enter Subgraph",
"inSubgraph": "in subgraph '{name}'",
"resizeFromBottomRight": "Resize from bottom-right corner",
"resizeFromTopRight": "Resize from top-right corner",
@@ -2484,8 +2484,6 @@
"model": "Model",
"added": "Added",
"accountInitialized": "Account initialized",
"loadEventsError": "Failed to load activity. Please try again.",
"loadEventsUnknownError": "Something went wrong while loading activity. Please refresh and try again.",
"eventTypes": {
"creditAdded": "Credits Added",
"accountCreated": "Account Created",

View File

@@ -426,6 +426,10 @@
"Comfy_Validation_Workflows": {
"name": "Validate workflows"
},
"Comfy_VueNodes_AutoScaleLayout": {
"name": "Auto-scale layout (Nodes 2.0)",
"tooltip": "Automatically scale node positions when switching to Nodes 2.0 rendering to prevent overlap"
},
"Comfy_VueNodes_Enabled": {
"name": "Modern Node Design (Nodes 2.0)",
"tooltip": "Modern: DOM-based rendering with enhanced interactivity, native browser features, and updated visual design. Classic: Traditional canvas rendering."

View File

@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetsApi } from './useAssetsApi'
const mockAssetsStore = vi.hoisted(() => ({
inputAssets: [] as AssetItem[],
historyAssets: [] as AssetItem[],
inputLoading: false,
historyLoading: false,
inputError: null as string | null,
historyError: null as string | null,
hasMoreHistory: false,
isLoadingMore: false,
updateInputs: vi.fn(),
updateHistory: vi.fn(),
loadMoreHistory: vi.fn()
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => mockAssetsStore
}))
function createAsset(id: string): AssetItem {
return {
id,
name: `${id}.png`,
size: 1,
created_at: '2026-01-01T00:00:00Z',
tags: ['input']
}
}
describe('useAssetsApi', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAssetsStore.inputAssets = [createAsset('input-1')]
mockAssetsStore.historyAssets = [createAsset('history-1')]
mockAssetsStore.inputLoading = true
mockAssetsStore.historyLoading = false
mockAssetsStore.inputError = 'input-error'
mockAssetsStore.historyError = 'history-error'
mockAssetsStore.hasMoreHistory = true
mockAssetsStore.isLoadingMore = true
})
it('uses input assets and refreshes inputs', async () => {
const api = useAssetsApi('input')
expect(api.media.value).toEqual([createAsset('input-1')])
expect(api.loading.value).toBe(true)
expect(api.error.value).toBe('input-error')
expect(api.hasMore.value).toBe(false)
expect(api.isLoadingMore.value).toBe(false)
await expect(api.fetchMediaList()).resolves.toEqual([
createAsset('input-1')
])
await expect(api.refresh()).resolves.toEqual([createAsset('input-1')])
await api.loadMore()
expect(mockAssetsStore.updateInputs).toHaveBeenCalledTimes(2)
expect(mockAssetsStore.updateHistory).not.toHaveBeenCalled()
expect(mockAssetsStore.loadMoreHistory).not.toHaveBeenCalled()
})
it('uses output history and loads more history', async () => {
const api = useAssetsApi('output')
expect(api.media.value).toEqual([createAsset('history-1')])
expect(api.loading.value).toBe(false)
expect(api.error.value).toBe('history-error')
expect(api.hasMore.value).toBe(true)
expect(api.isLoadingMore.value).toBe(true)
await expect(api.fetchMediaList()).resolves.toEqual([
createAsset('history-1')
])
await api.loadMore()
expect(mockAssetsStore.updateHistory).toHaveBeenCalledOnce()
expect(mockAssetsStore.updateInputs).not.toHaveBeenCalled()
expect(mockAssetsStore.loadMoreHistory).toHaveBeenCalledOnce()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, ref } from 'vue'
@@ -14,6 +15,7 @@ vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetMetadata: vi.fn(),
uploadAssetAsync: vi.fn(),
uploadAssetFromBase64: vi.fn(),
uploadAssetPreviewImage: vi.fn()
}
}))
@@ -248,6 +250,81 @@ describe('useUploadModelWizard', () => {
expect(wizard.selectedModelType.value).toBe('checkpoints')
})
it('does not fetch metadata until the URL matches a supported source', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const wizard = setupUploadModelWizard(modelTypes)
expect(wizard.canFetchMetadata.value).toBe(false)
await wizard.fetchMetadata()
expect(assetService.getAssetMetadata).not.toHaveBeenCalled()
expect(wizard.currentStep.value).toBe(1)
})
it('decodes metadata filenames and selects a matching model type tag', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.getAssetMetadata).mockResolvedValue({
content_length: 100,
final_url: 'https://huggingface.co/org/model',
filename: '%E6%A8%A1%E5%9E%8B.safetensors',
name: '%E5%90%8D%E7%A8%B1',
tags: ['checkpoints'],
preview_image: 'data:image/png;base64,abc'
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = ' https://huggingface.co/org/model '
await wizard.fetchMetadata()
expect(wizard.currentStep.value).toBe(2)
expect(wizard.wizardData.value.url).toBe('https://huggingface.co/org/model')
expect(wizard.wizardData.value.name).toBe('模型.safetensors')
expect(wizard.wizardData.value.previewImage).toBe(
'data:image/png;base64,abc'
)
expect(wizard.selectedModelType.value).toBe('checkpoints')
})
it('keeps metadata text when percent decoding fails', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.getAssetMetadata).mockResolvedValue({
content_length: 100,
final_url: 'https://civitai.com/models/12345',
filename: '%E0%A4%A',
name: '%E0%A4%A',
tags: []
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
await wizard.fetchMetadata()
expect(wizard.currentStep.value).toBe(2)
expect(wizard.wizardData.value.name).toBe('%E0%A4%A')
expect(wizard.selectedModelType.value).toBeUndefined()
})
it('uses the fallback metadata error for non-error rejections', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.getAssetMetadata).mockRejectedValue('no metadata')
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
await wizard.fetchMetadata()
expect(wizard.currentStep.value).toBe(1)
expect(wizard.uploadError.value).toBe(
'Failed to retrieve metadata. Please check the link and try again.'
)
})
it('uploads with the required model type even if selection changes', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
@@ -279,6 +356,382 @@ describe('useUploadModelWizard', () => {
expect(result?.modelType).toBe('checkpoints')
})
it('clears upload errors and type mismatches when the URL changes', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-lora',
name: 'model.safetensors',
tags: ['models', 'loras']
}
})
const wizard = setupUploadModelWizard(
ref([
{ name: 'Checkpoint', value: 'checkpoints' },
{ name: 'LoRA', value: 'loras' }
]),
{ requiredModelType: 'checkpoints' }
)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
await wizard.uploadModel()
expect(wizard.uploadTypeMismatch.value).not.toBeNull()
wizard.wizardData.value.url = 'https://civitai.com/models/54321'
await nextTick()
expect(wizard.uploadError.value).toBe('')
expect(wizard.uploadTypeMismatch.value).toBeNull()
})
it('returns null while another upload is in progress', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
type UploadResult = Awaited<
ReturnType<typeof assetService.uploadAssetAsync>
>
let resolveUpload!: (value: UploadResult) => void
vi.mocked(assetService.uploadAssetAsync).mockReturnValue(
new Promise<UploadResult>((resolve) => {
resolveUpload = resolve
})
)
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
const firstUpload = wizard.uploadModel()
await nextTick()
await expect(wizard.uploadModel()).resolves.toBeNull()
resolveUpload({
type: 'sync',
asset: {
id: 'asset-1',
name: 'model.safetensors',
tags: ['models', 'checkpoints']
}
})
await expect(firstUpload).resolves.toEqual(
expect.objectContaining({ status: 'success' })
)
})
it('returns null when no model type is selected', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
const result = await wizard.uploadModel()
expect(result).toBeNull()
expect(assetService.uploadAssetAsync).not.toHaveBeenCalled()
})
it('reports an upload error when no valid source is detected', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://example.com/model'
wizard.selectedModelType.value = 'checkpoints'
const result = await wizard.uploadModel()
expect(result).toBeNull()
expect(assetService.uploadAssetAsync).not.toHaveBeenCalled()
})
it('uploads preview images and passes the preview id to the model upload', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetFromBase64).mockResolvedValue(
fromPartial({ id: 'preview-1' })
)
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-1',
name: 'model.safetensors',
tags: ['models', 'checkpoints']
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.wizardData.value.metadata = {
content_length: 100,
final_url: 'https://civitai.com/models/12345',
filename: 'model.safetensors'
}
wizard.wizardData.value.previewImage = 'data:image/jpeg;base64,abc'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
expect(assetService.uploadAssetFromBase64).toHaveBeenCalledWith({
data: 'data:image/jpeg;base64,abc',
name: 'model_preview.jpg',
tags: ['preview']
})
expect(assetService.uploadAssetAsync).toHaveBeenCalledWith(
expect.objectContaining({ preview_id: 'preview-1' })
)
})
it('continues model upload when preview upload fails', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetFromBase64).mockRejectedValue(
new Error('preview failed')
)
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-1',
name: 'model.safetensors',
tags: ['models', 'checkpoints']
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.wizardData.value.metadata = {
content_length: 100,
final_url: 'https://civitai.com/models/12345',
name: 'model'
}
wizard.wizardData.value.previewImage = 'data:image/webp;base64,abc'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
expect(assetService.uploadAssetAsync).toHaveBeenCalledWith(
expect.objectContaining({ preview_id: undefined })
)
expect(wizard.uploadStatus.value).toBe('success')
})
it('treats an already completed async upload as success', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'async',
task: {
task_id: 'task-complete',
status: 'completed',
message: 'Download complete'
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.wizardData.value.metadata = {
content_length: 100,
final_url: 'https://civitai.com/models/12345',
filename: 'queued.safetensors'
}
wizard.selectedModelType.value = 'checkpoints'
const result = await wizard.uploadModel()
expect(result).toEqual({
filename: 'queued.safetensors',
modelType: 'checkpoints',
status: 'success'
})
expect(wizard.uploadStatus.value).toBe('success')
})
it('cleans up an immediately resolved async watcher', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const { useAssetDownloadStore } =
await import('@/stores/assetDownloadStore')
const assetDownloadStore = useAssetDownloadStore()
assetDownloadStore.trackDownload(
'task-ready',
'checkpoints',
'ready.safetensors'
)
const { api } = await import('@/scripts/api')
const handler = vi
.mocked(api.addEventListener)
.mock.calls.find((c) => c[0] === 'asset_download')?.[1] as
| ((e: CustomEvent) => void)
| undefined
expect(handler).toBeDefined()
handler!(
new CustomEvent('asset_download', {
detail: {
task_id: 'task-ready',
asset_id: 'asset-ready',
asset_name: 'ready.safetensors',
bytes_total: 100,
bytes_downloaded: 100,
progress: 100,
status: 'completed' as const
}
})
)
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'async',
task: {
task_id: 'task-ready',
status: 'created',
message: 'Download queued'
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
await nextTick()
expect(wizard.uploadStatus.value).toBe('success')
})
it('uses the default failed-download message when no error is available', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'async',
task: {
task_id: 'task-fallback-fail',
status: 'created',
message: 'Download queued'
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
const { api } = await import('@/scripts/api')
const handler = vi
.mocked(api.addEventListener)
.mock.calls.find((c) => c[0] === 'asset_download')?.[1] as
| ((e: CustomEvent) => void)
| undefined
expect(handler).toBeDefined()
handler!(
new CustomEvent('asset_download', {
detail: {
task_id: 'task-fallback-fail',
asset_id: '',
asset_name: '',
bytes_total: 1000,
bytes_downloaded: 500,
progress: 50,
status: 'failed' as const
}
})
)
await nextTick()
expect(wizard.uploadStatus.value).toBe('error')
expect(wizard.uploadError.value).toBe('assetBrowser.downloadFailed')
})
it('uses fallback labels for unknown mismatch types', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-unknown',
name: 'model.safetensors',
tags: ['models']
}
})
const wizard = setupUploadModelWizard(modelTypes, {
requiredModelType: 'unknown-required'
})
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
const result = await wizard.uploadModel()
expect(result).toBeNull()
expect(wizard.uploadTypeMismatch.value).toEqual({
importedModelType: undefined,
importedModelTypeLabel: undefined,
requiredModelType: 'unknown-required',
requiredModelTypeLabel: 'unknown-required'
})
})
it('uses a generic upload error for non-error upload failures', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockRejectedValue('failed')
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
const result = await wizard.uploadModel()
expect(result).toBeNull()
expect(wizard.uploadStatus.value).toBe('error')
expect(wizard.uploadError.value).toBe('Failed to upload model')
})
it('navigates backward only after the first step', () => {
const wizard = setupUploadModelWizard(modelTypes)
wizard.goToPreviousStep()
expect(wizard.currentStep.value).toBe(1)
wizard.currentStep.value = 3
wizard.goToPreviousStep()
expect(wizard.currentStep.value).toBe(2)
})
it('resets wizard state and cancels pending async status watching', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'async',
task: {
task_id: 'task-reset',
status: 'created',
message: 'Download queued'
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.wizardData.value.name = 'Model'
wizard.wizardData.value.tags = ['checkpoints']
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
wizard.resetWizard()
expect(wizard.currentStep.value).toBe(1)
expect(wizard.uploadStatus.value).toBeUndefined()
expect(wizard.uploadError.value).toBe('')
expect(wizard.wizardData.value).toEqual({
url: '',
name: '',
tags: []
})
expect(wizard.selectedModelType.value).toBeUndefined()
})
it('returns the synced asset filename for sync imports', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')

View File

@@ -12,6 +12,7 @@ import { api } from '@/scripts/api'
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
const mockSettingStoreGet = vi.hoisted(() => vi.fn(() => false))
const mockGetCategoryForNodeType = vi.hoisted(() => vi.fn())
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
@@ -33,7 +34,7 @@ vi.mock('@/stores/modelToNodeStore', () => {
return {
useModelToNodeStore: vi.fn(() => ({
getRegisteredNodeTypes: () => registeredNodeTypes,
getCategoryForNodeType: vi.fn()
getCategoryForNodeType: mockGetCategoryForNodeType
}))
}
})
@@ -60,11 +61,9 @@ function buildResponse(
body: unknown,
init: { ok?: boolean; status?: number } = {}
): Response {
return {
ok: init.ok ?? true,
status: init.status ?? 200,
json: vi.fn().mockResolvedValue(body)
} as unknown as Response
return new Response(body == null ? null : JSON.stringify(body), {
status: init.status ?? 200
})
}
function buildAssetListResponse(
@@ -172,6 +171,26 @@ describe(assetService.getAssetMetadata, () => {
).rejects.toThrow('File too large')
})
it('falls back to the unknown localized message for unrecognized error codes', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({ code: 'NOT_A_REAL_CODE' }, { ok: false, status: 400 })
)
await expect(
assetService.getAssetMetadata('https://example.com/model.safetensors')
).rejects.toThrow('Unknown error')
})
it('falls back to unknown when error JSON cannot be parsed', async () => {
fetchApiMock.mockResolvedValueOnce(
new Response('not valid json', { status: 400 })
)
await expect(
assetService.getAssetMetadata('https://example.com/model.safetensors')
).rejects.toThrow('Unknown error')
})
it('throws a localized message when validation reports is_valid=false', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({
@@ -189,6 +208,20 @@ describe(assetService.getAssetMetadata, () => {
).rejects.toThrow('Unsafe virus scan')
})
it('falls back to unknown when validation errors are absent', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({
content_length: 100,
final_url: 'https://example.com/model.safetensors',
validation: { is_valid: false }
})
)
await expect(
assetService.getAssetMetadata('https://example.com/model.safetensors')
).rejects.toThrow('Unknown error')
})
it('encodes the URL in the query string', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({
@@ -208,12 +241,115 @@ describe(assetService.getAssetMetadata, () => {
})
})
describe(assetService.getAssetsForNodeType, () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetCategoryForNodeType.mockReset()
})
it('returns an empty list for invalid node types without fetching', async () => {
await expect(assetService.getAssetsForNodeType('')).resolves.toEqual([])
expect(fetchApiMock).not.toHaveBeenCalled()
})
it('returns an empty list when the node type has no asset category', async () => {
mockGetCategoryForNodeType.mockReturnValue(undefined)
await expect(
assetService.getAssetsForNodeType('UnknownNode')
).resolves.toEqual([])
expect(fetchApiMock).not.toHaveBeenCalled()
})
it('fetches category assets with default pagination', async () => {
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
const assets = [
validAsset({ id: 'ckpt-1', tags: ['models', 'checkpoints'] })
]
fetchApiMock.mockResolvedValueOnce(buildAssetListResponse(assets))
await expect(
assetService.getAssetsForNodeType('CheckpointLoaderSimple')
).resolves.toEqual(assets)
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('include_tags')).toBe('models,checkpoints')
expect(params.get('limit')).toBe('500')
expect(params.has('offset')).toBe(false)
})
it('passes positive offsets for category asset pagination', async () => {
mockGetCategoryForNodeType.mockReturnValue('loras')
fetchApiMock.mockResolvedValueOnce(buildAssetListResponse([]))
await assetService.getAssetsForNodeType('LoraLoader', {
limit: 25,
offset: 50
})
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('include_tags')).toBe('models,loras')
expect(params.get('limit')).toBe('25')
expect(params.get('offset')).toBe('50')
})
})
describe(assetService.getAssetDetails, () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('throws when the details response is not ok', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({}, { ok: false, status: 404 })
)
await expect(assetService.getAssetDetails('missing')).rejects.toThrow(
'Unable to load asset details for missing: Server returned 404'
)
})
it('throws when the details response is invalid', async () => {
fetchApiMock.mockResolvedValueOnce(buildResponse({ id: 'asset-1' }))
await expect(assetService.getAssetDetails('asset-1')).rejects.toThrow(
/Invalid asset response/
)
})
it('returns validated asset details', async () => {
const asset = validAsset({ id: 'asset-details' })
fetchApiMock.mockResolvedValueOnce(buildResponse(asset))
await expect(
assetService.getAssetDetails('asset-details')
).resolves.toEqual(asset)
})
})
describe(assetService.uploadAssetFromUrl, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateInputAssetsIncludingPublic()
})
it('throws when URL upload returns a non-ok response', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 500 })
)
await expect(
assetService.uploadAssetFromUrl({
url: 'https://example.com/input.png',
name: 'input.png'
})
).rejects.toThrow('Failed to upload asset')
})
it('does not invalidate cached input assets when the upload response is invalid', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -294,6 +430,61 @@ describe(assetService.uploadAssetFromBase64, () => {
expect(fetchApiMock).not.toHaveBeenCalled()
})
it('throws when base64 upload returns a non-ok response', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
try {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 507 })
)
await expect(
assetService.uploadAssetFromBase64({
data: 'data:text/plain;base64,aGVsbG8=',
name: 'input.txt'
})
).rejects.toThrow('Failed to upload asset from base64: 507')
} finally {
fetchSpy.mockRestore()
}
})
it('posts base64 uploads with tags and user metadata', async () => {
const uploadedAsset = {
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
created_new: false
}
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
try {
fetchApiMock.mockResolvedValueOnce(buildResponse(uploadedAsset))
const result = await assetService.uploadAssetFromBase64({
data: 'data:text/plain;base64,aGVsbG8=',
name: 'input.txt',
tags: ['input', 'mask'],
user_metadata: { source: 'paste' }
})
expect(result).toEqual(uploadedAsset)
const request = fetchApiMock.mock.calls[0]?.[1]
expect(request).toEqual(expect.objectContaining({ method: 'POST' }))
expect(request?.body).toBeInstanceOf(FormData)
const formData = request?.body
if (!(formData instanceof FormData)) {
throw new Error('Expected base64 upload body to be FormData')
}
expect(formData.get('tags')).toBe(JSON.stringify(['input', 'mask']))
expect(formData.get('user_metadata')).toBe(
JSON.stringify({ source: 'paste' })
)
} finally {
fetchSpy.mockRestore()
}
})
it('does not invalidate cached input assets when the upload response is invalid', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -355,6 +546,7 @@ describe(assetService.uploadAssetFromBase64, () => {
describe(assetService.uploadAssetAsync, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateInputAssetsIncludingPublic()
})
it('returns an async result when the server responds 202', async () => {
@@ -389,6 +581,64 @@ describe(assetService.uploadAssetAsync, () => {
asset: expect.objectContaining({ id: 'asset-2' })
})
})
it('throws when the async upload response is not ok', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 502 })
)
await expect(
assetService.uploadAssetAsync({
source_url: 'https://example.com/model.safetensors'
})
).rejects.toThrow('Failed to upload asset')
})
it('throws when an async upload task response is invalid', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({ task_id: 'task-1', status: 'waiting' }, { status: 202 })
)
await expect(
assetService.uploadAssetAsync({
source_url: 'https://example.com/model.safetensors'
})
).rejects.toThrow('Failed to parse async upload response')
})
it('throws when a sync upload asset response is invalid', async () => {
fetchApiMock.mockResolvedValueOnce(buildResponse({ id: 'asset-2' }))
await expect(
assetService.uploadAssetAsync({
source_url: 'https://example.com/model.safetensors'
})
).rejects.toThrow('Failed to parse sync upload response')
})
it('invalidates cached input assets for completed async input uploads', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(
buildResponse(
{ task_id: 'task-1', status: 'completed' },
{ ok: true, status: 202 }
)
)
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
await assetService.getInputAssetsIncludingPublic()
await assetService.uploadAssetAsync({
source_url: 'https://example.com/input.png',
tags: ['input']
})
const refreshed = await assetService.getInputAssetsIncludingPublic()
expect(refreshed).toEqual(freshAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(3)
})
})
describe(assetService.deleteAsset, () => {
@@ -416,6 +666,94 @@ describe(assetService.deleteAsset, () => {
})
})
describe(assetService.addAssetTags, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateInputAssetsIncludingPublic()
})
it('posts tags and returns the parsed tag operation result', async () => {
const result = { total_tags: ['input', 'mask'], added: ['mask'] }
fetchApiMock.mockResolvedValueOnce(buildResponse(result))
await expect(
assetService.addAssetTags('asset-1', ['mask'])
).resolves.toEqual(result)
expect(fetchApiMock).toHaveBeenCalledWith(
'/assets/asset-1/tags',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ tags: ['mask'] })
})
)
})
it('throws when adding tags fails', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 403 })
)
await expect(
assetService.addAssetTags('asset-1', ['mask'])
).rejects.toThrow(
'Unable to add tags to asset asset-1: Server returned 403'
)
})
it('throws when the add-tags response is invalid', async () => {
fetchApiMock.mockResolvedValueOnce(buildResponse({ added: ['mask'] }))
await expect(
assetService.addAssetTags('asset-1', ['mask'])
).rejects.toThrow()
})
})
describe(assetService.removeAssetTags, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateInputAssetsIncludingPublic()
})
it('deletes tags and returns the parsed tag operation result', async () => {
const result = { total_tags: ['input'], removed: ['mask'] }
fetchApiMock.mockResolvedValueOnce(buildResponse(result))
await expect(
assetService.removeAssetTags('asset-1', ['mask'])
).resolves.toEqual(result)
expect(fetchApiMock).toHaveBeenCalledWith(
'/assets/asset-1/tags',
expect.objectContaining({
method: 'DELETE',
body: JSON.stringify({ tags: ['mask'] })
})
)
})
it('throws when removing tags fails', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 404 })
)
await expect(
assetService.removeAssetTags('asset-1', ['mask'])
).rejects.toThrow(
'Unable to remove tags from asset asset-1: Server returned 404'
)
})
it('throws when the remove-tags response is invalid', async () => {
fetchApiMock.mockResolvedValueOnce(buildResponse({ removed: ['mask'] }))
await expect(
assetService.removeAssetTags('asset-1', ['mask'])
).rejects.toThrow()
})
})
describe(assetService.getAssetModelFolders, () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -481,6 +819,16 @@ describe(assetService.updateAsset, () => {
})
)
})
it('throws when the update response is not ok', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 409 })
)
await expect(
assetService.updateAsset('asset-1', { name: 'renamed.safetensors' })
).rejects.toThrow('Unable to update asset asset-1: Server returned 409')
})
})
describe(assetService.getAssetsByTag, () => {
@@ -515,6 +863,21 @@ describe(assetService.getAssetsByTag, () => {
expect(params.get('include_tags')).toBe('input')
expect(params.get('exclude_tags')).toBe(MISSING_TAG)
})
it('forwards explicit public filtering and offset pagination', async () => {
fetchApiMock.mockResolvedValueOnce(buildAssetListResponse([]))
await assetService.getAssetsByTag('input', false, {
limit: 30,
offset: 60
})
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('include_public')).toBe('false')
expect(params.get('limit')).toBe('30')
expect(params.get('offset')).toBe('60')
})
})
describe(assetService.getAllAssetsByTag, () => {
@@ -562,6 +925,31 @@ describe(assetService.getAllAssetsByTag, () => {
expect(secondParams.has('offset')).toBe(false)
})
it('uses the default page size when limit is not positive', async () => {
fetchApiMock.mockResolvedValueOnce(buildAssetListResponse([]))
await expect(
assetService.getAllAssetsByTag('input', true, { limit: 0 })
).resolves.toEqual([])
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('limit')).toBe('500')
})
it('throws before fetching when the pagination signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
await expect(
assetService.getAllAssetsByTag('input', true, {
signal: controller.signal
})
).rejects.toMatchObject({ name: 'AbortError' })
expect(fetchApiMock).not.toHaveBeenCalled()
})
it('honors has_more when walking tagged asset pages', async () => {
fetchApiMock
.mockResolvedValueOnce(
@@ -703,6 +1091,75 @@ describe(assetService.getAllAssetsByTag, () => {
})
})
describe(assetService.createAssetExport, () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('posts export options and returns the export task', async () => {
const task = { task_id: 'export-1', status: 'created', message: 'queued' }
fetchApiMock.mockResolvedValueOnce(buildResponse(task))
await expect(
assetService.createAssetExport({
asset_ids: ['asset-1'],
include_previews: true
})
).resolves.toEqual(task)
expect(fetchApiMock).toHaveBeenCalledWith(
'/assets/export',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
asset_ids: ['asset-1'],
include_previews: true
})
})
)
})
it('throws when creating an export fails', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 503 })
)
await expect(
assetService.createAssetExport({ asset_ids: ['asset-1'] })
).rejects.toThrow('Failed to create asset export: 503')
})
})
describe(assetService.getExportDownloadUrl, () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns the export download URL', async () => {
const download = {
url: 'https://example.com/export.zip',
expires_at: '2026-07-01T00:00:00Z'
}
fetchApiMock.mockResolvedValueOnce(buildResponse(download))
await expect(
assetService.getExportDownloadUrl('export.zip')
).resolves.toEqual(download)
expect(fetchApiMock).toHaveBeenCalledWith('/assets/exports/export.zip')
})
it('throws when export download URL lookup fails', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 404 })
)
await expect(
assetService.getExportDownloadUrl('missing.zip')
).rejects.toThrow('Failed to get export download URL: 404')
})
})
describe(assetService.getInputAssetsIncludingPublic, () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -729,6 +1186,17 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
expect(params.get('limit')).toBe('500')
})
it('throws before starting a shared request when the caller signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
await expect(
assetService.getInputAssetsIncludingPublic(controller.signal)
).rejects.toMatchObject({ name: 'AbortError' })
expect(fetchApiMock).not.toHaveBeenCalled()
})
it('fetches fresh input assets after explicit invalidation', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetType } from '@/platform/assets/utils/assetTypeUtil'
function asset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-1',
name: 'image.png',
preview_url: '',
tags: [],
created_at: '',
updated_at: '',
size: 0,
mime_type: 'image/png',
user_metadata: {},
...overrides
} as AssetItem
}
describe('getAssetType', () => {
it('prefers the preview URL type over tags', () => {
expect(
getAssetType(
asset({
preview_url: '/api/view?filename=image.png&type=temp',
tags: ['output']
})
)
).toBe('temp')
})
it('falls back to tags and then the supplied default type', () => {
expect(getAssetType(asset({ tags: ['input'] }))).toBe('input')
expect(getAssetType(asset(), 'input')).toBe('input')
})
})

View File

@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetUrl } from '@/platform/assets/utils/assetUrlUtil'
const { apiURL } = vi.hoisted(() => ({
apiURL: vi.fn((path: string) => `https://comfy.local${path}`)
}))
vi.mock('@/scripts/api', () => ({
api: { apiURL }
}))
function asset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-1',
name: 'folder image.png',
preview_url: '',
tags: ['output'],
created_at: '',
updated_at: '',
size: 0,
mime_type: 'image/png',
user_metadata: {},
...overrides
} as AssetItem
}
beforeEach(() => {
apiURL.mockClear()
})
describe('getAssetUrl', () => {
it('builds encoded view URLs with type and subfolder', () => {
const url = getAssetUrl(
asset({
user_metadata: { subfolder: 'nested/path' }
})
)
expect(apiURL).toHaveBeenCalledWith(
'/view?filename=folder+image.png&type=output&subfolder=nested%2Fpath'
)
expect(url).toBe(
'https://comfy.local/view?filename=folder+image.png&type=output&subfolder=nested%2Fpath'
)
})
it('uses preview URL type and omits empty subfolders', () => {
getAssetUrl(
asset({
preview_url: '/api/view?filename=image.png&type=temp',
tags: ['output'],
user_metadata: { subfolder: '' }
})
)
expect(apiURL).toHaveBeenCalledWith(
'/view?filename=folder+image.png&type=temp'
)
})
})

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -28,6 +29,8 @@ interface HostAssetWidget extends IBaseWidget<
node: LGraphNode
}
type AssetWidget = IBaseWidget<string | undefined, 'asset', IWidgetAssetOptions>
type OnWidgetChanged = NonNullable<LGraphNode['onWidgetChanged']>
function checkpointAsset(name: string): AssetItem {
@@ -166,4 +169,118 @@ describe('createAssetWidget', () => {
)
expect(captureCanvasState).toHaveBeenCalledOnce()
})
it('falls back to widget name and empty current value for cloned widgets', async () => {
const { node } = createAssetWidgetNode()
const sourceWidget = createAssetWidget({
node,
widgetName: 'lora_name',
nodeTypeForBrowser: 'LoraLoader'
})
assertAssetOptions(sourceWidget.options)
const clonedWidget: AssetWidget = {
type: 'asset',
name: 'lora_name',
value: undefined,
options: sourceWidget.options,
y: 0
}
await sourceWidget.options.openModal(clonedWidget)
expect(firstShowOptions()).toMatchObject({
nodeType: 'LoraLoader',
inputName: 'lora_name',
currentValue: ''
})
})
it('rejects malformed asset selections', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { node } = createAssetWidgetNode()
const widget = createAssetWidget({
node,
widgetName: 'ckpt_name',
nodeTypeForBrowser: 'CheckpointLoaderSimple',
defaultValue: 'fake_model.safetensors'
})
assertAssetOptions(widget.options)
await widget.options.openModal(widget)
firstShowOptions().onAssetSelected?.(
fromPartial({ id: 'asset-without-name' })
)
expect(widget.value).toBe('fake_model.safetensors')
expect(captureCanvasState).not.toHaveBeenCalled()
consoleError.mockRestore()
})
it('rejects invalid asset filenames', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { node } = createAssetWidgetNode()
const widget = createAssetWidget({
node,
widgetName: 'ckpt_name',
nodeTypeForBrowser: 'CheckpointLoaderSimple',
defaultValue: 'fake_model.safetensors'
})
assertAssetOptions(widget.options)
await widget.options.openModal(widget)
firstShowOptions().onAssetSelected?.(checkpointAsset('../bad.safetensors'))
expect(widget.value).toBe('fake_model.safetensors')
expect(captureCanvasState).not.toHaveBeenCalled()
consoleError.mockRestore()
})
it('updates ownerless cloned widgets without node callbacks', async () => {
const { node, onWidgetChanged } = createAssetWidgetNode()
const sourceWidget = createAssetWidget({
node,
widgetName: 'ckpt_name',
nodeTypeForBrowser: 'CheckpointLoaderSimple',
defaultValue: 'fake_model.safetensors'
})
assertAssetOptions(sourceWidget.options)
const callback = vi.fn<NonNullable<IBaseWidget['callback']>>()
const clonedWidget: AssetWidget = {
type: 'asset',
name: 'ckpt_name',
value: 'fake_model.safetensors',
callback,
options: sourceWidget.options,
y: 0
}
await sourceWidget.options.openModal(clonedWidget)
firstShowOptions().onAssetSelected?.(
checkpointAsset('real_model.safetensors')
)
expect(clonedWidget.value).toBe('real_model.safetensors')
expect(callback).toHaveBeenCalledWith('real_model.safetensors')
expect(onWidgetChanged).not.toHaveBeenCalled()
expect(captureCanvasState).toHaveBeenCalledOnce()
})
it('does not capture canvas state when the selection is unchanged', async () => {
const { node } = createAssetWidgetNode()
const widget = createAssetWidget({
node,
widgetName: 'ckpt_name',
nodeTypeForBrowser: 'CheckpointLoaderSimple',
defaultValue: 'fake_model.safetensors'
})
assertAssetOptions(widget.options)
await widget.options.openModal(widget)
firstShowOptions().onAssetSelected?.(
checkpointAsset('fake_model.safetensors')
)
expect(widget.value).toBe('fake_model.safetensors')
expect(captureCanvasState).not.toHaveBeenCalled()
})
})

View File

@@ -18,7 +18,7 @@
</div>
<!-- Workspace mode: workspace-aware subscription content (renders its own footer) -->
<SubscriptionPanelContentWorkspace v-if="shouldUseWorkspaceBilling" />
<SubscriptionPanelContentWorkspace v-if="teamWorkspacesEnabled" />
<!-- Legacy mode: user-level subscription content -->
<template v-else>
<SubscriptionPanelContentLegacy />
@@ -29,20 +29,24 @@
</template>
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
import { computed, defineAsyncComponent } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import SubscriptionFooterLinks from '@/platform/cloud/subscription/components/SubscriptionFooterLinks.vue'
import SubscriptionPanelContentLegacy from '@/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue'
import { isCloud } from '@/platform/distribution/types'
const SubscriptionPanelContentWorkspace = defineAsyncComponent(
() =>
import('@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue')
)
const { shouldUseWorkspaceBilling } = useBillingRouting()
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const { isActiveSubscription } = useBillingContext()
</script>

View File

@@ -1,136 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import SubscriptionPanelContentLegacy from './SubscriptionPanelContentLegacy.vue'
const mockAccessBillingPortal = vi.fn()
const mockTrackSubscriptionCancellation = vi.fn()
const mockShowSubscriptionDialog = vi.fn()
const mockHandleRefresh = vi.fn()
const mockIsActiveSubscription = ref(true)
const mockIsCancelled = ref(false)
const mockIsFreeTier = ref(false)
const mockSubscriptionTier = ref<'STANDARD' | 'CREATOR' | 'PRO' | null>(
'STANDARD'
)
const mockIsYearlySubscription = ref(true)
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
accessBillingPortal: mockAccessBillingPortal
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackSubscriptionCancellation: mockTrackSubscriptionCancellation
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
isCancelled: computed(() => mockIsCancelled.value),
isFreeTier: computed(() => mockIsFreeTier.value),
formattedRenewalDate: computed(() => '2026-08-01'),
formattedEndDate: computed(() => '2026-08-01'),
subscriptionTier: computed(() => mockSubscriptionTier.value),
subscriptionTierName: computed(() => 'Standard'),
isYearlySubscription: computed(() => mockIsYearlySubscription.value)
})
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionActions',
() => ({
useSubscriptionActions: () => ({
handleRefresh: mockHandleRefresh
})
})
)
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({
show: mockShowSubscriptionDialog
})
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subscription: {
perMonth: '/ month',
manageSubscription: 'Manage subscription',
upgradePlan: 'Upgrade plan',
subscribeNow: 'Subscribe now',
yourPlanIncludes: 'Your plan includes',
viewMoreDetailsPlans: 'View more details',
renewsDate: 'Renews {date}',
expiresDate: 'Expires {date}',
monthlyCreditsLabel: 'monthly credits',
maxDurationLabel: 'max duration',
gpuLabel: 'GPU access',
addCreditsLabel: 'Add credits',
customLoRAsLabel: 'Custom LoRAs',
maxDuration: {
standard: '30 min'
}
}
}
}
})
function renderComponent() {
return render(SubscriptionPanelContentLegacy, {
global: {
plugins: [i18n],
stubs: {
CreditsTile: true,
SubscribeButton: true,
Button: {
template: '<button @click="$emit(\'click\')"><slot /></button>',
emits: ['click']
}
}
}
})
}
describe('SubscriptionPanelContentLegacy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAccessBillingPortal.mockResolvedValue(undefined)
mockIsActiveSubscription.value = true
mockIsCancelled.value = false
mockIsFreeTier.value = false
mockSubscriptionTier.value = 'STANDARD'
mockIsYearlySubscription.value = true
})
it('tracks cancel intent before opening the billing portal', async () => {
renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /manage subscription/i })
)
expect(mockTrackSubscriptionCancellation).toHaveBeenCalledExactlyOnceWith(
'flow_opened',
{
source: 'manage_subscription_button',
current_tier: 'standard',
cycle: 'yearly'
}
)
expect(mockAccessBillingPortal).toHaveBeenCalledOnce()
})
})

View File

@@ -36,7 +36,11 @@
v-if="isActiveSubscription && !isFreeTier"
variant="secondary"
class="ml-auto rounded-lg bg-interface-menu-component-surface-selected px-4 py-2 text-sm font-normal text-text-primary"
@click="handleManageSubscription"
@click="
async () => {
await authActions.accessBillingPortal()
}
"
>
{{ $t('subscription.manageSubscription') }}
</Button>
@@ -121,7 +125,6 @@ import { useAuthActions } from '@/composables/auth/useAuthActions'
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
@@ -157,18 +160,6 @@ const tierPrice = computed(() =>
getTierPrice(tierKey.value, isYearlySubscription.value)
)
// The portal is the only place a legacy user can cancel (in-app UI already
// covers plan changes), so this click is the closest observable cancel-intent
// signal on the mainline path.
async function handleManageSubscription() {
useTelemetry()?.trackSubscriptionCancellation('flow_opened', {
source: 'manage_subscription_button',
current_tier: subscriptionTier.value?.toLowerCase(),
cycle: isYearlySubscription.value ? 'yearly' : 'monthly'
})
await authActions.accessBillingPortal()
}
const tierBenefits = computed((): TierBenefit[] =>
getCommonTierBenefits(tierKey.value, t, n)
)

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