Compare commits
1 Commits
main
...
shihchi/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d67c50578 |
86
.claude/settings.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc directly.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(vue-tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running vue-tsc directly.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc via npx.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc via pnpx.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpm exec tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of `pnpm exec tsc`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx eslint *)",
|
||||
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of npx eslint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx eslint *)",
|
||||
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of pnpx eslint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx prettier *)",
|
||||
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx prettier *)",
|
||||
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx oxlint *)",
|
||||
"command": "echo 'Use `pnpm oxlint` instead of npx oxlint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx stylelint *)",
|
||||
"command": "echo 'Use `pnpm stylelint` instead of npx stylelint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx knip *)",
|
||||
"command": "echo 'Use `pnpm knip` instead of npx knip.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx knip *)",
|
||||
"command": "echo 'Use `pnpm knip` instead of pnpx knip.' >&2 && exit 2"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
21
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -134,27 +134,6 @@ jobs:
|
||||
fi
|
||||
echo '✅ No Customer.io references found'
|
||||
|
||||
- name: Scan dist for Syft telemetry references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Scanning for Syft references...'
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
-e '(?i)syft' \
|
||||
-e '(?i)sy-d\.io' \
|
||||
dist; then
|
||||
echo '❌ ERROR: Syft references found in dist assets!'
|
||||
echo 'Syft must be properly tree-shaken from OSS builds.'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
|
||||
echo '2. Call telemetry via useTelemetry() hook'
|
||||
echo '3. Use conditional dynamic imports behind isCloud checks'
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No Syft references found'
|
||||
|
||||
- name: Scan dist for Cloudflare Turnstile sitekey references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
2
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -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
|
||||
|
||||
1
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -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'
|
||||
|
||||
3
.github/workflows/ci-tests-unit.yaml
vendored
@@ -55,3 +55,6 @@ jobs:
|
||||
flags: unit
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Enforce critical coverage gate
|
||||
run: pnpm test:coverage:critical
|
||||
|
||||
@@ -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
|
||||
|
||||
10
.github/workflows/ci-website-e2e.yaml
vendored
@@ -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 }}
|
||||
|
||||
13
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -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
|
||||
|
||||
@@ -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') ||
|
||||
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -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 |
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -15,7 +15,7 @@ const { categories } = defineProps<{
|
||||
|
||||
const activeSection = ref(categories[0]?.value ?? '')
|
||||
|
||||
const HEADER_OFFSET_PX = -144
|
||||
const HEADER_OFFSET = -144
|
||||
const BOTTOM_THRESHOLD_PX = 4
|
||||
const SCROLL_SAFETY_MS = 1500
|
||||
|
||||
@@ -52,7 +52,7 @@ function scrollToSection(id: string) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET_PX,
|
||||
offset: HEADER_OFFSET,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: clearScrollLock
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<li
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow"
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow before:content-['']"
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": {
|
||||
"0": 64,
|
||||
"1": 104
|
||||
},
|
||||
"size": {
|
||||
"0": 210,
|
||||
"1": 58
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"linearData": {
|
||||
"inputs": [],
|
||||
"outputs": ["9"]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -34,10 +34,6 @@ export class AppModeHelper {
|
||||
public readonly outputPlaceholder: Locator
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
public readonly linearWidgets: Locator
|
||||
/** The validation warning shown above the app mode run button. */
|
||||
public readonly validationWarning: Locator
|
||||
/** The action that opens graph mode errors from the validation warning. */
|
||||
public readonly viewErrorsInGraphButton: Locator
|
||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||
public readonly imagePickerPopover: Locator
|
||||
/** The Run button in the app mode footer. */
|
||||
@@ -96,19 +92,13 @@ export class AppModeHelper {
|
||||
this.outputPlaceholder = this.page.getByTestId(
|
||||
TestIds.builder.outputPlaceholder
|
||||
)
|
||||
this.linearWidgets = this.page.getByTestId(TestIds.linear.widgetContainer)
|
||||
this.validationWarning = this.page.getByTestId(
|
||||
TestIds.linear.validationWarning
|
||||
)
|
||||
this.viewErrorsInGraphButton = this.validationWarning.getByTestId(
|
||||
TestIds.linear.viewErrorsInGraph
|
||||
)
|
||||
this.linearWidgets = this.page.getByTestId('linear-widgets')
|
||||
this.imagePickerPopover = this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
this.runButton = this.page
|
||||
.getByTestId(TestIds.linear.runButton)
|
||||
.getByTestId('linear-run-button')
|
||||
.getByRole('button', { name: /run/i })
|
||||
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
|
||||
this.emptyWorkflowText = this.page.getByTestId(
|
||||
|
||||
@@ -172,9 +172,6 @@ export const TestIds = {
|
||||
mobileNavigation: 'linear-mobile-navigation',
|
||||
mobileWorkflows: 'linear-mobile-workflows',
|
||||
outputInfo: 'linear-output-info',
|
||||
runButton: 'linear-run-button',
|
||||
validationWarning: 'linear-validation-warning',
|
||||
viewErrorsInGraph: 'linear-view-errors',
|
||||
widgetContainer: 'linear-widgets'
|
||||
},
|
||||
builder: {
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const SAVE_IMAGE_NODE_ID = '9'
|
||||
|
||||
function buildSaveImageRequiredInputError(): NodeError {
|
||||
return {
|
||||
class_type: 'SaveImage',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing: images',
|
||||
details: '',
|
||||
extra_info: { input_name: 'images' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'App mode validation warning',
|
||||
{ tag: ['@ui', '@workflow'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsOverlay(comfyPage)
|
||||
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens graph errors from the app mode validation warning', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(comfyPage.appMode.validationWarning).toBeHidden()
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
const appModeOverlay = comfyPage.appMode.centerPanel.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(appModeOverlay).toBeHidden()
|
||||
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.validationWarning).toContainText(
|
||||
/Required input missing/i
|
||||
)
|
||||
await expect(comfyPage.appMode.viewErrorsInGraphButton).toBeVisible()
|
||||
|
||||
await comfyPage.appMode.viewErrorsInGraphButton.click()
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps the app mode run button enabled when the warning is visible', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.runButton).toBeEnabled()
|
||||
|
||||
let promptQueued = false
|
||||
const mockResponse: PromptResponse = {
|
||||
prompt_id: 'test-id',
|
||||
node_errors: {},
|
||||
error: ''
|
||||
}
|
||||
await comfyPage.page.route(
|
||||
'**/api/prompt',
|
||||
async (route) => {
|
||||
promptQueued = true
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
},
|
||||
{ times: 1 }
|
||||
)
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
|
||||
await expect.poll(() => promptQueued).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
@@ -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.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -16,10 +15,9 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
(linkId) => window.app!.graph!.links.get(linkId)?.target_slot,
|
||||
toLinkId(1)
|
||||
)
|
||||
comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.links.get(1)?.target_slot
|
||||
})
|
||||
)
|
||||
.toBe(1)
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
@@ -3,7 +3,6 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Displays linear controls when app mode active', async ({
|
||||
@@ -17,9 +16,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Run button visible in linear mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.linear.runButton)
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Workflow info section visible', async ({ comfyPage }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 36 KiB |
@@ -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)
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 90 KiB |
@@ -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()
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -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)
|
||||
|
||||
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB |
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 94 KiB |
@@ -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()
|
||||
|
||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 104 KiB |
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 30 KiB |
@@ -5,7 +5,12 @@ import {
|
||||
|
||||
test.describe('Widget copy button', { tag: ['@ui', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.searchBoxV2.addNode('Preview as Text')
|
||||
// Add a PreviewAny node which has a read-only textarea with a copy button
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('PreviewAny')
|
||||
window.app!.graph.add(node)
|
||||
})
|
||||
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
|
||||
25
global.d.ts
vendored
@@ -41,29 +41,6 @@ interface GtagFunction {
|
||||
(...args: unknown[]): void
|
||||
}
|
||||
|
||||
type SyftDataTraits = Record<string, string | number | null | undefined>
|
||||
|
||||
interface SyftDataPendingFetch {
|
||||
args: unknown[]
|
||||
resolve: (value: unknown) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}
|
||||
|
||||
interface SyftDataClient {
|
||||
identify(email: string, traits?: SyftDataTraits): void
|
||||
signup(email: string, traits?: SyftDataTraits): void
|
||||
track(event: string, traits?: SyftDataTraits): void
|
||||
page(...args: unknown[]): void
|
||||
q?: unknown[][]
|
||||
fi?: SyftDataPendingFetch[]
|
||||
fetchID?: (...args: unknown[]) => Promise<unknown>
|
||||
}
|
||||
|
||||
/** Installed by the Syft UMD instead of SyftDataClient when telemetry is opted out */
|
||||
interface SyftDisabledClient {
|
||||
enable: () => void
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__CONFIG__: {
|
||||
gtm_container_id?: string
|
||||
@@ -101,8 +78,6 @@ interface Window {
|
||||
}
|
||||
dataLayer?: Array<Record<string, unknown>>
|
||||
gtag?: GtagFunction
|
||||
syft?: SyftDataClient | SyftDisabledClient
|
||||
syftc?: { sourceId?: string; enabled?: boolean }
|
||||
ire_o?: string
|
||||
ire?: ImpactQueueFunction
|
||||
rewardful?: RewardfulQueueFunction
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:coverage:critical": "cross-env COVERAGE_CRITICAL=true vitest run --coverage",
|
||||
"test:unit": "vitest run",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
size="unset"
|
||||
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
|
||||
data-testid="error-overlay-see-errors"
|
||||
@click="viewErrorsInGraph"
|
||||
@click="seeErrors"
|
||||
>
|
||||
{{
|
||||
appMode
|
||||
@@ -67,18 +67,31 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
|
||||
|
||||
const { appMode = false } = defineProps<{ appMode?: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const { viewErrorsInGraph } = useViewErrorsInGraph()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
|
||||
|
||||
function dismiss() {
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -224,7 +224,7 @@ const handleOpenUserSettings = () => {
|
||||
}
|
||||
|
||||
const handleOpenPlansAndPricing = () => {
|
||||
subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' })
|
||||
subscriptionDialog.showPricingTable()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -239,7 +239,8 @@ const handleOpenPlanAndCreditsSettings = () => {
|
||||
}
|
||||
|
||||
const handleTopUp = () => {
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
|
||||
// Track purchase credits entry from avatar popover
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
emit('close')
|
||||
}
|
||||
@@ -253,7 +254,7 @@ const handleOpenPartnerNodesInfo = () => {
|
||||
}
|
||||
|
||||
const handleUpgradeToAddCredits = () => {
|
||||
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
|
||||
subscriptionDialog.showPricingTable()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,6 @@ const { isFreeTier } = useBillingContext()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
function handleClick() {
|
||||
subscriptionDialog.showPricingTable({ reason: 'subscribe_now_button' })
|
||||
subscriptionDialog.showPricingTable()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
BillingStatus,
|
||||
@@ -76,10 +75,9 @@ export interface BillingActions {
|
||||
*/
|
||||
requireActiveSubscription: () => Promise<void>
|
||||
/**
|
||||
* Shows the subscription dialog. Pass a reason so the paywall open and any
|
||||
* downstream checkout stay attributed to the triggering product moment.
|
||||
* Shows the subscription dialog.
|
||||
*/
|
||||
showSubscriptionDialog: (options?: SubscriptionDialogOptions) => void
|
||||
showSubscriptionDialog: () => void
|
||||
}
|
||||
|
||||
export interface BillingState {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { computed, ref, shallowRef, toValue, watch } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
KEY_TO_TIER,
|
||||
getTierFeatures
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
PreviewSubscribeOptions,
|
||||
SubscribeOptions
|
||||
@@ -17,10 +17,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 +34,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 +68,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 +95,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 +169,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 +185,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 +215,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,8 +281,8 @@ function useBillingContextInternal(): BillingContext {
|
||||
return activeContext.value.requireActiveSubscription()
|
||||
}
|
||||
|
||||
function showSubscriptionDialog(options?: SubscriptionDialogOptions) {
|
||||
return activeContext.value.showSubscriptionDialog(options)
|
||||
function showSubscriptionDialog() {
|
||||
return activeContext.value.showSubscriptionDialog()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||