mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 01:38:03 +00:00
Compare commits
1 Commits
codex/part
...
fix/legacy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75223f8598 |
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
|
||||
"entry": ["src/main.ts", "index.html"],
|
||||
"duplicates": {
|
||||
"minOccurrences": 3,
|
||||
"ignore": ["**/*.generated.*", "**/generatedManagerTypes.ts"]
|
||||
},
|
||||
"rules": {}
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,7 +16,6 @@ yarn.lock
|
||||
.eslintcache
|
||||
.prettiercache
|
||||
.stylelintcache
|
||||
.fallow/
|
||||
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
@@ -88,11 +88,6 @@ const config: StorybookConfig = {
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/composables/useWorkspaceUI',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useWorkspaceUI.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/stores/teamWorkspaceStore',
|
||||
replacement:
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const MCP_ENDPOINT = 'https://cloud.comfy.org/mcp'
|
||||
|
||||
// The setup island hydrates on visibility; clicks before hydration are
|
||||
// no-ops, so retry until the tab actually activates.
|
||||
async function selectClientTab(setup: Locator, name: string) {
|
||||
const tab = setup.getByRole('tab', { name })
|
||||
await expect(async () => {
|
||||
await tab.click()
|
||||
await expect(tab).toHaveAttribute('data-state', 'active', { timeout: 500 })
|
||||
}).toPass()
|
||||
}
|
||||
|
||||
test.describe('MCP page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/mcp')
|
||||
@@ -30,72 +19,46 @@ test.describe('MCP page @smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('Claude Desktop is the default tab and shows only the connector card', async ({
|
||||
page
|
||||
}) => {
|
||||
test('setup section shows both install options', async ({ page }) => {
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
await expect(
|
||||
setup.getByRole('tab', { name: 'Claude Desktop' })
|
||||
).toHaveAttribute('data-state', 'active')
|
||||
await expect(
|
||||
setup.getByRole('heading', { name: 'Add Custom Connector' })
|
||||
).toBeVisible()
|
||||
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
setup.getByRole('heading', {
|
||||
name: 'Ask your agent to install Comfy MCP'
|
||||
})
|
||||
).toHaveCount(0)
|
||||
await expect(setup.locator('video')).toBeVisible()
|
||||
})
|
||||
|
||||
test('client tabs swap install instructions and agent-card visibility', async ({
|
||||
page
|
||||
}) => {
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
const activePanel = setup.locator('[role="tabpanel"][data-state="active"]')
|
||||
const agentHeading = setup.getByRole('heading', {
|
||||
name: 'Ask your agent to install Comfy MCP'
|
||||
})
|
||||
|
||||
await expect(activePanel).toContainText('Add custom connector')
|
||||
|
||||
// First interaction retries until the island hydrates; later switches
|
||||
// assert synchronously so steady-state click regressions fail.
|
||||
await selectClientTab(setup, 'Claude Code Terminal')
|
||||
await expect(activePanel).toContainText(
|
||||
`claude mcp add --transport http comfy-cloud ${MCP_ENDPOINT}`
|
||||
)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
setup.getByRole('heading', { name: 'Install manually' })
|
||||
).toBeVisible()
|
||||
await expect(agentHeading).toBeVisible()
|
||||
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
await setup.getByRole('tab', { name: 'Codex' }).click()
|
||||
test('client tabs swap install instructions', async ({ page }) => {
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
const activePanel = setup.locator('[role="tabpanel"][data-state="active"]')
|
||||
|
||||
// Claude Code is the default tab and carries the CLI command
|
||||
await expect(
|
||||
setup.getByRole('tab', { name: 'Claude Code' })
|
||||
).toHaveAttribute('data-state', 'active')
|
||||
await expect(activePanel).toContainText(
|
||||
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
|
||||
`claude mcp add --transport http comfy-cloud ${MCP_ENDPOINT}`
|
||||
)
|
||||
await expect(agentHeading).toHaveCount(0)
|
||||
await expect(setup.locator('video')).toBeVisible()
|
||||
|
||||
await setup.getByRole('tab', { name: 'Claude Desktop' }).click()
|
||||
await expect(activePanel).toContainText('Add custom connector')
|
||||
|
||||
await setup.getByRole('tab', { name: 'Cursor' }).click()
|
||||
await expect(activePanel).toContainText('X-API-Key')
|
||||
await expect(
|
||||
activePanel.getByRole('link', { name: 'platform.comfy.org' })
|
||||
).toHaveAttribute('href', 'https://platform.comfy.org/profile/api-keys')
|
||||
await expect(agentHeading).toBeVisible()
|
||||
|
||||
await setup.getByRole('tab', { name: 'OpenClaw' }).click()
|
||||
await setup.getByRole('tab', { name: 'Codex' }).click()
|
||||
await expect(activePanel).toContainText(
|
||||
'openclaw skills install @comfy-org/comfy'
|
||||
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
|
||||
)
|
||||
await expect(agentHeading).toBeVisible()
|
||||
|
||||
await setup.getByRole('tab', { name: 'Others' }).click()
|
||||
await expect(activePanel).toContainText('remote MCP server')
|
||||
await expect(agentHeading).toBeVisible()
|
||||
})
|
||||
|
||||
test('skills plugin link lives in the agent option card', async ({
|
||||
@@ -103,7 +66,6 @@ test.describe('MCP page @smoke', () => {
|
||||
}) => {
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
await selectClientTab(setup, 'Claude Code Terminal')
|
||||
await expect(
|
||||
setup.getByRole('link', { name: 'View on GitHub' })
|
||||
).toHaveAttribute('href', 'https://github.com/Comfy-Org/comfy-skills')
|
||||
@@ -143,10 +105,7 @@ test.describe('MCP page zh-CN @smoke', () => {
|
||||
await page.goto('/zh-CN/mcp')
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
await expect(
|
||||
setup.getByRole('heading', { name: '添加自定义连接器' })
|
||||
).toBeVisible()
|
||||
await selectClientTab(setup, 'Claude Code Terminal')
|
||||
await expect(setup.getByText('方式一')).toBeVisible()
|
||||
await expect(setup.getByRole('heading', { name: '手动安装' })).toBeVisible()
|
||||
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -33,7 +33,6 @@ const {
|
||||
minimal = false,
|
||||
hideControls = false,
|
||||
fit = 'cover',
|
||||
ariaLabel,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
@@ -45,7 +44,6 @@ const {
|
||||
minimal?: boolean
|
||||
hideControls?: boolean
|
||||
fit?: 'cover' | 'contain'
|
||||
ariaLabel?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
@@ -95,28 +93,6 @@ watch(videoEl, syncNativeDuration)
|
||||
useEventListener(videoEl, 'loadedmetadata', syncNativeDuration)
|
||||
useEventListener(videoEl, 'durationchange', syncNativeDuration)
|
||||
|
||||
// The muted attribute only sets defaultMuted, so SSR-rendered autoplay
|
||||
// videos count as unmuted and get blocked; force the property and kick
|
||||
// playback. Scoped to hideControls (decorative) clips so chrome-visible
|
||||
// consumers keep native semantics. flush: 'post' guarantees this runs
|
||||
// after useMediaControls' internal muted watcher on the same source.
|
||||
watch(
|
||||
[videoEl, () => src],
|
||||
([el]) => {
|
||||
if (!el || !autoplay || !hideControls) return
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
el.pause()
|
||||
return
|
||||
}
|
||||
el.muted = true
|
||||
el.play().catch((error: unknown) => {
|
||||
if (error instanceof Error && error.name === 'AbortError') return
|
||||
console.warn('VideoPlayer autoplay failed', error)
|
||||
})
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
const effectiveDuration = computed(() => duration.value || nativeDuration.value)
|
||||
|
||||
// Scrubber (modeled after VueUse demo Scrubber.vue)
|
||||
@@ -231,7 +207,6 @@ function toggleFullscreen() {
|
||||
<video
|
||||
v-if="src"
|
||||
ref="videoEl"
|
||||
:aria-label="ariaLabel"
|
||||
:class="
|
||||
cn('size-full', fit === 'contain' ? 'object-contain' : 'object-cover')
|
||||
"
|
||||
|
||||
@@ -1919,48 +1919,42 @@ const translations = {
|
||||
'zh-CN': '配置 Comfy MCP'
|
||||
},
|
||||
'mcp.setup.subtitle': {
|
||||
en: 'Two ways to connect: add the server yourself, or ask your agent to install it. Sign in once, and the full ComfyUI toolset is available right in your chat.',
|
||||
en: 'Two ways to connect: ask your agent to install it, or add the server yourself. Sign in once, and the full ComfyUI toolset is available right in your chat.',
|
||||
'zh-CN':
|
||||
'两种接入方式:自行添加服务器,或让你的智能体自动安装。登录一次,ComfyUI 全套工具即可直接在对话中使用。'
|
||||
'两种接入方式:让你的智能体自动安装,或自行添加服务器。登录一次,ComfyUI 全套工具即可直接在对话中使用。'
|
||||
},
|
||||
'mcp.setup.manual.title': {
|
||||
en: 'Install manually',
|
||||
'zh-CN': '手动安装'
|
||||
},
|
||||
'mcp.setup.manual.description': {
|
||||
en: 'Add this URL as a custom connector or remote MCP server in your client, then sign in when prompted.',
|
||||
'zh-CN':
|
||||
'将此 URL 添加为客户端的自定义连接器或远程 MCP 服务器,然后按提示登录。'
|
||||
},
|
||||
'mcp.setup.manual.tabsLabel': {
|
||||
en: 'Pick your client',
|
||||
'zh-CN': '选择你的客户端'
|
||||
},
|
||||
'mcp.setup.agent.title': {
|
||||
'mcp.setup.option1.label': { en: 'OPTION 1', 'zh-CN': '方式一' },
|
||||
'mcp.setup.option1.title': {
|
||||
en: 'Ask your agent to install Comfy MCP',
|
||||
'zh-CN': '让你的智能体安装 Comfy MCP'
|
||||
},
|
||||
'mcp.setup.agent.command': {
|
||||
'mcp.setup.option1.command': {
|
||||
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
|
||||
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
|
||||
},
|
||||
'mcp.setup.agent.description': {
|
||||
en: 'Prefer to let your agent do it? Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
|
||||
'mcp.setup.option1.description': {
|
||||
en: 'Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
|
||||
'zh-CN':
|
||||
'想让智能体代劳?将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
|
||||
'将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
|
||||
},
|
||||
'mcp.setup.option2.label': { en: 'OPTION 2', 'zh-CN': '方式二' },
|
||||
'mcp.setup.option2.title': {
|
||||
en: 'Install manually',
|
||||
'zh-CN': '手动安装'
|
||||
},
|
||||
'mcp.setup.option2.description': {
|
||||
en: 'Prefer manual setup? Add this URL as a custom connector or remote MCP server in your client, then sign in when prompted.',
|
||||
'zh-CN':
|
||||
'想手动配置?将此 URL 添加为客户端的自定义连接器或远程 MCP 服务器,然后按提示登录。'
|
||||
},
|
||||
'mcp.setup.option2.tabsLabel': {
|
||||
en: 'Pick your client',
|
||||
'zh-CN': '选择你的客户端'
|
||||
},
|
||||
'mcp.setup.clients.claudeCode.step': {
|
||||
en: 'Run this in your terminal, then use /mcp to pick comfy-cloud and authenticate.',
|
||||
'zh-CN': '在终端运行以下命令,然后通过 /mcp 选择 comfy-cloud 并完成认证。'
|
||||
},
|
||||
'mcp.setup.walkthroughAlt': {
|
||||
en: '{client} setup walkthrough',
|
||||
'zh-CN': '{client} 设置演示'
|
||||
},
|
||||
'mcp.setup.clients.claudeDesktop.manualTitle': {
|
||||
en: 'Add Custom Connector',
|
||||
'zh-CN': '添加自定义连接器'
|
||||
},
|
||||
'mcp.setup.clients.claudeDesktop.step': {
|
||||
en: 'Click Customize in the sidebar, open Connectors, choose Add custom connector, paste the URL above, and sign in.',
|
||||
'zh-CN':
|
||||
@@ -1979,13 +1973,9 @@ const translations = {
|
||||
en: 'Run this in your terminal, then codex mcp login comfy-cloud to sign in.',
|
||||
'zh-CN': '在终端运行以下命令,然后执行 codex mcp login comfy-cloud 登录。'
|
||||
},
|
||||
'mcp.setup.clients.openclaw.step': {
|
||||
en: 'Run these in your terminal, then openclaw mcp login comfy to sign in.',
|
||||
'zh-CN': '在终端运行以下命令,然后执行 openclaw mcp login comfy 登录。'
|
||||
},
|
||||
'mcp.setup.clients.other.name': {
|
||||
en: 'Others',
|
||||
'zh-CN': '其他'
|
||||
en: 'Other clients',
|
||||
'zh-CN': '其他客户端'
|
||||
},
|
||||
'mcp.setup.clients.other.step': {
|
||||
en: 'Add the URL above as a remote MCP server. No OAuth in your client? Use an X-API-Key header instead. Full walkthroughs live in the ',
|
||||
@@ -2200,9 +2190,9 @@ const translations = {
|
||||
'zh-CN': '我需要 API 密钥吗?'
|
||||
},
|
||||
'mcp.faq.3.a': {
|
||||
en: 'Not for Claude Code, Claude Desktop, Codex, or OpenClaw. You need a Comfy API key for Cursor and Hermes for now. Just copy https://docs.comfy.org/agent-tools/cloud and your agent will figure out the installation for you.',
|
||||
en: 'Not for Claude Code, Claude Desktop, or Codex. You need a Comfy API key for Cursor, Hermes, and OpenClaw for now. Just copy https://docs.comfy.org/agent-tools/cloud and your agent will figure out the installation for you.',
|
||||
'zh-CN':
|
||||
'Claude Code、Claude Desktop、Codex 和 OpenClaw 不需要。Cursor 和 Hermes 目前需要 Comfy API 密钥。只需复制 https://docs.comfy.org/agent-tools/cloud,你的智能体就会为你完成安装。'
|
||||
'Claude Code、Claude Desktop 和 Codex 不需要。Cursor、Hermes 和 OpenClaw 目前需要 Comfy API 密钥。只需复制 https://docs.comfy.org/agent-tools/cloud,你的智能体就会为你完成安装。'
|
||||
},
|
||||
'mcp.faq.4.q': {
|
||||
en: 'Does it cost anything?',
|
||||
|
||||
@@ -78,28 +78,3 @@ describe('captureDownloadClick', () => {
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureMcpClientTabClick', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('captures the tab click with the client id', async () => {
|
||||
const { initPostHog, captureMcpClientTabClick } = await import('./posthog')
|
||||
initPostHog()
|
||||
captureMcpClientTabClick('claude-code')
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
'website:mcp_client_tab_clicked',
|
||||
{ client: 'claude-code' }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not capture before PostHog is initialized', async () => {
|
||||
const { captureMcpClientTabClick } = await import('./posthog')
|
||||
captureMcpClientTabClick('cursor')
|
||||
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,12 +49,3 @@ export function captureDownloadClick(platform: Platform) {
|
||||
console.error('PostHog download click capture failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function captureMcpClientTabClick(client: string) {
|
||||
if (!initialized) return
|
||||
try {
|
||||
posthog.capture('website:mcp_client_tab_clicked', { client })
|
||||
} catch (error) {
|
||||
console.error('PostHog MCP client tab capture failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import VideoPlayer from '../../components/common/VideoPlayer.vue'
|
||||
import SectionLabel from '../../components/common/SectionLabel.vue'
|
||||
import CopyableField from '../../components/ui/copyable-field/CopyableField.vue'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { captureMcpClientTabClick } from '../../scripts/posthog'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const agentCommand = t('mcp.setup.agent.command', locale).replace(
|
||||
const agentCommand = t('mcp.setup.option1.command', locale).replace(
|
||||
'{url}',
|
||||
externalLinks.docsMcp
|
||||
)
|
||||
@@ -23,35 +21,19 @@ interface McpClient {
|
||||
step: string
|
||||
command?: string
|
||||
link?: { label: string; href: string }
|
||||
manualTitle?: string
|
||||
showAgentCard: boolean
|
||||
// Walkthrough clip shown in place of the agent card (source: docs.comfy.org/agent-tools/mcp)
|
||||
video?: string
|
||||
}
|
||||
|
||||
const clients: McpClient[] = [
|
||||
{
|
||||
id: 'claude-code',
|
||||
name: 'Claude Code',
|
||||
step: t('mcp.setup.clients.claudeCode.step', locale),
|
||||
command: `claude mcp add --transport http comfy-cloud ${externalLinks.mcpEndpoint}`
|
||||
},
|
||||
{
|
||||
id: 'claude-desktop',
|
||||
name: 'Claude Desktop',
|
||||
step: t('mcp.setup.clients.claudeDesktop.step', locale),
|
||||
manualTitle: t('mcp.setup.clients.claudeDesktop.manualTitle', locale),
|
||||
showAgentCard: false,
|
||||
video: '/videos/mcp/setup-claude-desktop-v1.mp4'
|
||||
},
|
||||
{
|
||||
id: 'claude-code',
|
||||
name: 'Claude Code Terminal',
|
||||
step: t('mcp.setup.clients.claudeCode.step', locale),
|
||||
command: `claude mcp add --transport http comfy-cloud ${externalLinks.mcpEndpoint}`,
|
||||
showAgentCard: true
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
step: t('mcp.setup.clients.codex.step', locale),
|
||||
command: `codex mcp add comfy-cloud --url ${externalLinks.mcpEndpoint}`,
|
||||
showAgentCard: false,
|
||||
video: '/videos/mcp/setup-codex-oauth-v1.mp4'
|
||||
step: t('mcp.setup.clients.claudeDesktop.step', locale)
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
@@ -60,15 +42,13 @@ const clients: McpClient[] = [
|
||||
link: {
|
||||
label: t('mcp.setup.clients.cursor.linkLabel', locale),
|
||||
href: externalLinks.apiKeys
|
||||
},
|
||||
showAgentCard: true
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'openclaw',
|
||||
name: 'OpenClaw',
|
||||
step: t('mcp.setup.clients.openclaw.step', locale),
|
||||
command: `openclaw skills install @comfy-org/comfy\nopenclaw mcp set comfy '{"url":"${externalLinks.mcpEndpoint}","transport":"streamable-http","auth":"oauth"}'`,
|
||||
showAgentCard: true
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
step: t('mcp.setup.clients.codex.step', locale),
|
||||
command: `codex mcp add comfy-cloud --url ${externalLinks.mcpEndpoint}`
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
@@ -77,38 +57,10 @@ const clients: McpClient[] = [
|
||||
link: {
|
||||
label: t('mcp.setup.clients.other.linkLabel', locale),
|
||||
href: externalLinks.docsMcp
|
||||
},
|
||||
showAgentCard: true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const activeClientId = ref(clients[0].id)
|
||||
const activeClient = computed(
|
||||
() =>
|
||||
clients.find((client) => client.id === activeClientId.value) ?? clients[0]
|
||||
)
|
||||
const manualTitle = computed(
|
||||
() => activeClient.value.manualTitle ?? t('mcp.setup.manual.title', locale)
|
||||
)
|
||||
|
||||
// reka-ui re-emits update:modelValue even when the value is unchanged
|
||||
// (re-clicking the active tab), so dedupe before capturing.
|
||||
let lastTrackedClientId: string | undefined
|
||||
function onClientTabChange(value: string | number | undefined) {
|
||||
if (!value) return
|
||||
const id = String(value)
|
||||
if (id === lastTrackedClientId) return
|
||||
lastTrackedClientId = id
|
||||
captureMcpClientTabClick(id)
|
||||
}
|
||||
|
||||
const walkthroughLabel = computed(() =>
|
||||
t('mcp.setup.walkthroughAlt', locale).replace(
|
||||
'{client}',
|
||||
activeClient.value.name
|
||||
)
|
||||
)
|
||||
|
||||
const copyLabel = t('ui.copy', locale)
|
||||
const copiedLabel = t('ui.copied', locale)
|
||||
</script>
|
||||
@@ -131,48 +83,77 @@ const copiedLabel = t('ui.copied', locale)
|
||||
</template>
|
||||
</SectionHeader>
|
||||
|
||||
<TabsRoot
|
||||
v-model="activeClientId"
|
||||
activation-mode="manual"
|
||||
class="mt-10 block"
|
||||
@update:model-value="onClientTabChange"
|
||||
>
|
||||
<TabsList
|
||||
:aria-label="t('mcp.setup.manual.tabsLabel', locale)"
|
||||
class="grid grid-cols-1 gap-px rounded-2xl border border-white/15 bg-primary-comfy-ink p-1 min-[360px]:grid-cols-2 lg:inline-flex lg:flex-nowrap"
|
||||
<div class="mt-16 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div
|
||||
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
|
||||
>
|
||||
<TabsTrigger
|
||||
v-for="client in clients"
|
||||
:key="client.id"
|
||||
:value="client.id"
|
||||
class="focus-visible:ring-primary-comfy-yellow/50 data-[state=active]:bg-primary-comfy-yellow shrink-0 cursor-pointer rounded-lg bg-white/8 px-2 py-2.5 text-[10px] font-bold tracking-wider whitespace-nowrap text-smoke-700 uppercase transition-colors hover:text-primary-comfy-canvas focus-visible:ring-2 focus-visible:outline-none data-[state=active]:text-primary-comfy-ink lg:rounded-none lg:px-6 lg:text-xs lg:first:rounded-l-xl lg:last:rounded-r-xl"
|
||||
<SectionLabel>{{ t('mcp.setup.option1.label', locale) }}</SectionLabel>
|
||||
<h3
|
||||
class="mt-3 text-xl font-light text-primary-comfy-canvas lg:text-2xl"
|
||||
>
|
||||
{{ client.name }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{{ t('mcp.setup.option1.title', locale) }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ t('mcp.setup.option1.description', locale) }}
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<CopyableField
|
||||
:value="agentCommand"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-auto pt-6 text-sm text-smoke-700">
|
||||
{{ t('mcp.setup.skillsNote', locale)
|
||||
}}<a
|
||||
:href="externalLinks.mcpSkills"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
|
||||
>{{ t('mcp.setup.skillsLink', locale) }}</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div
|
||||
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
|
||||
<div
|
||||
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
|
||||
>
|
||||
<SectionLabel>{{ t('mcp.setup.option2.label', locale) }}</SectionLabel>
|
||||
<h3
|
||||
class="mt-3 text-xl font-light text-primary-comfy-canvas lg:text-2xl"
|
||||
>
|
||||
<h3 class="text-xl font-light text-primary-comfy-canvas lg:text-2xl">
|
||||
{{ manualTitle }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ t('mcp.setup.manual.description', locale) }}
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<CopyableField
|
||||
:value="externalLinks.mcpEndpoint"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</div>
|
||||
{{ t('mcp.setup.option2.title', locale) }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ t('mcp.setup.option2.description', locale) }}
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<CopyableField
|
||||
:value="externalLinks.mcpEndpoint"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabsRoot default-value="claude-code" class="mt-6">
|
||||
<TabsList
|
||||
:aria-label="t('mcp.setup.option2.tabsLabel', locale)"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<TabsTrigger
|
||||
v-for="client in clients"
|
||||
:key="client.id"
|
||||
:value="client.id"
|
||||
class="bg-transparency-white-t4 focus-visible:ring-primary-comfy-yellow/50 data-[state=active]:bg-primary-comfy-yellow cursor-pointer rounded-full px-4 py-2 text-xs font-bold tracking-wider text-smoke-700 uppercase transition-colors hover:text-primary-comfy-canvas focus-visible:ring-2 focus-visible:outline-none data-[state=active]:text-primary-comfy-ink"
|
||||
>
|
||||
{{ client.name }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
v-for="client in clients"
|
||||
:key="client.id"
|
||||
:value="client.id"
|
||||
class="mt-6 flex min-h-36 flex-col gap-3"
|
||||
class="mt-4 flex min-h-24 flex-col gap-3"
|
||||
>
|
||||
<p class="text-sm text-smoke-700">
|
||||
{{ client.step
|
||||
@@ -192,57 +173,8 @@ const copiedLabel = t('ui.copied', locale)
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-transparency-white-t4 flex flex-col rounded-3xl"
|
||||
:class="
|
||||
activeClient.showAgentCard
|
||||
? 'p-6 lg:p-8'
|
||||
: 'relative overflow-hidden max-lg:aspect-video'
|
||||
"
|
||||
>
|
||||
<template v-if="activeClient.showAgentCard">
|
||||
<h3
|
||||
class="text-xl font-light text-primary-comfy-canvas lg:text-2xl"
|
||||
>
|
||||
{{ t('mcp.setup.agent.title', locale) }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ t('mcp.setup.agent.description', locale) }}
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<CopyableField
|
||||
:value="agentCommand"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-6 text-sm text-smoke-700">
|
||||
{{ t('mcp.setup.skillsNote', locale)
|
||||
}}<a
|
||||
:href="externalLinks.mcpSkills"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
|
||||
>{{ t('mcp.setup.skillsLink', locale) }}</a
|
||||
>
|
||||
</p>
|
||||
</template>
|
||||
<VideoPlayer
|
||||
v-else-if="activeClient.video"
|
||||
:key="activeClient.id"
|
||||
:locale="locale"
|
||||
:aria-label="walkthroughLabel"
|
||||
:src="activeClient.video"
|
||||
autoplay
|
||||
loop
|
||||
hide-controls
|
||||
fit="contain"
|
||||
class="absolute inset-0 size-full bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -8,13 +8,11 @@ export class ComfyActionbar {
|
||||
public readonly root: Locator
|
||||
public readonly queueButton: ComfyQueueButton
|
||||
public readonly propertiesButton: Locator
|
||||
public readonly dragHandle: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.root = page.locator('.actionbar-container')
|
||||
this.queueButton = new ComfyQueueButton(this)
|
||||
this.propertiesButton = this.root.getByLabel('Toggle properties panel')
|
||||
this.dragHandle = this.root.locator('.drag-handle')
|
||||
}
|
||||
|
||||
async isDocked() {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class FreeTierQuota {
|
||||
readonly root: Locator
|
||||
|
||||
constructor(comfyPage: ComfyPage) {
|
||||
this.root = comfyPage.page.getByTestId(TestIds.topbar.freeTierQuota)
|
||||
}
|
||||
|
||||
async getMax() {
|
||||
const text = await this.root.textContent()
|
||||
return text?.match(/(\d+) \/ (\d+)/)?.[2]
|
||||
}
|
||||
async getAvailable() {
|
||||
const text = await this.root.textContent()
|
||||
return text?.match(/(\d+) \/ (\d+)/)?.[1]
|
||||
}
|
||||
}
|
||||
@@ -58,17 +58,6 @@ class MaskEditorHelper {
|
||||
return dialog
|
||||
}
|
||||
|
||||
async reopenDialog(): Promise<Locator> {
|
||||
const imagePreview = this.page.locator('.image-preview').first()
|
||||
await imagePreview.getByRole('region').hover()
|
||||
await this.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
const dialog = this.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
async drawStrokeOnPointerZone(dialog: Locator) {
|
||||
const pointerZone = dialog.getByTestId('pointer-zone')
|
||||
await expect(pointerZone).toBeVisible()
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { PartnerNodePolicyResponse } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { PromptResponse } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as base } from '@e2e/fixtures/ComfyPage'
|
||||
import { WORKSPACE_FEATURE_FLAG } from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
const DISABLED_NODE = 'DisabledPartnerNode'
|
||||
|
||||
interface PartnerNodeGovernanceFixture {
|
||||
promptRequestCount: () => number
|
||||
}
|
||||
|
||||
function disabledPartnerNode(): ComfyNodeDef {
|
||||
return {
|
||||
name: DISABLED_NODE,
|
||||
display_name: 'Disabled Partner Node',
|
||||
category: 'partner/image/Acme',
|
||||
python_module: 'comfy_api_nodes.acme',
|
||||
description: '',
|
||||
input: {},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
output_node: true,
|
||||
api_node: true
|
||||
}
|
||||
}
|
||||
|
||||
async function setupGovernedWorkspace(page: Page) {
|
||||
await new CloudWorkspaceMockHelper(page).setup()
|
||||
await page.route('**/api/features', (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
...WORKSPACE_FEATURE_FLAG,
|
||||
partner_node_governance_enabled: true
|
||||
} satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/object_info', (route) =>
|
||||
route.fulfill(jsonRoute({ [DISABLED_NODE]: disabledPartnerNode() }))
|
||||
)
|
||||
await page.route(/\/api\/assets(?:\?.*)?$/, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
assets: [],
|
||||
total: 0,
|
||||
has_more: false
|
||||
} satisfies ListAssetsResponse)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/workspace/partner-node-policy', (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
enforcement_enabled: true,
|
||||
nodes: { [DISABLED_NODE]: false }
|
||||
} satisfies PartnerNodePolicyResponse)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export const partnerNodeGovernanceTest = base.extend<{
|
||||
partnerNodeGovernance: PartnerNodeGovernanceFixture
|
||||
}>({
|
||||
partnerNodeGovernance: async ({ page }, use) => {
|
||||
await setupGovernedWorkspace(page)
|
||||
let promptRequests = 0
|
||||
await page.route('**/api/prompt', (route) => {
|
||||
promptRequests++
|
||||
return route.fulfill(
|
||||
jsonRoute({
|
||||
prompt_id: 'unexpected-prompt',
|
||||
node_errors: {},
|
||||
error: ''
|
||||
} satisfies PromptResponse)
|
||||
)
|
||||
})
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
await page.evaluate((nodeType) => {
|
||||
const node = window.LiteGraph!.createNode(nodeType)
|
||||
if (!node) throw new Error(`Failed to create ${nodeType}`)
|
||||
window.app!.rootGraph.add(node)
|
||||
}, DISABLED_NODE)
|
||||
|
||||
await use({ promptRequestCount: () => promptRequests })
|
||||
}
|
||||
})
|
||||
@@ -103,8 +103,7 @@ export const TestIds = {
|
||||
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
|
||||
workflowTabs: 'topbar-workflow-tabs',
|
||||
integratedTabBarActions: 'integrated-tab-bar-actions',
|
||||
actionBarButtons: 'action-bar-buttons',
|
||||
freeTierQuota: 'free-tier-quota'
|
||||
actionBarButtons: 'action-bar-buttons'
|
||||
},
|
||||
nodeLibrary: {
|
||||
bookmarksSection: 'node-library-bookmarks-section'
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { PartnerNodePolicyResponse } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { WORKSPACE_FEATURE_FLAG } from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
function partnerNode(
|
||||
name: string,
|
||||
displayName: string,
|
||||
provider: string
|
||||
): ComfyNodeDef {
|
||||
return {
|
||||
name,
|
||||
display_name: displayName,
|
||||
category: `partner/image/${provider}`,
|
||||
python_module: `comfy_api_nodes.${provider.toLocaleLowerCase()}`,
|
||||
description: '',
|
||||
input: {},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
output_node: false,
|
||||
api_node: true
|
||||
}
|
||||
}
|
||||
|
||||
async function openAllowlist(page: Page) {
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
await page
|
||||
.getByRole('button', { name: /^Settings/ })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
const dialog = page.getByTestId('settings-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
|
||||
|
||||
const content = dialog.getByRole('main')
|
||||
await content.getByRole('tab', { name: 'Allowlist' }).click()
|
||||
await expect(
|
||||
content.getByRole('heading', { name: 'Partner nodes' })
|
||||
).toBeVisible()
|
||||
return content
|
||||
}
|
||||
|
||||
test.describe('Partner node allowlist', { tag: '@cloud' }, () => {
|
||||
test.describe.configure({ timeout: 60_000 })
|
||||
|
||||
test('saves enforcement and allowlist as one policy', async ({ page }) => {
|
||||
await new CloudWorkspaceMockHelper(page).setup()
|
||||
|
||||
await page.route('**/api/features', (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
...WORKSPACE_FEATURE_FLAG,
|
||||
partner_node_governance_enabled: true
|
||||
} satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/object_info', (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
FluxFill: partnerNode('FluxFill', 'Flux Fill', 'BFL'),
|
||||
FluxExpand: partnerNode('FluxExpand', 'Flux Expand', 'BFL'),
|
||||
VeoVideo: partnerNode('VeoVideo', 'Veo Video', 'Google')
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route(/\/api\/assets(?:\?.*)?$/, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
assets: [],
|
||||
total: 0,
|
||||
has_more: false
|
||||
} satisfies ListAssetsResponse)
|
||||
)
|
||||
)
|
||||
|
||||
let policy: PartnerNodePolicyResponse = {
|
||||
enforcement_enabled: false,
|
||||
nodes: { FluxFill: false, FluxExpand: true, VeoVideo: true }
|
||||
} satisfies PartnerNodePolicyResponse
|
||||
const updates: unknown[] = []
|
||||
await page.route('**/api/workspace/partner-node-policy', (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
policy = route.request().postDataJSON() as PartnerNodePolicyResponse
|
||||
updates.push(policy)
|
||||
}
|
||||
return route.fulfill(jsonRoute(policy))
|
||||
})
|
||||
|
||||
const content = await openAllowlist(page)
|
||||
const fluxFill = content.getByRole('switch', { name: 'Allow Flux Fill' })
|
||||
await expect(fluxFill).not.toBeChecked()
|
||||
await expect(
|
||||
content.getByRole('switch', { name: 'Allow Flux Expand' })
|
||||
).toBeChecked()
|
||||
|
||||
await content
|
||||
.getByRole('switch', { name: 'Enforce partner node allowlist' })
|
||||
.click()
|
||||
await fluxFill.click()
|
||||
await content.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(page.getByText('Partner node policy saved')).toBeVisible()
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
enforcement_enabled: true,
|
||||
nodes: { FluxFill: true, FluxExpand: true, VeoVideo: true }
|
||||
}
|
||||
] satisfies PartnerNodePolicyResponse[])
|
||||
})
|
||||
})
|
||||
@@ -28,11 +28,11 @@ 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'
|
||||
|
||||
// billing_control_enabled routes personal workspaces to the unified pricing
|
||||
// table asserted here; without it they fall back to the legacy table.
|
||||
// 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,
|
||||
billing_control_enabled: true
|
||||
consolidated_billing_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
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
import { FreeTierQuota } from '@e2e/fixtures/components/FreeTierQuota'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Free Tier Quota', { tag: ['@cloud', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const features = {
|
||||
free_tier_job_allowance_enabled: true,
|
||||
free_tier_balance: { allowance: 5, remaining: 3, used: 0 }
|
||||
}
|
||||
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(features)))
|
||||
})
|
||||
|
||||
wstest('Free Tier Quota', async ({ comfyPage, comfyMouse, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
const freeTierQuota = new FreeTierQuota(comfyPage)
|
||||
|
||||
await test.step('Populates initial state from config', async () => {
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe('3')
|
||||
expect(await freeTierQuota.getMax()).toBe('5')
|
||||
})
|
||||
|
||||
await test.step('available decrements on run', async () => {
|
||||
await execution.run()
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
|
||||
})
|
||||
|
||||
await test.step('connects to detached run button', async () => {
|
||||
const handle = comfyPage.actionbar.dragHandle
|
||||
await comfyMouse.dragElementBy(handle, { x: -100, y: 100 })
|
||||
await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(false)
|
||||
expect(await freeTierQuota.getAvailable()).toBe('2')
|
||||
await comfyMouse.dragElementBy(handle, { x: 100, y: -100 })
|
||||
await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(true)
|
||||
})
|
||||
|
||||
await test.step('Detects workflows with Partner nodes', async () => {
|
||||
await comfyPage.searchBoxV2.addNode('Node With Price Badge')
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Price Badge')
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
|
||||
await node.delete()
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
|
||||
})
|
||||
|
||||
await test.step('Does not decrease past 0', async () => {
|
||||
await execution.run()
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe('1')
|
||||
await execution.run()
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
|
||||
await execution.run()
|
||||
await execution.run()
|
||||
await execution.run()
|
||||
await comfyPage.nextFrame()
|
||||
expect(await freeTierQuota.getAvailable()).toBe(undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -67,22 +67,6 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Reopening the editor after save restores the drawn mask', async ({
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
const savedMask = await maskEditor.getCanvasSnapshot(MASK_CANVAS_INDEX)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
await maskEditor.reopenDialog()
|
||||
await expect
|
||||
.poll(() => maskEditor.getCanvasSnapshot(MASK_CANVAS_INDEX))
|
||||
.toBe(savedMask)
|
||||
})
|
||||
|
||||
test('Save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { PartnerNodePolicyResponse } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { WORKSPACE_FEATURE_FLAG } from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
function partnerNode(name: string): ComfyNodeDef {
|
||||
return {
|
||||
name,
|
||||
display_name: name,
|
||||
category: 'partner/image/Acme',
|
||||
python_module: 'comfy_api_nodes.acme',
|
||||
description: '',
|
||||
input: {},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
output_node: false,
|
||||
api_node: true
|
||||
}
|
||||
}
|
||||
|
||||
async function setupGovernedWorkspace(page: Page) {
|
||||
await new CloudWorkspaceMockHelper(page).setup()
|
||||
await page.route('**/api/features', (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
...WORKSPACE_FEATURE_FLAG,
|
||||
partner_node_governance_enabled: true
|
||||
} satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/object_info', (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
AllowedPartnerNode: partnerNode('AllowedPartnerNode'),
|
||||
DisabledPartnerNode: partnerNode('DisabledPartnerNode')
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route(/\/api\/assets(?:\?.*)?$/, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
assets: [],
|
||||
total: 0,
|
||||
has_more: false
|
||||
} satisfies ListAssetsResponse)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/workspace/partner-node-policy', (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
enforcement_enabled: true,
|
||||
nodes: {
|
||||
AllowedPartnerNode: true,
|
||||
DisabledPartnerNode: false
|
||||
}
|
||||
} satisfies PartnerNodePolicyResponse)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Partner node governance discovery', { tag: '@cloud' }, () => {
|
||||
test('hides disabled nodes from search', async ({ page }) => {
|
||||
await setupGovernedWorkspace(page)
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
await page.evaluate(async () => {
|
||||
await window.app!.extensionManager.setting.set(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'default'
|
||||
)
|
||||
await window.app!.extensionManager.command.execute(
|
||||
'Workspace.SearchBox.Toggle'
|
||||
)
|
||||
})
|
||||
const search = page.getByRole('search')
|
||||
await expect(search).toBeVisible()
|
||||
await search.getByRole('combobox').fill('PartnerNode')
|
||||
|
||||
await expect(search.getByText('AllowedPartnerNode')).toBeVisible()
|
||||
await expect(search.getByText('DisabledPartnerNode')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { partnerNodeGovernanceTest as test } from '@e2e/fixtures/partnerNodeGovernanceFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Partner node governance workbench', { tag: '@cloud' }, () => {
|
||||
test('blocks queueing a disabled partner node', async ({
|
||||
page,
|
||||
partnerNodeGovernance
|
||||
}) => {
|
||||
await page.getByTestId(TestIds.topbar.queueButton).click()
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText(
|
||||
'Workflow blocked by workspace policy'
|
||||
)
|
||||
expect(partnerNodeGovernance.promptRequestCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -29,8 +29,6 @@
|
||||
"dev:test": "cross-env VITE_USE_LEGACY_DEFAULT_GRAPH=true vite --config vite.config.mts",
|
||||
"dev": "vite --config vite.config.mts",
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
||||
"fallow": "fallow",
|
||||
"fallow:audit": "fallow audit",
|
||||
"format:check": "oxfmt --check",
|
||||
"format": "oxfmt --write",
|
||||
"json-schema": "tsx scripts/generate-json-schema.ts",
|
||||
@@ -174,7 +172,6 @@
|
||||
"eslint-plugin-testing-library": "catalog:",
|
||||
"eslint-plugin-unused-imports": "catalog:",
|
||||
"eslint-plugin-vue": "catalog:",
|
||||
"fallow": "catalog:",
|
||||
"fast-check": "catalog:",
|
||||
"fs-extra": "^11.2.0",
|
||||
"globals": "catalog:",
|
||||
|
||||
88
pnpm-lock.yaml
generated
88
pnpm-lock.yaml
generated
@@ -240,9 +240,6 @@ catalogs:
|
||||
eslint-plugin-vue:
|
||||
specifier: ^10.9.1
|
||||
version: 10.9.1
|
||||
fallow:
|
||||
specifier: ^2.102.0
|
||||
version: 2.102.0
|
||||
fast-check:
|
||||
specifier: ^4.5.3
|
||||
version: 4.5.3
|
||||
@@ -766,9 +763,6 @@ importers:
|
||||
eslint-plugin-vue:
|
||||
specifier: 'catalog:'
|
||||
version: 10.9.1(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.4.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.4.0(jiti@2.6.1)))
|
||||
fallow:
|
||||
specifier: 'catalog:'
|
||||
version: 2.102.0
|
||||
fast-check:
|
||||
specifier: 'catalog:'
|
||||
version: 4.5.3
|
||||
@@ -1914,46 +1908,6 @@ packages:
|
||||
'@exodus/crypto':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/darwin-arm64@2.102.0':
|
||||
resolution: {integrity: sha512-B8wzfzJgoX6h5Gv2xQ9ZidO5Jb8/PWdssAxYbWs1pb5oJHZ6S5PLwXuUdINmNSIaRGQwTk4DC9/tIMFHFvd9uw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@fallow-cli/darwin-x64@2.102.0':
|
||||
resolution: {integrity: sha512-aLbTWWzQnleKdi56obAPXMJm7YA3qAnkIX9T3eocRHiagYqp8nsf4cslM0rZKvu2WwK34NaBm8x886gl4cl+zg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@fallow-cli/linux-arm64-gnu@2.102.0':
|
||||
resolution: {integrity: sha512-8nYeOSLSewqcKH/KUcKZaCq5QII5VTRX62l60B6UM1iKe/0jcmlQ2mOtx4rkHpeq3LL5emvT/ph4NNgGmWKSBg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/linux-arm64-musl@2.102.0':
|
||||
resolution: {integrity: sha512-2Zi33PXzZxD7HU5sPVwyElZGP7zyEdGo4hK0ewy6gMYgQ9BDfLnFhgaSSOzN1J4paIhYtBmVsLmqakyDKy22Jw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/linux-x64-gnu@2.102.0':
|
||||
resolution: {integrity: sha512-7Hys4X6hKuR/lqUaGXwezRzDrwXwu9KfahUy85WTuiG1to+ZbzDCqdbZ04LtnI8kK8ufrPDcq+ZXdyt5ksOJHA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/linux-x64-musl@2.102.0':
|
||||
resolution: {integrity: sha512-RuDY1jOEPgJOuHBgEpHVl6J7Xf2QLFklnNy6zO1nw8R1fLgWUAAKlFirn1Y93pb9bXzqpn5Gre3YlhgIZ3+LBA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/win32-arm64-msvc@2.102.0':
|
||||
resolution: {integrity: sha512-wsvHjLzWFvsYmCqnQLm1doSEHb9Z038+sFp1RzMcffPUBC5tqS3vCr8J8nolcLlPk7o3QWHQMXAqb1xSJe+doA==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@fallow-cli/win32-x64-msvc@2.102.0':
|
||||
resolution: {integrity: sha512-rH1hd0PD0mm6pCxh1pw5jubpJsvV6f5rjixMoD5AZLzWa6NPJBpPPuzruLGtH/9CYy8B0y7zPrGGTKRO2PCGzg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@firebase/analytics-compat@0.2.18':
|
||||
resolution: {integrity: sha512-Hw9mzsSMZaQu6wrTbi3kYYwGw9nBqOHr47pVLxfr5v8CalsdrG5gfs9XUlPOZjHRVISp3oQrh1j7d3E+ulHPjQ==}
|
||||
peerDependencies:
|
||||
@@ -5728,11 +5682,6 @@ packages:
|
||||
extendable-media-recorder@9.2.27:
|
||||
resolution: {integrity: sha512-2X+Ixi1cxLek0Cj9x9atmhQ+apG+LwJpP2p3ypP8Pxau0poDnicrg7FTfPVQV5PW/3DHFm/eQ16vbgo5Yk3HGQ==}
|
||||
|
||||
fallow@2.102.0:
|
||||
resolution: {integrity: sha512-bkOT58kPVCB12d2apQjIKBw/qSdsGRPQFrN5ff9Yl5WzXRqlDTbT/MVdMXld4sJD5JQW1ftw2bTxJWCINggh6g==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
fast-check@4.5.3:
|
||||
resolution: {integrity: sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==}
|
||||
engines: {node: '>=12.17.0'}
|
||||
@@ -10095,30 +10044,6 @@ snapshots:
|
||||
|
||||
'@exodus/bytes@1.7.0': {}
|
||||
|
||||
'@fallow-cli/darwin-arm64@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/darwin-x64@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-arm64-gnu@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-arm64-musl@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-x64-gnu@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-x64-musl@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/win32-arm64-msvc@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/win32-x64-msvc@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@firebase/analytics-compat@0.2.18(@firebase/app-compat@0.2.53)(@firebase/app@0.11.4)':
|
||||
dependencies:
|
||||
'@firebase/analytics': 0.10.12(@firebase/app@0.11.4)
|
||||
@@ -14170,19 +14095,6 @@ snapshots:
|
||||
subscribable-things: 2.1.53
|
||||
tslib: 2.8.1
|
||||
|
||||
fallow@2.102.0:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
'@fallow-cli/darwin-arm64': 2.102.0
|
||||
'@fallow-cli/darwin-x64': 2.102.0
|
||||
'@fallow-cli/linux-arm64-gnu': 2.102.0
|
||||
'@fallow-cli/linux-arm64-musl': 2.102.0
|
||||
'@fallow-cli/linux-x64-gnu': 2.102.0
|
||||
'@fallow-cli/linux-x64-musl': 2.102.0
|
||||
'@fallow-cli/win32-arm64-msvc': 2.102.0
|
||||
'@fallow-cli/win32-x64-msvc': 2.102.0
|
||||
|
||||
fast-check@4.5.3:
|
||||
dependencies:
|
||||
pure-rand: 7.0.1
|
||||
|
||||
@@ -89,7 +89,6 @@ catalog:
|
||||
eslint-plugin-testing-library: ^7.16.1
|
||||
eslint-plugin-unused-imports: ^4.4.1
|
||||
eslint-plugin-vue: ^10.9.1
|
||||
fallow: ^2.102.0
|
||||
fast-check: ^4.5.3
|
||||
firebase: ^11.6.0
|
||||
glob: ^13.0.6
|
||||
|
||||
@@ -629,7 +629,7 @@ describe('TopMenuSection', () => {
|
||||
await nextTick()
|
||||
|
||||
expect(querySpy).toHaveBeenCalledTimes(1)
|
||||
expect(actionbarContainer!.classList).not.toContain('w-0')
|
||||
expect(actionbarContainer!.classList).toContain('px-2')
|
||||
} finally {
|
||||
unmount()
|
||||
vi.unstubAllGlobals()
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mx-1 flex flex-col items-end gap-1">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-if="managerState.shouldShowManagerButtons.value || isCloud"
|
||||
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
@@ -34,75 +34,61 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pointer-events-auto z-1 flex flex-col rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 py-1.75 shadow-interface"
|
||||
>
|
||||
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="actionbarContainerRef"
|
||||
:class="
|
||||
cn(
|
||||
'actionbar-container relative flex items-center gap-2',
|
||||
isActionbarContainerEmpty &&
|
||||
'-ml-2 w-0 min-w-0 border-transparent shadow-none has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
|
||||
)
|
||||
"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
data-testid="legacy-topbar-container"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
ref="legacyCommandsContainerRef"
|
||||
data-testid="legacy-topbar-container"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
|
||||
<ComfyActionbar
|
||||
:top-menu-container="actionbarContainerRef"
|
||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
@update:progress-target="updateProgressTarget"
|
||||
/>
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
|
||||
<ComfyActionbar
|
||||
:top-menu-container="actionbarContainerRef"
|
||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
@update:progress-target="updateProgressTarget"
|
||||
/>
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.bottom="shareTooltipConfig"
|
||||
variant="secondary"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[comfy--send] size-4" />
|
||||
<span class="not-md:hidden">
|
||||
{{ t('actionbar.share') }}
|
||||
</span>
|
||||
</Button>
|
||||
<div v-if="!isRightSidePanelOpen" class="relative">
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.bottom="shareTooltipConfig"
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
:class="
|
||||
cn(
|
||||
showErrorIndicatorOnPanelButton &&
|
||||
'outline-1 outline-destructive-background'
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
size="icon"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="openRightSidePanel"
|
||||
>
|
||||
<i class="icon-[comfy--send] size-4" />
|
||||
<span class="not-md:hidden">
|
||||
{{ t('actionbar.share') }}
|
||||
</span>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</Button>
|
||||
<div v-if="!isRightSidePanelOpen" class="relative">
|
||||
<Button
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
:class="
|
||||
cn(
|
||||
showErrorIndicatorOnPanelButton &&
|
||||
'outline-1 outline-destructive-background'
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="openRightSidePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</Button>
|
||||
<StatusBadge
|
||||
v-if="showErrorIndicatorOnPanelButton"
|
||||
variant="dot"
|
||||
severity="danger"
|
||||
class="absolute -top-1 -right-1"
|
||||
/>
|
||||
</div>
|
||||
<StatusBadge
|
||||
v-if="showErrorIndicatorOnPanelButton"
|
||||
variant="dot"
|
||||
severity="danger"
|
||||
class="absolute -top-1 -right-1"
|
||||
/>
|
||||
</div>
|
||||
<FreeTierQuota v-if="!isActionbarFloating" />
|
||||
</div>
|
||||
</div>
|
||||
<ErrorOverlay />
|
||||
@@ -161,7 +147,6 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -224,6 +209,21 @@ const hasDockedButtons = computed(() => {
|
||||
const isActionbarContainerEmpty = computed(
|
||||
() => isActionbarFloating.value && !hasDockedButtons.value
|
||||
)
|
||||
const actionbarContainerClass = computed(() => {
|
||||
const base =
|
||||
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
|
||||
|
||||
if (isActionbarContainerEmpty.value) {
|
||||
return cn(
|
||||
base,
|
||||
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
|
||||
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
|
||||
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
|
||||
)
|
||||
}
|
||||
|
||||
return cn(base, 'px-2', 'border-interface-stroke')
|
||||
})
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
||||
)
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
</Button>
|
||||
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
|
||||
</div>
|
||||
<FreeTierQuota v-if="!isDocked" />
|
||||
</Panel>
|
||||
|
||||
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
|
||||
@@ -110,7 +109,6 @@ import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
@@ -4,11 +4,11 @@ import { nextTick, ref } from 'vue'
|
||||
|
||||
import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue'
|
||||
|
||||
const mockCanRunWorkflows = ref(true)
|
||||
const mockIsActiveSubscription = ref(true)
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
canRunWorkflows: mockCanRunWorkflows
|
||||
isActiveSubscription: mockIsActiveSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -32,7 +32,7 @@ function renderWrapper() {
|
||||
|
||||
describe('CloudRunButtonWrapper', () => {
|
||||
beforeEach(() => {
|
||||
mockCanRunWorkflows.value = true
|
||||
mockIsActiveSubscription.value = true
|
||||
})
|
||||
|
||||
it('renders the runnable queue button when the subscription is active', () => {
|
||||
@@ -45,7 +45,7 @@ describe('CloudRunButtonWrapper', () => {
|
||||
})
|
||||
|
||||
it('locks the run button when the subscription is inactive', () => {
|
||||
mockCanRunWorkflows.value = false
|
||||
mockIsActiveSubscription.value = false
|
||||
renderWrapper()
|
||||
|
||||
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
|
||||
@@ -53,12 +53,12 @@ describe('CloudRunButtonWrapper', () => {
|
||||
})
|
||||
|
||||
it('unlocks the run button once the subscription becomes active again', async () => {
|
||||
mockCanRunWorkflows.value = false
|
||||
mockIsActiveSubscription.value = false
|
||||
renderWrapper()
|
||||
|
||||
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
|
||||
|
||||
mockCanRunWorkflows.value = true
|
||||
mockIsActiveSubscription.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('queue-button')).toBeInTheDocument()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<component
|
||||
:is="currentButton"
|
||||
:key="canRunWorkflows ? 'queue' : 'subscribe'"
|
||||
:key="isActiveSubscription ? 'queue' : 'subscribe'"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
@@ -11,9 +11,9 @@ import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueBu
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
|
||||
const { canRunWorkflows } = useBillingContext()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
|
||||
const currentButton = computed(() =>
|
||||
canRunWorkflows.value ? ComfyQueueButton : SubscribeToRunButton
|
||||
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -112,13 +112,5 @@ export interface BillingContext extends BillingState, BillingActions {
|
||||
* (legacy) per-member tier plan, which keeps the old team pricing table.
|
||||
*/
|
||||
isLegacyTeamPlan: ComputedRef<boolean>
|
||||
/**
|
||||
* True when the subscription is a team plan of either generation. Unlike
|
||||
* `isLegacyTeamPlan` this does not require an active subscription: the spend
|
||||
* gate folds billing_status into is_active, so a paused or payment-failed team
|
||||
* plan reports is_active=false and must still read as a team plan.
|
||||
*/
|
||||
isTeamPlan: ComputedRef<boolean>
|
||||
getMaxSeats: (tierKey: TierKey) => number
|
||||
canRunWorkflows: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
|
||||
|
||||
const {
|
||||
mockTeamWorkspacesEnabled,
|
||||
mockBillingControlEnabled,
|
||||
mockConsolidatedBillingEnabled,
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits,
|
||||
@@ -27,7 +27,7 @@ const {
|
||||
mockBillingStatus
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockBillingControlEnabled: { value: false },
|
||||
mockConsolidatedBillingEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn(),
|
||||
@@ -59,11 +59,13 @@ vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
teamWorkspacesEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
const billingControlEnabledRef = ref(mockBillingControlEnabled.value)
|
||||
Object.defineProperty(mockBillingControlEnabled, 'value', {
|
||||
get: () => billingControlEnabledRef.value,
|
||||
const consolidatedBillingEnabledRef = ref(
|
||||
mockConsolidatedBillingEnabled.value
|
||||
)
|
||||
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
|
||||
get: () => consolidatedBillingEnabledRef.value,
|
||||
set: (value: boolean) => {
|
||||
billingControlEnabledRef.value = value
|
||||
consolidatedBillingEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
@@ -72,8 +74,8 @@ vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
},
|
||||
get billingControlEnabled() {
|
||||
return mockBillingControlEnabled.value
|
||||
get consolidatedBillingEnabled() {
|
||||
return mockConsolidatedBillingEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -163,7 +165,7 @@ describe('useBillingContext', () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockBillingControlEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
mockPlans.value = []
|
||||
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
|
||||
@@ -175,27 +177,27 @@ describe('useBillingContext', () => {
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('keeps personal on legacy when billing control is disabled', () => {
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('selects workspace type for personal when billing control is enabled', () => {
|
||||
it('selects workspace type for personal when consolidated billing is enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('workspace')
|
||||
})
|
||||
|
||||
it('selects workspace type for team regardless of billing control', () => {
|
||||
it('selects workspace type for team regardless of consolidated billing', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { type } = useBillingContext()
|
||||
@@ -296,7 +298,7 @@ describe('useBillingContext', () => {
|
||||
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
|
||||
// Authenticated remote config resolves the flag on for the same workspace
|
||||
mockBillingControlEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -305,16 +307,16 @@ describe('useBillingContext', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('moves a personal workspace to workspace billing when billing control flips on', async () => {
|
||||
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
await nextTick()
|
||||
expect(type.value).toBe('legacy')
|
||||
|
||||
mockBillingControlEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(type.value).toBe('workspace')
|
||||
@@ -323,9 +325,9 @@ describe('useBillingContext', () => {
|
||||
})
|
||||
|
||||
describe('subscription mirror to workspace store', () => {
|
||||
it('mirrors subscription for personal workspaces on the billing control flow', async () => {
|
||||
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
@@ -553,110 +555,4 @@ describe('useBillingContext', () => {
|
||||
expect(isLegacyTeamPlan.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTeamPlan', () => {
|
||||
it('is false for a personal workspace', () => {
|
||||
const { isTeamPlan } = useBillingContext()
|
||||
expect(isTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
// subscription_tier is omitted throughout: the backend sends 'TEAM' here, but
|
||||
// the FE's SubscriptionTier resolves to the registry spec, which has no TEAM
|
||||
// (tierPricing.ts imports comfyRegistryTypes for what is an ingest field).
|
||||
// isTeamPlan reads the credit stop and the slug, never the tier — which is
|
||||
// what keeps it working despite that divergence.
|
||||
it('is true for a credit-slider team sub, which carries a credit stop', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
plan_slug: 'team_per_credit_monthly',
|
||||
team_credit_stop: {
|
||||
id: 'team_700',
|
||||
credits_monthly: 700,
|
||||
stop_usd: 332
|
||||
}
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is true for a legacy team sub, identified by slug rather than credit stop', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
plan_slug: 'team-standard-annual'
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
// The banner states that need isTeamPlan most — paused and payment_failed —
|
||||
// are exactly the ones the backend reports with is_active=false, because the
|
||||
// spend gate folds billing_status into it. Coupling isTeamPlan to an active
|
||||
// subscription would blank the banner precisely when it is needed.
|
||||
it('stays true for a paused team plan, which the backend reports inactive', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: false,
|
||||
has_funds: true,
|
||||
billing_status: 'paused',
|
||||
plan_slug: 'team_per_credit_monthly',
|
||||
team_credit_stop: {
|
||||
id: 'team_700',
|
||||
credits_monthly: 700,
|
||||
stop_usd: 332
|
||||
}
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('stays true for a legacy team plan whose payment failed', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: false,
|
||||
has_funds: true,
|
||||
billing_status: 'payment_failed',
|
||||
subscription_tier: 'STANDARD',
|
||||
plan_slug: 'team-standard-annual'
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for a team workspace on a personal-tier plan', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
plan_slug: 'pro-monthly'
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
getTierFeatures
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
PreviewSubscribeOptions,
|
||||
@@ -36,8 +35,8 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
*
|
||||
* - 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 billing control is enabled;
|
||||
* personal workspaces otherwise stay on legacy billing
|
||||
* workspaces, and for personal workspaces once consolidated billing is
|
||||
* enabled; personal workspaces otherwise stay on legacy billing
|
||||
*
|
||||
* The context automatically initializes when the workspace changes and provides
|
||||
* a unified interface for subscription status, balance, and billing actions.
|
||||
@@ -130,16 +129,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
|
||||
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
|
||||
|
||||
const freeTierQuota = useFreeTierQuota()
|
||||
|
||||
const canRunWorkflows = computed(
|
||||
() =>
|
||||
isActiveSubscription.value &&
|
||||
(!isFreeTier.value ||
|
||||
!freeTierQuota.quotaEnabled.value ||
|
||||
freeTierQuota.freeTierExecutionPermitted.value)
|
||||
)
|
||||
|
||||
const isLegacyTeamPlan = computed(
|
||||
() =>
|
||||
type.value === 'workspace' &&
|
||||
@@ -152,21 +141,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
false)
|
||||
)
|
||||
|
||||
// Plan identity, independent of subscription health: the per-credit Team plan
|
||||
// carries a credit stop, the retired seat-based ones a `team-` slug. Kept off
|
||||
// isActiveSubscription on purpose — paused and payment_failed both force
|
||||
// is_active=false, which is exactly when callers still need to know this is a
|
||||
// team plan.
|
||||
const isTeamPlan = computed(
|
||||
() =>
|
||||
type.value === 'workspace' &&
|
||||
(currentTeamCreditStop.value !== null ||
|
||||
(currentPlanSlug.value
|
||||
?.toLowerCase()
|
||||
.startsWith(LEGACY_TEAM_PLAN_SLUG_PREFIX) ??
|
||||
false))
|
||||
)
|
||||
|
||||
const billingStatus = computed(() =>
|
||||
toValue(activeContext.value.billingStatus)
|
||||
)
|
||||
@@ -217,9 +191,9 @@ function useBillingContextInternal(): BillingContext {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// type flips when the team-workspaces or billing-control flag resolves from
|
||||
// authenticated config, swapping the active backend. Reset then reinit on
|
||||
// every workspace-id or type change.
|
||||
// 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.
|
||||
watch(
|
||||
[() => store.activeWorkspace?.id, () => type.value],
|
||||
async ([newWorkspaceId]) => {
|
||||
@@ -323,10 +297,8 @@ function useBillingContextInternal(): BillingContext {
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
canRunWorkflows,
|
||||
isFreeTier,
|
||||
isLegacyTeamPlan,
|
||||
isTeamPlan,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useBillingRouting } from './useBillingRouting'
|
||||
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
|
||||
mockFlags: {
|
||||
teamWorkspacesEnabled: false,
|
||||
billingControlEnabled: false
|
||||
consolidatedBillingEnabled: false
|
||||
},
|
||||
mockActiveWorkspace: {
|
||||
value: null as { id: string; type: 'personal' | 'team' } | null
|
||||
@@ -30,7 +30,7 @@ const team = { id: 'w-team', type: 'team' as const }
|
||||
describe('useBillingRouting', () => {
|
||||
beforeEach(() => {
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockFlags.billingControlEnabled = false
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
})
|
||||
|
||||
@@ -44,9 +44,9 @@ describe('useBillingRouting', () => {
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps personal on legacy when billing control is disabled', () => {
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = false
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
@@ -54,9 +54,9 @@ describe('useBillingRouting', () => {
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('moves personal to workspace billing when billing control is enabled', () => {
|
||||
it('moves personal to workspace billing when consolidated billing is enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
@@ -65,9 +65,9 @@ describe('useBillingRouting', () => {
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces regardless of billing control', () => {
|
||||
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = false
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
@@ -76,9 +76,9 @@ describe('useBillingRouting', () => {
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces with billing control enabled', () => {
|
||||
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
@@ -89,7 +89,7 @@ describe('useBillingRouting', () => {
|
||||
|
||||
it('defaults to legacy while the workspace has not loaded', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = null
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 `billingControlEnabled`; team workspaces are always
|
||||
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
|
||||
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
|
||||
*/
|
||||
export function useBillingRouting() {
|
||||
@@ -23,7 +23,7 @@ export function useBillingRouting() {
|
||||
const workspaceType = workspaceStore.activeWorkspace?.type
|
||||
if (!workspaceType) return 'legacy'
|
||||
|
||||
if (workspaceType === 'personal' && !flags.billingControlEnabled) {
|
||||
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
|
||||
return 'legacy'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useMaskEditorLoader } from './useMaskEditorLoader'
|
||||
|
||||
// ---- Module Mocks ----
|
||||
|
||||
const mockDataStore: Record<string, unknown> = {
|
||||
inputData: null,
|
||||
sourceNode: null,
|
||||
setLoading: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('@/stores/maskEditorDataStore', () => ({
|
||||
useMaskEditorDataStore: vi.fn(() => mockDataStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: vi.fn(() => ({
|
||||
getNodeOutputs: vi.fn(() => undefined)
|
||||
}))
|
||||
}))
|
||||
|
||||
const distribution = vi.hoisted(() => ({ isCloud: false }))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distribution.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn(),
|
||||
apiURL: vi.fn((route: string) => `http://localhost:8188/api${route}`)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
getPreviewFormatParam: vi.fn(() => ''),
|
||||
getRandParam: vi.fn(() => '')
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock Image constructor so the loader's image fetches resolve without a
|
||||
// network. Records every requested URL; URLs matching failUrlPattern reject
|
||||
// like a 404 would (reset per test in beforeEach).
|
||||
const requestedUrls: string[] = []
|
||||
let failUrlPattern: RegExp | null = null
|
||||
|
||||
class MockImage {
|
||||
crossOrigin = ''
|
||||
onload: ((ev: Event) => void) | null = null
|
||||
onerror: ((ev: unknown) => void) | null = null
|
||||
private _src = ''
|
||||
get src() {
|
||||
return this._src
|
||||
}
|
||||
set src(value: string) {
|
||||
this._src = value
|
||||
requestedUrls.push(value)
|
||||
queueMicrotask(() => {
|
||||
if (failUrlPattern?.test(value)) this.onerror?.(new Event('error'))
|
||||
else this.onload?.(new Event('load'))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function createLoadImageNode(widgetValue: string): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id: 7,
|
||||
type: 'LoadImage',
|
||||
imgs: [{ src: 'http://localhost:8188/api/view?filename=whatever.png' }],
|
||||
images: undefined,
|
||||
widgets: [{ name: 'image', value: widgetValue }]
|
||||
})
|
||||
}
|
||||
|
||||
function subfolderOf(url: string): string | null {
|
||||
return new URL(url).searchParams.get('subfolder')
|
||||
}
|
||||
|
||||
function requestedLayerUrls(layerFilename: string): string[] {
|
||||
return requestedUrls.filter((url) => url.includes(layerFilename))
|
||||
}
|
||||
|
||||
describe('useMaskEditorLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
requestedUrls.length = 0
|
||||
failUrlPattern = null
|
||||
distribution.isCloud = false
|
||||
mockDataStore.inputData = null
|
||||
mockDataStore.sourceNode = null
|
||||
vi.stubGlobal('Image', MockImage)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('loads layer files from the input root for saves without a subfolder prefix', async () => {
|
||||
const node = createLoadImageNode('clipspace-painted-masked-123.png [input]')
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(node)
|
||||
|
||||
for (const layerFilename of [
|
||||
'clipspace-mask-123.png',
|
||||
'clipspace-paint-123.png'
|
||||
]) {
|
||||
const layerUrls = requestedLayerUrls(layerFilename)
|
||||
expect(layerUrls.length).toBeGreaterThan(0)
|
||||
for (const url of layerUrls) {
|
||||
expect(subfolderOf(url)).toBeNull()
|
||||
}
|
||||
}
|
||||
expect(mockDataStore.inputData).toMatchObject({
|
||||
sourceRef: { filename: 'clipspace-mask-123.png', type: 'input' },
|
||||
nodeId: 7
|
||||
})
|
||||
})
|
||||
|
||||
it('loads layer files from the clipspace subfolder for legacy saves', async () => {
|
||||
const node = createLoadImageNode(
|
||||
'clipspace/clipspace-painted-masked-123.png [input]'
|
||||
)
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(node)
|
||||
|
||||
for (const layerFilename of [
|
||||
'clipspace-mask-123.png',
|
||||
'clipspace-paint-123.png'
|
||||
]) {
|
||||
const layerUrls = requestedLayerUrls(layerFilename)
|
||||
expect(layerUrls.length).toBeGreaterThan(0)
|
||||
for (const url of layerUrls) {
|
||||
expect(subfolderOf(url)).toBe('clipspace')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the clipspace subfolder for cloud-resolved layer files', async () => {
|
||||
distribution.isCloud = true
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
painted_masked: 'hash-painted-masked.png',
|
||||
painted: 'hash-painted.png',
|
||||
paint: 'hash-paint.png',
|
||||
mask: 'hash-mask.png'
|
||||
})
|
||||
} as Response)
|
||||
const node = createLoadImageNode('clipspace-painted-masked-123.png [input]')
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(node)
|
||||
|
||||
for (const layerFilename of ['hash-painted-masked.png', 'hash-paint.png']) {
|
||||
const layerUrls = requestedLayerUrls(layerFilename)
|
||||
expect(layerUrls.length).toBeGreaterThan(0)
|
||||
for (const url of layerUrls) {
|
||||
expect(subfolderOf(url)).toBe('clipspace')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the node image when layer files are missing', async () => {
|
||||
failUrlPattern = /clipspace-(mask|paint)-123\.png/
|
||||
const node = createLoadImageNode('clipspace-painted-masked-123.png [input]')
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(node)
|
||||
|
||||
expect(mockDataStore.inputData).toMatchObject({
|
||||
sourceRef: { filename: 'clipspace-painted-masked-123.png' },
|
||||
paintLayer: undefined,
|
||||
nodeId: 7
|
||||
})
|
||||
})
|
||||
|
||||
it('still fails when the node image itself cannot be loaded', async () => {
|
||||
failUrlPattern = /./
|
||||
const node = createLoadImageNode('some-regular-image.png [input]')
|
||||
|
||||
await expect(useMaskEditorLoader().loadFromNode(node)).rejects.toThrow()
|
||||
expect(mockDataStore.setLoading).toHaveBeenLastCalledWith(
|
||||
false,
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -53,10 +53,10 @@ function imageLayerFilenamesIfApplicable(
|
||||
}
|
||||
}
|
||||
|
||||
function toRef(filename: string, subfolder: string): ImageRef {
|
||||
function toRef(filename: string): ImageRef {
|
||||
return {
|
||||
filename,
|
||||
subfolder,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
}
|
||||
}
|
||||
@@ -150,72 +150,34 @@ export function useMaskEditorLoader() {
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern-matched layer files live next to the painted-masked image the
|
||||
// widget points at: the input root for saves under the unified upload
|
||||
// contract (#12318), or the clipspace subfolder for saves made before
|
||||
// it. Cloud resolves the hash filenames from its mask-layers API
|
||||
// server-side, so its refs keep the subfolder they always used.
|
||||
const layerSubfolder = maskLayersFromApi
|
||||
? 'clipspace'
|
||||
: (nodeImageRef.subfolder ?? '')
|
||||
const baseImageUrl = imageLayerFilenames?.maskedImage
|
||||
? mkFileUrl({ ref: toRef(imageLayerFilenames.maskedImage) })
|
||||
: nodeImageUrl
|
||||
|
||||
const layeredBaseUrl = imageLayerFilenames?.maskedImage
|
||||
? mkFileUrl({
|
||||
ref: toRef(imageLayerFilenames.maskedImage, layerSubfolder)
|
||||
})
|
||||
: undefined
|
||||
const baseImageUrl = layeredBaseUrl ?? nodeImageUrl
|
||||
const sourceRef = imageLayerFilenames?.maskedImage
|
||||
? parseImageRef(baseImageUrl)
|
||||
: nodeImageRef
|
||||
|
||||
let paintLayerUrl: string | null = null
|
||||
if (maskLayersFromApi?.paint) {
|
||||
paintLayerUrl = mkFileUrl({
|
||||
ref: toRef(maskLayersFromApi.paint, layerSubfolder)
|
||||
})
|
||||
paintLayerUrl = mkFileUrl({ ref: toRef(maskLayersFromApi.paint) })
|
||||
} else if (imageLayerFilenames?.paint) {
|
||||
paintLayerUrl = mkFileUrl({
|
||||
ref: toRef(imageLayerFilenames.paint, layerSubfolder)
|
||||
})
|
||||
paintLayerUrl = mkFileUrl({ ref: toRef(imageLayerFilenames.paint) })
|
||||
}
|
||||
|
||||
const loadLayers = (baseUrl: string, paintUrl: string | null) =>
|
||||
Promise.all([
|
||||
loadImageLayer(baseUrl, 'rgb'),
|
||||
loadImageLayer(baseUrl, 'a'),
|
||||
paintUrl ? loadPaintLayer(paintUrl) : Promise.resolve(undefined)
|
||||
])
|
||||
|
||||
const loadInputData = async () => {
|
||||
try {
|
||||
const [baseLayer, maskLayer, paintLayer] = await loadLayers(
|
||||
baseImageUrl,
|
||||
paintLayerUrl
|
||||
)
|
||||
return {
|
||||
baseLayer,
|
||||
maskLayer,
|
||||
paintLayer,
|
||||
sourceRef: layeredBaseUrl
|
||||
? parseImageRef(baseImageUrl)
|
||||
: nodeImageRef
|
||||
}
|
||||
} catch (error) {
|
||||
if (!layeredBaseUrl) throw error
|
||||
console.warn(
|
||||
'[MaskEditorLoader] Failed to load editor layer files, falling back to the node image',
|
||||
error
|
||||
)
|
||||
const [baseLayer, maskLayer] = await loadLayers(nodeImageUrl, null)
|
||||
return {
|
||||
baseLayer,
|
||||
maskLayer,
|
||||
paintLayer: undefined,
|
||||
sourceRef: nodeImageRef
|
||||
}
|
||||
}
|
||||
}
|
||||
const [baseLayer, maskLayer, paintLayer] = await Promise.all([
|
||||
loadImageLayer(baseImageUrl, 'rgb'),
|
||||
loadImageLayer(baseImageUrl, 'a'),
|
||||
paintLayerUrl
|
||||
? loadPaintLayer(paintLayerUrl)
|
||||
: Promise.resolve(undefined)
|
||||
])
|
||||
|
||||
dataStore.inputData = {
|
||||
...(await loadInputData()),
|
||||
baseLayer,
|
||||
maskLayer,
|
||||
paintLayer,
|
||||
sourceRef,
|
||||
nodeId: node.id
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import { mapAllNodes } from '@/utils/graphTraversalUtil'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
type LinkedWidgetInput = INodeInputSlot & {
|
||||
_subgraphSlot?: SubgraphInput
|
||||
@@ -157,20 +150,3 @@ export const usePriceBadge = () => {
|
||||
updateSubgraphCredits
|
||||
}
|
||||
}
|
||||
export const useCreditsBadgesInGraph = createSharedComposable(() => {
|
||||
const { isCreditsBadge } = usePriceBadge()
|
||||
const vueNodeLifecycle = useVueNodeLifecycle()
|
||||
return computed(() => {
|
||||
void vueNodeLifecycle.nodeManager.value?.vueNodeData.size
|
||||
if (!app.graph) return []
|
||||
return mapAllNodes(app.graph, (node) => {
|
||||
if (node.isSubgraphNode()) return
|
||||
|
||||
const priceBadge = node.badges.find(isCreditsBadge)
|
||||
if (!priceBadge) return
|
||||
|
||||
trackNodePrice(node)
|
||||
return [node.title, toValue(priceBadge).text, node.id] as const
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@/composables/useFeatureFlags'
|
||||
import * as distributionTypes from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedBillingControlEnabled,
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
remoteConfig,
|
||||
remoteConfigState
|
||||
@@ -182,30 +182,6 @@ describe('useFeatureFlags', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('partnerNodeGovernanceEnabled', () => {
|
||||
afterEach(() => {
|
||||
remoteConfig.value = {}
|
||||
})
|
||||
|
||||
it('uses the workspace eligibility flag', () => {
|
||||
remoteConfig.value = { partner_node_governance_enabled: true }
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
expect(flags.partnerNodeGovernanceEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to false when the remote flag is unset', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(_path, defaultValue) => defaultValue
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
expect(flags.partnerNodeGovernanceEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dev override via localStorage', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
@@ -250,19 +226,19 @@ describe('useFeatureFlags', () => {
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('billingControlEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
localStorage.setItem('ff:billing_control_enabled', 'true')
|
||||
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('billingControlEnabled is false off-cloud even without an override', () => {
|
||||
it('consolidatedBillingEnabled is false off-cloud even without an override', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.billingControlEnabled).toBe(false)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -272,7 +248,7 @@ describe('useFeatureFlags', () => {
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedBillingControlEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
@@ -281,36 +257,36 @@ describe('useFeatureFlags', () => {
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedBillingControlEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns the cached session value during the auth window', () => {
|
||||
cachedTeamWorkspacesEnabled.value = false
|
||||
cachedBillingControlEnabled.value = true
|
||||
cachedConsolidatedBillingEnabled.value = true
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
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.billingControlEnabled).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,
|
||||
billing_control_enabled: true
|
||||
consolidated_billing_enabled: true
|
||||
}
|
||||
vi.mocked(api.getServerFeature).mockReturnValue(false)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
|
||||
@@ -319,14 +295,15 @@ describe('useFeatureFlags', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
|
||||
if (path === ServerFeatureFlag.BILLING_CONTROL_ENABLED) return true
|
||||
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
|
||||
return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Ref } from 'vue'
|
||||
|
||||
import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedBillingControlEnabled,
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
isAuthenticatedConfigLoaded,
|
||||
remoteConfig
|
||||
@@ -24,7 +24,6 @@ export enum ServerFeatureFlag {
|
||||
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
|
||||
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
|
||||
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
|
||||
PARTNER_NODE_GOVERNANCE_ENABLED = 'partner_node_governance_enabled',
|
||||
USER_SECRETS_ENABLED = 'user_secrets_enabled',
|
||||
NODE_REPLACEMENTS = 'node_replacements',
|
||||
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled',
|
||||
@@ -33,8 +32,7 @@ export enum ServerFeatureFlag {
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
BILLING_CONTROL_ENABLED = 'billing_control_enabled',
|
||||
FREE_TIER_JOB_ALLOWANCE_ENABLED = 'free_tier_job_allowance_enabled',
|
||||
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile'
|
||||
}
|
||||
|
||||
@@ -135,13 +133,6 @@ export function useFeatureFlags() {
|
||||
cachedTeamWorkspacesEnabled
|
||||
)
|
||||
},
|
||||
get partnerNodeGovernanceEnabled() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.PARTNER_NODE_GOVERNANCE_ENABLED,
|
||||
remoteConfig.value.partner_node_governance_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get userSecretsEnabled() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.USER_SECRETS_ENABLED,
|
||||
@@ -200,25 +191,15 @@ export function useFeatureFlags() {
|
||||
)
|
||||
},
|
||||
/**
|
||||
* Whether personal workspaces use the workspace-scoped billing flow. While
|
||||
* false (default), personal workspaces stay on the legacy per-user billing
|
||||
* flow; team workspaces are unaffected.
|
||||
* 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 billingControlEnabled() {
|
||||
get consolidatedBillingEnabled() {
|
||||
return resolveAuthGatedFlag(
|
||||
ServerFeatureFlag.BILLING_CONTROL_ENABLED,
|
||||
remoteConfig.value.billing_control_enabled,
|
||||
cachedBillingControlEnabled
|
||||
)
|
||||
},
|
||||
get freeTierJobAllowanceEnabled() {
|
||||
const config = remoteConfig.value as typeof remoteConfig.value & {
|
||||
free_tier_job_allowance_enabled?: boolean
|
||||
}
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.FREE_TIER_JOB_ALLOWANCE_ENABLED,
|
||||
config.free_tier_job_allowance_enabled,
|
||||
false
|
||||
ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED,
|
||||
remoteConfig.value.consolidated_billing_enabled,
|
||||
cachedConsolidatedBillingEnabled
|
||||
)
|
||||
},
|
||||
get signupTurnstileMode() {
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import type { QueuePromptGuard } from '@/services/queuePromptGuardService'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const {
|
||||
addToast,
|
||||
isNodeDisabled,
|
||||
registerQueuePromptGuard,
|
||||
registerExtension,
|
||||
usePartnerNodeGovernanceStore
|
||||
} = vi.hoisted(() => {
|
||||
const isNodeDisabled = vi.fn()
|
||||
return {
|
||||
addToast: vi.fn(),
|
||||
isNodeDisabled,
|
||||
registerQueuePromptGuard: vi.fn<
|
||||
(id: string, guard: QueuePromptGuard) => () => void
|
||||
>(() => () => {}),
|
||||
registerExtension: vi.fn<(extension: ComfyExtension) => void>(),
|
||||
usePartnerNodeGovernanceStore: vi.fn(() => ({ isNodeDisabled }))
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/partnerNodeGovernanceStore', () => ({
|
||||
usePartnerNodeGovernanceStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: addToast })
|
||||
}))
|
||||
|
||||
vi.mock('@/services/queuePromptGuardService', () => ({
|
||||
registerQueuePromptGuard
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({ registerExtension })
|
||||
}))
|
||||
|
||||
describe('cloudPartnerNodeGovernance', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function loadExtension(): Promise<ComfyExtension> {
|
||||
await import('./cloudPartnerNodeGovernance')
|
||||
const extension = registerExtension.mock.calls[0]?.[0]
|
||||
if (!extension)
|
||||
throw new Error('Expected governance extension registration')
|
||||
return extension
|
||||
}
|
||||
|
||||
it('initializes governance during Cloud setup', async () => {
|
||||
const extension = await loadExtension()
|
||||
expect(extension.name).toBe('Comfy.Cloud.PartnerNodeGovernance')
|
||||
|
||||
extension.setup?.(fromPartial<ComfyApp>({}))
|
||||
|
||||
expect(usePartnerNodeGovernanceStore).toHaveBeenCalledOnce()
|
||||
expect(registerQueuePromptGuard).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('blocks queueing when the graph contains a disabled partner node', async () => {
|
||||
const graph = new LGraph()
|
||||
graph.add(new LGraphNode('DisabledPartnerNode', 'DisabledPartnerNode'))
|
||||
isNodeDisabled.mockImplementation(
|
||||
(nodeType) => nodeType === 'DisabledPartnerNode'
|
||||
)
|
||||
const extension = await loadExtension()
|
||||
extension.setup?.(fromPartial<ComfyApp>({}))
|
||||
const guard = registerQueuePromptGuard.mock.calls[0]?.[1]
|
||||
if (!guard) throw new Error('Expected queue guard registration')
|
||||
|
||||
const result = await guard({ rootGraph: graph })
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(isNodeDisabled).toHaveBeenCalledWith('DisabledPartnerNode')
|
||||
expect(addToast).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'Workflow blocked by workspace policy',
|
||||
detail:
|
||||
'Remove disabled partner nodes from this workflow or ask a workspace owner to update the policy.',
|
||||
life: 6000
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
import { t } from '@/i18n'
|
||||
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { registerQueuePromptGuard } from '@/services/queuePromptGuardService'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
const QUEUE_GUARD_ID = 'workspace.partner-node-governance'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Cloud.PartnerNodeGovernance',
|
||||
|
||||
setup: () => {
|
||||
usePartnerNodeGovernanceStore()
|
||||
registerQueuePromptGuard(QUEUE_GUARD_ID, (context) => {
|
||||
const governanceStore = usePartnerNodeGovernanceStore()
|
||||
let hasDisabledPartnerNode = false
|
||||
forEachNode(context.rootGraph, (node) => {
|
||||
if (node.type && governanceStore.isNodeDisabled(node.type)) {
|
||||
hasDisabledPartnerNode = true
|
||||
}
|
||||
})
|
||||
|
||||
if (!hasDisabledPartnerNode) return true
|
||||
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.allowlist.runBlocked.summary'),
|
||||
detail: t('workspacePanel.allowlist.runBlocked.detail'),
|
||||
life: 6000
|
||||
})
|
||||
return false
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -38,7 +38,6 @@ if (isCloud) {
|
||||
await import('./cloudRemoteConfig')
|
||||
await import('./cloudBadges')
|
||||
await import('./cloudSessionCookie')
|
||||
await import('./cloudPartnerNodeGovernance')
|
||||
}
|
||||
|
||||
// Feedback button for cloud and nightly builds
|
||||
|
||||
@@ -2473,11 +2473,6 @@
|
||||
},
|
||||
"credits": {
|
||||
"activity": "Activity",
|
||||
"insufficient": {
|
||||
"memberTitle": "This workspace is out of credits",
|
||||
"memberDescription": "Your team has used all its credits. Your workspace admins need to add more credits to run workflows.",
|
||||
"memberCta": "Ok, got it"
|
||||
},
|
||||
"credits": "Credits",
|
||||
"yourCreditBalance": "Your credit balance",
|
||||
"purchaseCredits": "Purchase Credits",
|
||||
@@ -2887,63 +2882,13 @@
|
||||
"updatePassword": "Update Password"
|
||||
},
|
||||
"workspacePanel": {
|
||||
"billingStatus": {
|
||||
"warning": {
|
||||
"title": "Payment declined",
|
||||
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
|
||||
"bodyNoDate": "Your last payment didn't go through. Update payment to avoid a pause."
|
||||
},
|
||||
"paused": {
|
||||
"title": "Subscription paused",
|
||||
"body": "This workspace's subscription is paused. Update payment to resume.",
|
||||
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method."
|
||||
},
|
||||
"outOfCredits": {
|
||||
"title": "Out of credits",
|
||||
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
|
||||
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
|
||||
"memberBody": "Your team has used all its credits. Your workspace admins need to add more credits to continue generating.",
|
||||
"addCredits": "Add credits",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"ending": {
|
||||
"title": "Your team plan ends on {date}",
|
||||
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
|
||||
"reactivate": "Reactivate plan"
|
||||
},
|
||||
"updatePayment": "Update payment"
|
||||
},
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of {count} members",
|
||||
"tabs": {
|
||||
"dashboard": "Dashboard",
|
||||
"planCredits": "Plan & Credits",
|
||||
"membersCount": "Members ({count})",
|
||||
"allowlist": "Allowlist"
|
||||
},
|
||||
"allowlist": {
|
||||
"title": "Partner nodes",
|
||||
"description": "Choose which partner nodes are allowed when workspace enforcement is enabled.",
|
||||
"searchPlaceholder": "Search partner nodes...",
|
||||
"loading": "Loading partner node policy...",
|
||||
"loadError": "Partner node policy could not be loaded.",
|
||||
"retry": "Try again",
|
||||
"empty": "No partner nodes are available.",
|
||||
"noMatches": "No partner nodes match your search.",
|
||||
"allowedCount": "{enabled} of {total} allowed",
|
||||
"nodeToggle": "Allow {name}",
|
||||
"saved": "Partner node policy saved",
|
||||
"saveError": "Partner node policy could not be saved.",
|
||||
"runBlocked": {
|
||||
"summary": "Workflow blocked by workspace policy",
|
||||
"detail": "Remove disabled partner nodes from this workflow or ask a workspace owner to update the policy."
|
||||
},
|
||||
"enforcement": {
|
||||
"title": "Enforce partner node allowlist",
|
||||
"description": "Block partner nodes that are not explicitly allowed.",
|
||||
"toggle": "Enforce partner node allowlist"
|
||||
}
|
||||
"membersCount": "Members ({count})"
|
||||
},
|
||||
"dashboard": {
|
||||
"placeholder": "Dashboard workspace settings"
|
||||
@@ -3611,9 +3556,6 @@
|
||||
"dockToTop": "Dock to top",
|
||||
"feedback": "Feedback",
|
||||
"feedbackTooltip": "Feedback",
|
||||
"freeTierRuns": "{available} / {MAX_AVAILABLE} runs left",
|
||||
"freeTierRunsExhausted": "No runs left",
|
||||
"freeTierPartner": "Partner nodes need a paid plan",
|
||||
"share": "Share",
|
||||
"shareTooltip": "Share workflow"
|
||||
},
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
|
||||
|
||||
const DOT_COLORS = [
|
||||
'bg-destructive-background',
|
||||
'bg-warning-background',
|
||||
'bg-success-background'
|
||||
]
|
||||
|
||||
const { showSubscriptionDialog } = useBillingContext()
|
||||
const { t } = useI18n()
|
||||
const { available, hasInvalidNodes, maxAvailable, quotaEnabled } =
|
||||
useFreeTierQuota()
|
||||
|
||||
const dotColor = computed(() => {
|
||||
const ratio = maxAvailable.value ? available.value / maxAvailable.value : 0
|
||||
return DOT_COLORS[
|
||||
Math.min(Math.floor(ratio * DOT_COLORS.length), DOT_COLORS.length - 1)
|
||||
]
|
||||
})
|
||||
const label = computed(() =>
|
||||
available.value === 0
|
||||
? t('actionbar.freeTierRunsExhausted')
|
||||
: t('actionbar.freeTierRuns', {
|
||||
available: available.value,
|
||||
MAX_AVAILABLE: maxAvailable.value
|
||||
})
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="quotaEnabled"
|
||||
class="mt-2 w-full cursor-pointer border-t border-border-subtle bg-comfy-menu-bg px-4 pt-2 select-none"
|
||||
data-testid="free-tier-quota"
|
||||
@click="showSubscriptionDialog({ reason: 'free_tier_quota' })"
|
||||
>
|
||||
<div
|
||||
v-if="hasInvalidNodes"
|
||||
class="flex w-full items-center justify-center gap-2"
|
||||
>
|
||||
<i class="icon-[comfy--credits] bg-amber-400" />
|
||||
{{ t('actionbar.freeTierPartner') }}
|
||||
</div>
|
||||
<div v-else class="flex w-full items-center justify-between">
|
||||
<div class="flex gap-2" :aria-label="label" role="img">
|
||||
<div
|
||||
v-for="index in maxAvailable"
|
||||
:key="index"
|
||||
:class="
|
||||
cn(
|
||||
'size-1.5 rounded-full',
|
||||
index > available ? 'bg-secondary-background-selected' : dotColor
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-text="label" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,19 +8,10 @@ const mockDialogService = {
|
||||
showTopUpCreditsDialog: vi.fn()
|
||||
}
|
||||
|
||||
const mockBilling = {
|
||||
fetchStatus: vi.fn(),
|
||||
fetchBalance: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => mockDialogService)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => mockBilling)
|
||||
}))
|
||||
|
||||
describe('useAccountPreconditionDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -64,18 +55,4 @@ describe('useAccountPreconditionDialog', () => {
|
||||
mockDialogService.showSubscriptionRequiredDialog
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes the billing snapshot on a credit precondition so exhausted-state surfaces converge', () => {
|
||||
useAccountPreconditionDialog().open('credits')
|
||||
|
||||
expect(mockBilling.fetchStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockBilling.fetchBalance).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not touch billing state for non-credit preconditions', () => {
|
||||
useAccountPreconditionDialog().open('subscription')
|
||||
|
||||
expect(mockBilling.fetchStatus).not.toHaveBeenCalled()
|
||||
expect(mockBilling.fetchBalance).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import type { AccountPrecondition } from '@/platform/errorCatalog/accountPreconditionRouting'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
@@ -29,19 +28,11 @@ export function useAccountPreconditionDialog() {
|
||||
reason: 'subscription_required'
|
||||
})
|
||||
return
|
||||
case 'credits': {
|
||||
// The server just declared the balance exhausted; there is no push or
|
||||
// polling for billing state, so refresh it here to converge
|
||||
// hasFunds-keyed surfaces such as the credits-exhausted banner. The
|
||||
// refresh is best-effort: allSettled keeps a flaky billing API from
|
||||
// surfacing as unhandled rejections.
|
||||
const { fetchStatus, fetchBalance } = useBillingContext()
|
||||
void Promise.allSettled([fetchStatus(), fetchBalance()])
|
||||
case 'credits':
|
||||
void dialogService.showTopUpCreditsDialog({
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useCreditsBadgesInGraph } from '@/composables/node/usePriceBadge'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
export const useFreeTierQuota = createSharedComposable(function () {
|
||||
const { flags } = useFeatureFlags()
|
||||
const creditsBadges = useCreditsBadgesInGraph()
|
||||
|
||||
const available = ref(0)
|
||||
const maxAvailable = ref(0)
|
||||
watch(
|
||||
() => remoteConfig.value.free_tier_balance?.remaining,
|
||||
(val) => (available.value = val ?? 0),
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(
|
||||
() => remoteConfig.value.free_tier_balance?.allowance,
|
||||
(val) => (maxAvailable.value = val ?? 0),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const quotaEnabled = computed(
|
||||
() => flags.freeTierJobAllowanceEnabled && maxAvailable.value > 0
|
||||
)
|
||||
const hasInvalidNodes = computed(() => creditsBadges.value.length > 0)
|
||||
const freeTierExecutionPermitted = computed(
|
||||
() => !hasInvalidNodes.value && quotaEnabled.value && available.value > 0
|
||||
)
|
||||
|
||||
function trackRun() {
|
||||
if (available.value > 0) available.value--
|
||||
}
|
||||
|
||||
return {
|
||||
available,
|
||||
freeTierExecutionPermitted,
|
||||
hasInvalidNodes,
|
||||
maxAvailable,
|
||||
quotaEnabled,
|
||||
trackRun
|
||||
}
|
||||
})
|
||||
@@ -266,21 +266,6 @@ describe('useSubscriptionDialog', () => {
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the read-only member dialog for out-of-credits too, not the pricing table', () => {
|
||||
mockShouldUseWorkspaceBilling.value = true
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
mockCanManageSubscription.value = false
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable({ reason: 'out_of_credits' })
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
|
||||
const props = mockShowLayoutDialog.mock.calls[0][0].props
|
||||
expect(props).toHaveProperty('onClose')
|
||||
expect(props).not.toHaveProperty('reason')
|
||||
expect(props).not.toHaveProperty('initialPlanMode')
|
||||
})
|
||||
|
||||
it('does not track on non-cloud', () => {
|
||||
mockIsCloud.value = false
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
@@ -55,12 +55,12 @@ export const useSubscriptionDialog = () => {
|
||||
|
||||
// Members can't manage the workspace subscription, so a blocked run shows a
|
||||
// small read-only "ask your owner to reactivate" modal instead of the
|
||||
// pricing table — including out-of-credits, whose member recovery path is
|
||||
// also owner-only (FE-1246).
|
||||
// pricing table. Out-of-credits still routes everyone to the credits flow.
|
||||
if (
|
||||
shouldUseWorkspaceBilling.value &&
|
||||
!workspaceStore.isInPersonalWorkspace &&
|
||||
!permissions.value.canManageSubscription
|
||||
!permissions.value.canManageSubscription &&
|
||||
options?.reason !== 'out_of_credits'
|
||||
) {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
@@ -88,9 +88,9 @@ export const useSubscriptionDialog = () => {
|
||||
} as const
|
||||
|
||||
// Jun-5 model: a single unified pricing table (personal/team plan toggle on
|
||||
// one workspace) for workspaces on the workspace-scoped billing flow.
|
||||
// Replaces the old personal-vs-team workspace fork. Personal workspaces
|
||||
// still on the legacy flow (billing control disabled) get the legacy table.
|
||||
// one workspace) for workspaces on the consolidated billing flow. Replaces
|
||||
// the old personal-vs-team workspace fork. Personal workspaces still on the
|
||||
// legacy flow (consolidated billing disabled) get the legacy table.
|
||||
if (shouldUseWorkspaceBilling.value) {
|
||||
// Existing per-member (legacy) team subscribers keep the old tier-based
|
||||
// team table; the unified credit-slider table is for everyone else.
|
||||
|
||||
@@ -73,33 +73,6 @@ describe('resolveAccountPrecondition', () => {
|
||||
).toBe('credits')
|
||||
})
|
||||
|
||||
it('classifies the submit-time 402 body by its insufficient_credits type regardless of message', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
exceptionType: 'insufficient_credits',
|
||||
exceptionMessage: 'Workspace balance exhausted'
|
||||
})
|
||||
).toBe('credits')
|
||||
})
|
||||
|
||||
it('classifies the team submit-time 429 (PAYMENT_REQUIRED / insufficient credits) as a credits precondition', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
exceptionType: 'PAYMENT_REQUIRED',
|
||||
exceptionMessage: 'Insufficient credits to queue workflows'
|
||||
})
|
||||
).toBe('credits')
|
||||
})
|
||||
|
||||
it('keeps the team submit-time 429 for an inactive subscription on the subscription precondition', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
exceptionType: 'PAYMENT_REQUIRED',
|
||||
exceptionMessage: 'Subscription required to queue workflows'
|
||||
})
|
||||
).toBe('subscription')
|
||||
})
|
||||
|
||||
it('returns undefined for an ordinary workflow error', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
|
||||
@@ -33,13 +33,7 @@ const INSUFFICIENT_CREDITS_MESSAGES = new Set([
|
||||
'Payment Required: Please add credits to your account to use this node.'
|
||||
])
|
||||
const WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES = new Set([
|
||||
// Execution-time (pre-GPU) WebSocket failure for a queued team job.
|
||||
'Payment Required: Please add credits to your workspace to continue.',
|
||||
// Submit-time 429 rejection for a team workspace out of credits
|
||||
// (checkTeamWorkspaceSubscription). It shares the PAYMENT_REQUIRED error type
|
||||
// with the subscription-required rejection, so it can only be told apart by
|
||||
// message.
|
||||
'Insufficient credits to queue workflows'
|
||||
'Payment Required: Please add credits to your workspace to continue.'
|
||||
])
|
||||
const SUBSCRIPTION_REQUIRED_MESSAGES = new Set([
|
||||
'Workspace has no active subscription. Please subscribe to a plan to continue.',
|
||||
@@ -249,12 +243,8 @@ const RUNTIME_MATCH_RULES: RuntimeMatchRule[] = [
|
||||
resolve: () => catalogMatch(WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID)
|
||||
},
|
||||
{
|
||||
// 'insufficient_credits' is the error.type BE-2866 will emit on the
|
||||
// personal submit-time 402; the team submit path is a 429 matched by
|
||||
// message in WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES above.
|
||||
matches: (info, message) =>
|
||||
info.exceptionType === 'InsufficientFundsError' ||
|
||||
info.exceptionType === 'insufficient_credits' ||
|
||||
INSUFFICIENT_CREDITS_MESSAGES.has(message),
|
||||
resolve: () => catalogMatch(INSUFFICIENT_CREDITS_CATALOG_ID)
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
cachedBillingControlEnabled,
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
remoteConfig,
|
||||
remoteConfigState
|
||||
@@ -60,8 +60,8 @@ export async function refreshRemoteConfig(
|
||||
cachedTeamWorkspacesEnabled.value = Boolean(
|
||||
config.team_workspaces_enabled
|
||||
)
|
||||
cachedBillingControlEnabled.value = Boolean(
|
||||
config.billing_control_enabled
|
||||
cachedConsolidatedBillingEnabled.value = Boolean(
|
||||
config.consolidated_billing_enabled
|
||||
)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -60,7 +60,7 @@ export const cachedTeamWorkspacesEnabled = useStorage<boolean | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
export const cachedBillingControlEnabled = useStorage<boolean | undefined>(
|
||||
'billing_control_enabled' satisfies `${ServerFeatureFlag.BILLING_CONTROL_ENABLED}`,
|
||||
export const cachedConsolidatedBillingEnabled = useStorage<boolean | undefined>(
|
||||
'consolidated_billing_enabled' satisfies `${ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED}`,
|
||||
undefined
|
||||
)
|
||||
|
||||
@@ -107,21 +107,15 @@ export type RemoteConfig = {
|
||||
manager_survey_url?: string
|
||||
linear_toggle_enabled?: boolean
|
||||
team_workspaces_enabled?: boolean
|
||||
partner_node_governance_enabled?: boolean
|
||||
user_secrets_enabled?: boolean
|
||||
node_library_essentials_enabled?: boolean
|
||||
free_tier_credits?: number
|
||||
free_tier_balance?: {
|
||||
allowance: number
|
||||
used: number
|
||||
remaining: number
|
||||
}
|
||||
new_free_tier_subscriptions?: boolean
|
||||
workflow_sharing_enabled?: boolean
|
||||
comfyhub_upload_enabled?: boolean
|
||||
comfyhub_profile_gate_enabled?: boolean
|
||||
unified_cloud_auth?: boolean
|
||||
billing_control_enabled?: boolean
|
||||
consolidated_billing_enabled?: boolean
|
||||
sentry_dsn?: string
|
||||
turnstile_sitekey?: string
|
||||
// Raw, unvalidated wire value (a server typo like 'enfroce' is possible).
|
||||
|
||||
@@ -33,7 +33,6 @@ export type PaymentIntentSource =
|
||||
| 'invite_member_upsell'
|
||||
| 'upload_model_upgrade'
|
||||
| 'team_upgrade_resume'
|
||||
| 'free_tier_quota'
|
||||
|
||||
export type SubscriptionCheckoutType = 'new' | 'change'
|
||||
export type SubscriptionCheckoutTier = TierKey | 'team'
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
getPartnerNodePolicy,
|
||||
PartnerNodePolicyApiError,
|
||||
updatePartnerNodePolicy
|
||||
} from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
|
||||
const mockFetchApi = vi.fn()
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: (...args: unknown[]) => mockFetchApi(...args)
|
||||
}
|
||||
}))
|
||||
|
||||
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(body), init)
|
||||
}
|
||||
|
||||
describe('partnerNodePolicyApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('normalizes the configured policy response', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({
|
||||
enforcement_enabled: true,
|
||||
nodes: { AllowedNode: true, DisabledNode: false }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(getPartnerNodePolicy()).resolves.toEqual({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true, DisabledNode: false }
|
||||
})
|
||||
expect(mockFetchApi).toHaveBeenCalledWith(
|
||||
'/workspace/partner-node-policy',
|
||||
{ cache: 'no-store' }
|
||||
)
|
||||
})
|
||||
|
||||
it('maps 404 to an unconfigured policy', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({}, { status: 404, statusText: 'Not Found' })
|
||||
)
|
||||
|
||||
await expect(getPartnerNodePolicy()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('preserves non-404 status codes for policy decisions', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({}, { status: 503, statusText: 'Service Unavailable' })
|
||||
)
|
||||
|
||||
await expect(getPartnerNodePolicy()).rejects.toEqual(
|
||||
new PartnerNodePolicyApiError(503, 'Service Unavailable')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects malformed policy responses', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({ enforcement_enabled: 'yes', nodes: [] })
|
||||
)
|
||||
|
||||
await expect(getPartnerNodePolicy()).rejects.toMatchObject({
|
||||
name: 'ZodError'
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes and validates a whole-policy update', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({
|
||||
enforcement_enabled: false,
|
||||
nodes: { AllowedNode: true, DisabledNode: true }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
updatePartnerNodePolicy({
|
||||
enforcementEnabled: false,
|
||||
nodes: { AllowedNode: true, DisabledNode: true }
|
||||
})
|
||||
).resolves.toEqual({
|
||||
enforcementEnabled: false,
|
||||
nodes: { AllowedNode: true, DisabledNode: true }
|
||||
})
|
||||
expect(mockFetchApi).toHaveBeenCalledWith(
|
||||
'/workspace/partner-node-policy',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enforcement_enabled: false,
|
||||
nodes: { AllowedNode: true, DisabledNode: true }
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves update failures for the editor', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({}, { status: 409, statusText: 'Conflict' })
|
||||
)
|
||||
|
||||
await expect(
|
||||
updatePartnerNodePolicy({ enforcementEnabled: false, nodes: {} })
|
||||
).rejects.toEqual(new PartnerNodePolicyApiError(409, 'Conflict'))
|
||||
})
|
||||
|
||||
it('rejects malformed update responses', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({ enforcement_enabled: false, nodes: [] })
|
||||
)
|
||||
|
||||
await expect(
|
||||
updatePartnerNodePolicy({ enforcementEnabled: false, nodes: {} })
|
||||
).rejects.toMatchObject({ name: 'ZodError' })
|
||||
})
|
||||
})
|
||||
@@ -1,67 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
const PARTNER_NODE_POLICY_PATH = '/workspace/partner-node-policy'
|
||||
|
||||
const partnerNodePolicyResponseSchema = z.object({
|
||||
enforcement_enabled: z.boolean(),
|
||||
nodes: z.record(z.string(), z.boolean())
|
||||
})
|
||||
|
||||
export type PartnerNodePolicyResponse = z.infer<
|
||||
typeof partnerNodePolicyResponseSchema
|
||||
>
|
||||
|
||||
export interface PartnerNodePolicy {
|
||||
enforcementEnabled: boolean
|
||||
nodes: Readonly<Record<string, boolean>>
|
||||
}
|
||||
|
||||
export class PartnerNodePolicyApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'PartnerNodePolicyApiError'
|
||||
}
|
||||
}
|
||||
|
||||
function parsePartnerNodePolicy(data: unknown): PartnerNodePolicy {
|
||||
const policy = partnerNodePolicyResponseSchema.parse(data)
|
||||
return {
|
||||
enforcementEnabled: policy.enforcement_enabled,
|
||||
nodes: policy.nodes
|
||||
}
|
||||
}
|
||||
|
||||
function throwResponseError(response: Response): never {
|
||||
throw new PartnerNodePolicyApiError(response.status, response.statusText)
|
||||
}
|
||||
|
||||
export async function getPartnerNodePolicy(): Promise<PartnerNodePolicy | null> {
|
||||
const response = await api.fetchApi(PARTNER_NODE_POLICY_PATH, {
|
||||
cache: 'no-store'
|
||||
})
|
||||
if (response.status === 404) return null
|
||||
if (!response.ok) throwResponseError(response)
|
||||
|
||||
return parsePartnerNodePolicy(await response.json())
|
||||
}
|
||||
|
||||
export async function updatePartnerNodePolicy(
|
||||
policy: PartnerNodePolicy
|
||||
): Promise<PartnerNodePolicy> {
|
||||
const response = await api.fetchApi(PARTNER_NODE_POLICY_PATH, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enforcement_enabled: policy.enforcementEnabled,
|
||||
nodes: policy.nodes
|
||||
})
|
||||
})
|
||||
if (!response.ok) throwResponseError(response)
|
||||
|
||||
return parsePartnerNodePolicy(await response.json())
|
||||
}
|
||||
@@ -250,9 +250,6 @@ export type BillingStatus =
|
||||
| 'pending_payment'
|
||||
| 'paid'
|
||||
| 'payment_failed'
|
||||
// A Stripe-paused subscription stays `active` on the activity axis; the pause
|
||||
// is a payment-lifecycle fact. Not emitted until cloud#5075 ships.
|
||||
| 'paused'
|
||||
| 'inactive'
|
||||
|
||||
export interface CurrentTeamCreditStop {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col overflow-hidden rounded-2xl border border-border-default bg-base-background"
|
||||
data-testid="insufficient-credits-member-message"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 items-center gap-2 border-b border-border-default p-4"
|
||||
>
|
||||
<p class="m-0 min-w-0 flex-1 font-inter text-sm text-base-foreground">
|
||||
{{ $t('credits.insufficient.memberTitle') }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="$t('g.close')"
|
||||
class="flex size-4 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent text-base-foreground"
|
||||
@click="onClose"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<p class="m-0 font-inter text-sm text-muted-foreground">
|
||||
{{ $t('credits.insufficient.memberDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end p-4">
|
||||
<Button variant="secondary" size="lg" @click="onClose">
|
||||
{{ $t('credits.insufficient.memberCta') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { onClose } = defineProps<{
|
||||
onClose: () => void
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,178 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { SubscriptionInfo } from '@/composables/billing/types'
|
||||
import { i18n } from '@/i18n'
|
||||
import type { BillingContextMockState } from '@/storybook/mocks/useBillingContext'
|
||||
import { setBillingContextMock } from '@/storybook/mocks/useBillingContext'
|
||||
import type { WorkspaceUIMockState } from '@/storybook/mocks/useWorkspaceUI'
|
||||
import { setWorkspaceUIMock } from '@/storybook/mocks/useWorkspaceUI'
|
||||
|
||||
import BillingStatusBanner from './BillingStatusBanner.vue'
|
||||
|
||||
/**
|
||||
* The single billing banner slot for team workspaces (FE-1246), rendered in
|
||||
* priority order: paused > payment declined > out of credits > ending. At most
|
||||
* one state shows at a time. Each story drives the real `deriveBillingBanner`
|
||||
* through the stubbed billing context, so these are the states the backend can
|
||||
* actually emit — not hand-set banner kinds.
|
||||
*
|
||||
* The amber triangle-alert marks action-needed states; the muted circle-alert is
|
||||
* reserved for the informational "plan ends" notice (DES-380 severity rule).
|
||||
*
|
||||
* RUN WITH `DISTRIBUTION=cloud pnpm storybook`. The banner is cloud-only, and
|
||||
* `isCloud` is compile-time, so under a plain `pnpm storybook` every story below
|
||||
* renders empty.
|
||||
*/
|
||||
const meta: Meta<typeof BillingStatusBanner> = {
|
||||
title: 'Platform/Workspace/BillingStatusBanner',
|
||||
component: BillingStatusBanner,
|
||||
parameters: { layout: 'fullscreen' }
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof BillingStatusBanner>
|
||||
|
||||
const RENEWAL_DATE = '2026-08-01T00:00:00Z'
|
||||
const PLAN_END_DATE = '2026-08-01T00:00:00Z'
|
||||
|
||||
const teamSubscription: SubscriptionInfo = {
|
||||
isActive: true,
|
||||
tier: null,
|
||||
duration: 'MONTHLY',
|
||||
planSlug: 'team-monthly',
|
||||
renewalDate: RENEWAL_DATE,
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
hasFunds: true
|
||||
}
|
||||
|
||||
const funded = teamSubscription
|
||||
const exhausted: SubscriptionInfo = { ...teamSubscription, hasFunds: false }
|
||||
const cancelled: SubscriptionInfo = {
|
||||
...teamSubscription,
|
||||
isCancelled: true,
|
||||
endDate: PLAN_END_DATE
|
||||
}
|
||||
|
||||
const owner: Partial<WorkspaceUIMockState> = {}
|
||||
const member: Partial<WorkspaceUIMockState> = {
|
||||
canManageSubscription: false,
|
||||
canManageSubscriptionLifecycle: false,
|
||||
canTopUp: false
|
||||
}
|
||||
|
||||
function story(
|
||||
billing: Partial<BillingContextMockState>,
|
||||
workspace: Partial<WorkspaceUIMockState>
|
||||
): Story {
|
||||
return {
|
||||
beforeEach() {
|
||||
// Dates in the copy go through vue-i18n's `d()`, so pin the locale rather
|
||||
// than inherit the developer's.
|
||||
i18n.global.locale.value = 'en'
|
||||
setBillingContextMock({ isTeamPlan: true, ...billing })
|
||||
setWorkspaceUIMock(workspace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe suspended the subscription. The backend folds billing_status into
|
||||
* is_active, so a paused workspace always reports `is_active: false` — the
|
||||
* pairing these stories pin.
|
||||
*/
|
||||
export const PausedOwner: Story = story(
|
||||
{
|
||||
subscription: funded,
|
||||
isActiveSubscription: false,
|
||||
billingStatus: 'paused',
|
||||
subscriptionStatus: 'active'
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/** Members get an admin-directed notice and no action. */
|
||||
export const PausedMember: Story = story(
|
||||
{
|
||||
subscription: funded,
|
||||
isActiveSubscription: false,
|
||||
billingStatus: 'paused',
|
||||
subscriptionStatus: 'active'
|
||||
},
|
||||
member
|
||||
)
|
||||
|
||||
/**
|
||||
* A failed charge with Stripe still retrying. Owner-only, and `is_active: false`
|
||||
* for the same reason paused is — payment_failed also denies spend.
|
||||
*/
|
||||
export const PaymentDeclined: Story = story(
|
||||
{
|
||||
subscription: funded,
|
||||
isActiveSubscription: false,
|
||||
billingStatus: 'payment_failed',
|
||||
subscriptionStatus: 'active',
|
||||
renewalDate: RENEWAL_DATE
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/** Payment declined before a renewal date is known. */
|
||||
export const PaymentDeclinedNoDate: Story = story(
|
||||
{
|
||||
subscription: funded,
|
||||
isActiveSubscription: false,
|
||||
billingStatus: 'payment_failed',
|
||||
subscriptionStatus: 'active'
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/** Shared team credits exhausted. Session-dismissible. */
|
||||
export const OutOfCreditsOwner: Story = story(
|
||||
{
|
||||
subscription: exhausted,
|
||||
isActiveSubscription: true,
|
||||
billingStatus: 'paid',
|
||||
subscriptionStatus: 'active',
|
||||
renewalDate: RENEWAL_DATE
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/** Members can't top up, so they get contact-admin copy and no Add credits. */
|
||||
export const OutOfCreditsMember: Story = story(
|
||||
{
|
||||
subscription: exhausted,
|
||||
isActiveSubscription: true,
|
||||
billingStatus: 'paid',
|
||||
subscriptionStatus: 'active',
|
||||
renewalDate: RENEWAL_DATE
|
||||
},
|
||||
member
|
||||
)
|
||||
|
||||
/** Cancelled but still active until the period end. Informational. */
|
||||
export const EndingOwner: Story = story(
|
||||
{
|
||||
subscription: cancelled,
|
||||
isActiveSubscription: true,
|
||||
billingStatus: 'paid',
|
||||
subscriptionStatus: 'canceled'
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/**
|
||||
* Reactivate is lifecycle-gated to the original owner, so a non-original owner
|
||||
* sees the notice read-only.
|
||||
*/
|
||||
export const EndingNonOriginalOwner: Story = story(
|
||||
{
|
||||
subscription: cancelled,
|
||||
isActiveSubscription: true,
|
||||
billingStatus: 'paid',
|
||||
subscriptionStatus: 'canceled'
|
||||
},
|
||||
{ canManageSubscriptionLifecycle: false }
|
||||
)
|
||||
@@ -1,317 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
BillingStatus,
|
||||
WorkspaceType
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
|
||||
|
||||
interface Subscription {
|
||||
hasFunds: boolean
|
||||
isCancelled: boolean
|
||||
endDate: string | null
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
billingControlEnabled: true,
|
||||
isActiveSubscription: true,
|
||||
isTeamPlan: true,
|
||||
billingStatus: 'paid' as string | null,
|
||||
subscription: {
|
||||
hasFunds: true,
|
||||
isCancelled: false,
|
||||
endDate: null
|
||||
} as Subscription | null,
|
||||
renewalDate: null as string | null,
|
||||
workspaceType: 'team' as string,
|
||||
canManageSubscription: true,
|
||||
canManageSubscriptionLifecycle: true,
|
||||
canTopUp: true,
|
||||
showTopUpCreditsDialog: vi.fn(),
|
||||
manageSubscription: vi.fn(),
|
||||
handleResubscribe: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({ isCloud: true }))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get billingControlEnabled() {
|
||||
return state.billingControlEnabled
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: computed(() => state.isActiveSubscription),
|
||||
isTeamPlan: computed(() => state.isTeamPlan),
|
||||
billingStatus: computed(() => state.billingStatus as BillingStatus | null),
|
||||
subscription: computed(() => state.subscription),
|
||||
renewalDate: computed(() => state.renewalDate),
|
||||
manageSubscription: state.manageSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: computed(() => ({
|
||||
canManageSubscription: state.canManageSubscription,
|
||||
canManageSubscriptionLifecycle: state.canManageSubscriptionLifecycle,
|
||||
canTopUp: state.canTopUp
|
||||
})),
|
||||
workspaceType: computed(() => state.workspaceType as WorkspaceType)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useResubscribe', () => ({
|
||||
useResubscribe: () => ({
|
||||
isResubscribing: computed(() => false),
|
||||
handleResubscribe: state.handleResubscribe
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showTopUpCreditsDialog: state.showTopUpCreditsDialog
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
workspacePanel: {
|
||||
billingStatus: {
|
||||
warning: {
|
||||
title: 'Payment declined',
|
||||
body: "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
|
||||
bodyNoDate:
|
||||
"Your last payment didn't go through. Update payment to avoid a pause."
|
||||
},
|
||||
paused: {
|
||||
title: 'Subscription paused',
|
||||
body: "This workspace's subscription is paused. Update payment to resume.",
|
||||
memberBody:
|
||||
"This workspace's subscription is paused. Your workspace admins need to update the payment method."
|
||||
},
|
||||
outOfCredits: {
|
||||
title: 'Out of credits',
|
||||
body: 'Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.',
|
||||
bodyNoDate:
|
||||
'Your team has used all its credits. Add more credits to continue generating.',
|
||||
memberBody:
|
||||
'Your team has used all its credits. Your workspace admins need to add more credits to continue generating.',
|
||||
addCredits: 'Add credits',
|
||||
dismiss: 'Dismiss'
|
||||
},
|
||||
ending: {
|
||||
title: 'Your team plan ends on {date}',
|
||||
body: 'Members keep full access until then. Reactivate to keep your shared credits and seats.',
|
||||
reactivate: 'Reactivate plan'
|
||||
},
|
||||
updatePayment: 'Update payment'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const globalOptions = {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Button: {
|
||||
template:
|
||||
'<button v-bind="$attrs" @click="$emit(\'click\')"><slot/></button>',
|
||||
props: ['variant', 'size', 'loading'],
|
||||
emits: ['click']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderBanner() {
|
||||
return render(BillingStatusBanner, { global: globalOptions })
|
||||
}
|
||||
|
||||
function exhausted() {
|
||||
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
|
||||
}
|
||||
|
||||
// The spend gate folds billing_status into is_active, so the backend never emits
|
||||
// paused alongside an active subscription.
|
||||
function pausedState() {
|
||||
state.billingStatus = 'paused'
|
||||
state.isActiveSubscription = false
|
||||
}
|
||||
|
||||
function paymentFailedState() {
|
||||
state.billingStatus = 'payment_failed'
|
||||
state.isActiveSubscription = false
|
||||
}
|
||||
|
||||
describe('BillingStatusBanner', () => {
|
||||
beforeEach(() => {
|
||||
state.billingControlEnabled = true
|
||||
state.isActiveSubscription = true
|
||||
state.isTeamPlan = true
|
||||
state.billingStatus = 'paid'
|
||||
state.subscription = { hasFunds: true, isCancelled: false, endDate: null }
|
||||
state.renewalDate = null
|
||||
state.workspaceType = 'team'
|
||||
state.canManageSubscription = true
|
||||
state.canManageSubscriptionLifecycle = true
|
||||
state.canTopUp = true
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders nothing for a healthy funded team', () => {
|
||||
renderBanner()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing when billing control is rolled back, even out of credits', () => {
|
||||
state.billingControlEnabled = false
|
||||
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
|
||||
renderBanner()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows out-of-credits with an Add credits action for owners', async () => {
|
||||
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Out of credits')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Add credits' }))
|
||||
expect(state.showTopUpCreditsDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows out-of-credits contact-admin copy without an Add credits action for members', () => {
|
||||
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
|
||||
state.canManageSubscription = false
|
||||
state.canTopUp = false
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Your workspace admins need to add more credits'
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Add credits' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dismisses the out-of-credits banner for the session', async () => {
|
||||
exhausted()
|
||||
renderBanner()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shares one dismiss across instances rather than tracking it per mount', async () => {
|
||||
exhausted()
|
||||
render(
|
||||
{
|
||||
components: { BillingStatusBanner },
|
||||
template: '<div><BillingStatusBanner /><BillingStatusBanner /></div>'
|
||||
},
|
||||
{ global: globalOptions }
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('status')).toHaveLength(2)
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Dismiss' })[0])
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the paused banner with Update payment for owners', async () => {
|
||||
pausedState()
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Subscription paused')
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Update payment to resume'
|
||||
)
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'Update payment' })
|
||||
)
|
||||
expect(state.manageSubscription).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the paused member notice without an action', () => {
|
||||
pausedState()
|
||||
state.canManageSubscription = false
|
||||
state.canTopUp = false
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Your workspace admins need to update the payment method'
|
||||
)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the payment-declined banner with Update payment for owners', () => {
|
||||
paymentFailedState()
|
||||
state.renewalDate = '2026-08-01T00:00:00Z'
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Payment declined')
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/will pause on \S+/)
|
||||
expect(screen.getByRole('status')).not.toHaveTextContent('{date}')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Update payment' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to the no-date payment-declined copy when there is no renewal date', () => {
|
||||
paymentFailedState()
|
||||
state.renewalDate = null
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Update payment to avoid a pause'
|
||||
)
|
||||
expect(screen.getByRole('status')).not.toHaveTextContent('will pause on')
|
||||
expect(screen.getByRole('status')).not.toHaveTextContent('{date}')
|
||||
})
|
||||
|
||||
it('shows the ending banner with a Reactivate action', async () => {
|
||||
state.subscription = {
|
||||
hasFunds: true,
|
||||
isCancelled: true,
|
||||
endDate: '2026-08-01T00:00:00Z'
|
||||
}
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Your team plan ends on'
|
||||
)
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'Reactivate plan' })
|
||||
)
|
||||
expect(state.handleResubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the ending banner read-only to a non-original owner (Reactivate is lifecycle-gated)', () => {
|
||||
state.subscription = {
|
||||
hasFunds: true,
|
||||
isCancelled: true,
|
||||
endDate: '2026-08-01T00:00:00Z'
|
||||
}
|
||||
state.canManageSubscriptionLifecycle = false
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Your team plan ends on'
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Reactivate plan' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,165 +0,0 @@
|
||||
<template>
|
||||
<div v-if="banner" class="@container">
|
||||
<div
|
||||
role="status"
|
||||
class="flex flex-col gap-3 rounded-2xl border border-interface-stroke/60 bg-base-background p-4 @2xl:flex-row @2xl:items-center @2xl:gap-2"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'size-4 shrink-0',
|
||||
banner.muted
|
||||
? 'icon-[lucide--circle-alert] text-muted-foreground'
|
||||
: 'icon-[lucide--triangle-alert] text-warning-background'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span class="text-sm text-base-foreground">{{ banner.title }}</span>
|
||||
</div>
|
||||
<p class="m-0 pl-6 text-sm text-muted-foreground">{{ banner.body }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="banner.dismissible || banner.action"
|
||||
class="flex shrink-0 flex-wrap items-center gap-2 pl-6 @2xl:pl-0"
|
||||
>
|
||||
<Button
|
||||
v-if="banner.dismissible"
|
||||
variant="textonly"
|
||||
size="lg"
|
||||
@click="dismiss"
|
||||
>
|
||||
{{ $t('workspacePanel.billingStatus.outOfCredits.dismiss') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="banner.action === 'addCredits'"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@click="handleAddCredits"
|
||||
>
|
||||
{{ $t('workspacePanel.billingStatus.outOfCredits.addCredits') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="banner.action === 'reactivate'"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:loading="isResubscribing"
|
||||
@click="handleResubscribe"
|
||||
>
|
||||
{{ $t('workspacePanel.billingStatus.ending.reactivate') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="banner.action === 'updatePayment'"
|
||||
variant="inverted"
|
||||
size="lg"
|
||||
@click="handleUpdatePayment"
|
||||
>
|
||||
{{ $t('workspacePanel.billingStatus.updatePayment') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useBillingBanner } from '@/platform/workspace/composables/useBillingBanner'
|
||||
import { useResubscribe } from '@/platform/workspace/composables/useResubscribe'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
type BannerAction = 'addCredits' | 'reactivate' | 'updatePayment'
|
||||
|
||||
const { t, d } = useI18n()
|
||||
const { renewalDate, subscription, manageSubscription } = useBillingContext()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { kind, dismiss } = useBillingBanner()
|
||||
const { isResubscribing, handleResubscribe } = useResubscribe()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
const canManage = computed(() => permissions.value.canManageSubscription)
|
||||
const canManageLifecycle = computed(
|
||||
() => permissions.value.canManageSubscriptionLifecycle
|
||||
)
|
||||
const canTopUp = computed(() => permissions.value.canTopUp)
|
||||
|
||||
const cycleResetDate = computed(() => {
|
||||
const raw = renewalDate.value
|
||||
return raw ? d(new Date(raw), { month: 'short', day: 'numeric' }) : ''
|
||||
})
|
||||
const planEndDate = computed(() => {
|
||||
const raw = subscription.value?.endDate
|
||||
return raw
|
||||
? d(new Date(raw), { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: ''
|
||||
})
|
||||
|
||||
interface BannerView {
|
||||
muted: boolean
|
||||
title: string
|
||||
body: string
|
||||
action: BannerAction | null
|
||||
dismissible: boolean
|
||||
}
|
||||
|
||||
const banner = computed<BannerView | null>(() => {
|
||||
const bs = 'workspacePanel.billingStatus'
|
||||
switch (kind.value) {
|
||||
case 'paused':
|
||||
return {
|
||||
muted: false,
|
||||
title: t(`${bs}.paused.title`),
|
||||
body: canManage.value
|
||||
? t(`${bs}.paused.body`)
|
||||
: t(`${bs}.paused.memberBody`),
|
||||
action: canManage.value ? 'updatePayment' : null,
|
||||
dismissible: false
|
||||
}
|
||||
case 'paymentFailed':
|
||||
return {
|
||||
muted: false,
|
||||
title: t(`${bs}.warning.title`),
|
||||
body: cycleResetDate.value
|
||||
? t(`${bs}.warning.body`, { date: cycleResetDate.value })
|
||||
: t(`${bs}.warning.bodyNoDate`),
|
||||
action: 'updatePayment',
|
||||
dismissible: false
|
||||
}
|
||||
case 'outOfCredits':
|
||||
return {
|
||||
muted: false,
|
||||
title: t(`${bs}.outOfCredits.title`),
|
||||
body: canTopUp.value
|
||||
? cycleResetDate.value
|
||||
? t(`${bs}.outOfCredits.body`, { date: cycleResetDate.value })
|
||||
: t(`${bs}.outOfCredits.bodyNoDate`)
|
||||
: t(`${bs}.outOfCredits.memberBody`),
|
||||
action: canTopUp.value ? 'addCredits' : null,
|
||||
dismissible: true
|
||||
}
|
||||
case 'ending':
|
||||
return {
|
||||
muted: true,
|
||||
title: t(`${bs}.ending.title`, { date: planEndDate.value }),
|
||||
body: t(`${bs}.ending.body`),
|
||||
action: canManageLifecycle.value ? 'reactivate' : null,
|
||||
dismissible: false
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
function handleAddCredits() {
|
||||
void dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
function handleUpdatePayment() {
|
||||
void manageSubscription()
|
||||
}
|
||||
</script>
|
||||
@@ -1,303 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import type { PartnerNodePolicyStatus } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
|
||||
|
||||
import PartnerNodeAllowlistPanel from './PartnerNodeAllowlistPanel.vue'
|
||||
|
||||
const mockSavePolicy = vi.fn()
|
||||
const mockLoadPolicy = vi.fn()
|
||||
const mockToastAdd = vi.fn()
|
||||
|
||||
const state = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
return {
|
||||
governedWorkspaceId: ref<string | null>('workspace-one'),
|
||||
partnerNodes: ref([
|
||||
{ id: 'FluxFill', name: 'Flux Fill', provider: 'BFL' },
|
||||
{ id: 'FluxExpand', name: 'Flux Expand', provider: 'BFL' },
|
||||
{ id: 'VeoVideo', name: 'Veo Video', provider: 'Google' }
|
||||
]),
|
||||
policy: ref<PartnerNodePolicy | null>({
|
||||
enforcementEnabled: true,
|
||||
nodes: {
|
||||
FluxFill: false,
|
||||
FluxExpand: true,
|
||||
VeoVideo: true,
|
||||
RetiredNode: false
|
||||
}
|
||||
}),
|
||||
status: ref<PartnerNodePolicyStatus>('configured')
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('pinia', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
storeToRefs: (store: Record<string, unknown>) => store
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/partnerNodeGovernanceStore', () => ({
|
||||
usePartnerNodeGovernanceStore: () => ({
|
||||
...state,
|
||||
savePolicy: mockSavePolicy,
|
||||
loadPolicy: mockLoadPolicy
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: mockToastAdd })
|
||||
}))
|
||||
|
||||
const ToggleSwitchStub = defineComponent({
|
||||
name: 'ToggleSwitch',
|
||||
props: {
|
||||
modelValue: Boolean,
|
||||
disabled: Boolean,
|
||||
ariaLabel: String
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<button
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
:aria-label="ariaLabel"
|
||||
:disabled="disabled"
|
||||
@click="$emit('update:modelValue', !modelValue)"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
const SearchInputStub = defineComponent({
|
||||
name: 'SearchInput',
|
||||
props: {
|
||||
modelValue: { type: String, required: true },
|
||||
placeholder: String,
|
||||
disabled: Boolean
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderPanel() {
|
||||
return render(PartnerNodeAllowlistPanel, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
ToggleSwitch: ToggleSwitchStub,
|
||||
SearchInput: SearchInputStub
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('PartnerNodeAllowlistPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
state.governedWorkspaceId.value = 'workspace-one'
|
||||
state.partnerNodes.value = [
|
||||
{ id: 'FluxFill', name: 'Flux Fill', provider: 'BFL' },
|
||||
{ id: 'FluxExpand', name: 'Flux Expand', provider: 'BFL' },
|
||||
{ id: 'VeoVideo', name: 'Veo Video', provider: 'Google' }
|
||||
]
|
||||
state.policy.value = {
|
||||
enforcementEnabled: true,
|
||||
nodes: {
|
||||
FluxFill: false,
|
||||
FluxExpand: true,
|
||||
VeoVideo: true,
|
||||
RetiredNode: false
|
||||
}
|
||||
}
|
||||
state.status.value = 'configured'
|
||||
mockSavePolicy.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('groups catalog nodes and reflects the configured allowlist', () => {
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByText('BFL')).toBeInTheDocument()
|
||||
expect(screen.getByText('Google')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('switch', {
|
||||
name: 'Enforce partner node allowlist'
|
||||
})
|
||||
).toBeChecked()
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'Allow Flux Fill' })
|
||||
).not.toBeChecked()
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'Allow Flux Expand' })
|
||||
).toBeChecked()
|
||||
expect(screen.getByText('1 of 2 allowed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves one whole policy while preserving enforcement and hidden entries', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Allow Flux Fill' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(mockSavePolicy).toHaveBeenCalledWith({
|
||||
enforcementEnabled: true,
|
||||
nodes: {
|
||||
FluxFill: true,
|
||||
FluxExpand: true,
|
||||
VeoVideo: true,
|
||||
RetiredNode: false
|
||||
}
|
||||
})
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'success',
|
||||
summary: 'Partner node policy saved',
|
||||
life: 2000
|
||||
})
|
||||
})
|
||||
|
||||
it('saves enforcement with the existing allowlist', async () => {
|
||||
const user = userEvent.setup()
|
||||
state.policy.value = {
|
||||
enforcementEnabled: false,
|
||||
nodes: {
|
||||
FluxFill: false,
|
||||
FluxExpand: true,
|
||||
VeoVideo: true,
|
||||
RetiredNode: false
|
||||
}
|
||||
}
|
||||
renderPanel()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('switch', {
|
||||
name: 'Enforce partner node allowlist'
|
||||
})
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(mockSavePolicy).toHaveBeenCalledWith({
|
||||
enforcementEnabled: true,
|
||||
nodes: {
|
||||
FluxFill: false,
|
||||
FluxExpand: true,
|
||||
VeoVideo: true,
|
||||
RetiredNode: false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('treats an unconfigured workspace as allow-all draft', async () => {
|
||||
state.policy.value = null
|
||||
state.status.value = 'unconfigured'
|
||||
renderPanel()
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
screen.getByRole('switch', {
|
||||
name: 'Enforce partner node allowlist'
|
||||
})
|
||||
).not.toBeChecked()
|
||||
const nodeToggles = screen.getAllByRole('switch', { name: /^Allow / })
|
||||
expect(nodeToggles).toHaveLength(3)
|
||||
for (const toggle of nodeToggles) {
|
||||
expect(toggle).toBeChecked()
|
||||
}
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('filters by provider and node identity', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('Search partner nodes...'),
|
||||
'veo'
|
||||
)
|
||||
|
||||
expect(screen.getByText('Veo Video')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Flux Fill')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Google')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers retry when the policy is unavailable', async () => {
|
||||
const user = userEvent.setup()
|
||||
state.status.value = 'unavailable'
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Try again' }))
|
||||
|
||||
expect(mockLoadPolicy).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'Partner node policy could not be loaded.'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps draft changes and surfaces a save failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockSavePolicy.mockRejectedValue(new Error('Conflict'))
|
||||
renderPanel()
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: 'Allow Flux Fill' })
|
||||
await user.click(toggle)
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(toggle).toBeChecked()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'Partner node policy could not be saved.'
|
||||
)
|
||||
})
|
||||
|
||||
it('releases the new workspace when a previous save fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
let rejectSave: (reason?: unknown) => void = () => undefined
|
||||
mockSavePolicy.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>((_resolve, reject) => {
|
||||
rejectSave = reject
|
||||
})
|
||||
)
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Allow Flux Fill' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'Allow Flux Fill' })
|
||||
).toBeDisabled()
|
||||
|
||||
state.governedWorkspaceId.value = 'workspace-two'
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'Allow Flux Fill' })
|
||||
).not.toBeDisabled()
|
||||
|
||||
rejectSave(new Error('Conflict'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(
|
||||
screen.queryByText('Allowlist could not be saved.')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,300 +0,0 @@
|
||||
<template>
|
||||
<div class="grow overflow-auto pt-6">
|
||||
<div
|
||||
class="flex size-full min-h-0 flex-col gap-5 rounded-2xl border border-interface-stroke p-6"
|
||||
>
|
||||
<div class="flex items-start gap-6">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="m-0 text-base font-semibold text-base-foreground">
|
||||
{{ $t('workspacePanel.allowlist.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 mb-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.allowlist.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('workspacePanel.allowlist.searchPlaceholder')"
|
||||
size="lg"
|
||||
class="w-64 shrink-0"
|
||||
:disabled="!isReady"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isReady"
|
||||
class="flex min-h-14 items-center gap-4 rounded-xl border border-interface-stroke/60 px-4 py-3"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm text-base-foreground">
|
||||
{{ $t('workspacePanel.allowlist.enforcement.title') }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-muted-foreground">
|
||||
{{ $t('workspacePanel.allowlist.enforcement.description') }}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
:model-value="draftEnforcementEnabled"
|
||||
:disabled="isSaving"
|
||||
:aria-label="$t('workspacePanel.allowlist.enforcement.toggle')"
|
||||
@update:model-value="setEnforcementEnabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status === 'loading'"
|
||||
role="status"
|
||||
class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--loader-circle] size-4 animate-spin" />
|
||||
{{ $t('workspacePanel.allowlist.loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="status === 'error' || status === 'unavailable'"
|
||||
role="alert"
|
||||
class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>{{ $t('workspacePanel.allowlist.loadError') }}</span>
|
||||
<Button variant="muted-textonly" @click="loadPolicy">
|
||||
{{ $t('workspacePanel.allowlist.retry') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="isReady && partnerNodes.length === 0"
|
||||
class="flex flex-1 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.allowlist.empty') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="isReady" class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div
|
||||
v-if="groups.length === 0"
|
||||
class="flex min-h-32 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.allowlist.noMatches') }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<section
|
||||
v-for="group in groups"
|
||||
:key="group.provider"
|
||||
class="mb-5 last:mb-0"
|
||||
>
|
||||
<div
|
||||
class="mb-2 flex items-center justify-between text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
<span>{{ group.provider }}</span>
|
||||
<span>
|
||||
{{
|
||||
$t('workspacePanel.allowlist.allowedCount', {
|
||||
enabled: group.enabledCount,
|
||||
total: group.nodes.length
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="overflow-hidden rounded-xl border border-interface-stroke/60"
|
||||
>
|
||||
<div
|
||||
v-for="node in group.nodes"
|
||||
:key="node.id"
|
||||
class="flex min-h-14 items-center gap-4 border-b border-interface-stroke/40 px-4 last:border-b-0"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm text-base-foreground">
|
||||
{{ node.name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="node.name !== node.id"
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{{ node.id }}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
:model-value="draftNodes[node.id]"
|
||||
:disabled="isSaving"
|
||||
:aria-label="
|
||||
$t('workspacePanel.allowlist.nodeToggle', {
|
||||
name: node.name
|
||||
})
|
||||
"
|
||||
@update:model-value="
|
||||
(enabled: boolean) => setNodeEnabled(node.id, enabled)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="isReady" class="flex min-h-10 items-center justify-end gap-3">
|
||||
<span v-if="saveError" role="alert" class="text-destructive text-sm">
|
||||
{{ $t('workspacePanel.allowlist.saveError') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:disabled="!hasChanges"
|
||||
:loading="isSaving"
|
||||
@click="save"
|
||||
>
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import type { PartnerNodeCatalogItem } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
|
||||
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
interface PartnerNodeGroup {
|
||||
provider: string
|
||||
nodes: PartnerNodeCatalogItem[]
|
||||
enabledCount: number
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const governanceStore = usePartnerNodeGovernanceStore()
|
||||
const { governedWorkspaceId, partnerNodes, policy, status } =
|
||||
storeToRefs(governanceStore)
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const draftNodes = ref<Record<string, boolean>>({})
|
||||
const draftEnforcementEnabled = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const saveError = ref(false)
|
||||
let saveGeneration = 0
|
||||
|
||||
const isReady = computed(
|
||||
() => status.value === 'configured' || status.value === 'unconfigured'
|
||||
)
|
||||
|
||||
function originalNodeValue(nodeId: string): boolean {
|
||||
return policy.value ? policy.value.nodes[nodeId] === true : true
|
||||
}
|
||||
|
||||
function resetDraft(): void {
|
||||
draftEnforcementEnabled.value = policy.value?.enforcementEnabled ?? false
|
||||
draftNodes.value = Object.fromEntries(
|
||||
partnerNodes.value.map((node) => [node.id, originalNodeValue(node.id)])
|
||||
)
|
||||
saveError.value = false
|
||||
}
|
||||
|
||||
watch([governedWorkspaceId, partnerNodes, policy], resetDraft, {
|
||||
immediate: true
|
||||
})
|
||||
|
||||
watch(
|
||||
governedWorkspaceId,
|
||||
() => {
|
||||
saveGeneration += 1
|
||||
isSaving.value = false
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
|
||||
const hasChanges = computed(
|
||||
() =>
|
||||
draftEnforcementEnabled.value !==
|
||||
(policy.value?.enforcementEnabled ?? false) ||
|
||||
partnerNodes.value.some(
|
||||
(node) => draftNodes.value[node.id] !== originalNodeValue(node.id)
|
||||
)
|
||||
)
|
||||
|
||||
const groups = computed<PartnerNodeGroup[]>(() => {
|
||||
const query = searchQuery.value.trim().toLocaleLowerCase()
|
||||
const byProvider = new Map<string, PartnerNodeCatalogItem[]>()
|
||||
|
||||
for (const node of partnerNodes.value) {
|
||||
if (
|
||||
query &&
|
||||
![node.name, node.id, node.provider].some((value) =>
|
||||
value.toLocaleLowerCase().includes(query)
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const nodes = byProvider.get(node.provider) ?? []
|
||||
nodes.push(node)
|
||||
byProvider.set(node.provider, nodes)
|
||||
}
|
||||
|
||||
return [...byProvider.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([provider, nodes]) => {
|
||||
const sortedNodes = nodes.sort((left, right) =>
|
||||
left.name.localeCompare(right.name)
|
||||
)
|
||||
return {
|
||||
provider,
|
||||
nodes: sortedNodes,
|
||||
enabledCount: sortedNodes.filter(
|
||||
(node) => draftNodes.value[node.id] === true
|
||||
).length
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function setNodeEnabled(nodeId: string, enabled: boolean): void {
|
||||
draftNodes.value[nodeId] = enabled
|
||||
saveError.value = false
|
||||
}
|
||||
|
||||
function setEnforcementEnabled(enabled: boolean): void {
|
||||
draftEnforcementEnabled.value = enabled
|
||||
saveError.value = false
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!hasChanges.value || isSaving.value) return
|
||||
|
||||
const generation = saveGeneration
|
||||
isSaving.value = true
|
||||
saveError.value = false
|
||||
try {
|
||||
const applied = await governanceStore.savePolicy({
|
||||
enforcementEnabled: draftEnforcementEnabled.value,
|
||||
nodes: {
|
||||
...(policy.value?.nodes ?? {}),
|
||||
...draftNodes.value
|
||||
}
|
||||
})
|
||||
if (!applied || generation !== saveGeneration) return
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
summary: t('workspacePanel.allowlist.saved'),
|
||||
life: 2000
|
||||
})
|
||||
} catch {
|
||||
if (generation !== saveGeneration) return
|
||||
saveError.value = true
|
||||
} finally {
|
||||
if (generation === saveGeneration) {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadPolicy(): void {
|
||||
void governanceStore.loadPolicy()
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,4 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -10,19 +9,12 @@ import WorkspacePanelContent from './WorkspacePanelContent.vue'
|
||||
const mockFetchMembers = vi.fn()
|
||||
const mockFetchPendingInvites = vi.fn()
|
||||
|
||||
const {
|
||||
mockGovernedWorkspaceId,
|
||||
mockMembers,
|
||||
mockWorkspaceRole,
|
||||
mockWorkspaceType
|
||||
} = vi.hoisted(() => {
|
||||
const { mockMembers, mockWorkspaceType } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
|
||||
return {
|
||||
mockGovernedWorkspaceId: ref<string | null>('workspace-one'),
|
||||
mockMembers: ref<WorkspaceMember[]>([]),
|
||||
mockWorkspaceRole: ref<'owner' | 'member'>('owner'),
|
||||
mockWorkspaceType: ref<'personal' | 'team'>('team')
|
||||
}
|
||||
})
|
||||
@@ -49,20 +41,16 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
return {
|
||||
useWorkspaceUI: () => ({
|
||||
workspaceType: mockWorkspaceType,
|
||||
workspaceRole: mockWorkspaceRole
|
||||
workspaceRole: ref('owner')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/partnerNodeGovernanceStore', () => ({
|
||||
usePartnerNodeGovernanceStore: () => ({
|
||||
governedWorkspaceId: mockGovernedWorkspaceId
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue',
|
||||
() => ({
|
||||
@@ -77,21 +65,6 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue',
|
||||
() => ({
|
||||
default: {
|
||||
name: 'BillingStatusBanner',
|
||||
template: '<div data-testid="billing-banner" />'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/dialogs/settings/PartnerNodeAllowlistPanel.vue',
|
||||
() => ({ default: { template: '<div />' } })
|
||||
)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -111,9 +84,8 @@ function createMember(id: string): WorkspaceMember {
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent(defaultTab?: string) {
|
||||
function renderComponent() {
|
||||
return render(WorkspacePanelContent, {
|
||||
props: { defaultTab },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { WorkspaceProfilePic: true }
|
||||
@@ -121,33 +93,10 @@ function renderComponent(defaultTab?: string) {
|
||||
})
|
||||
}
|
||||
|
||||
describe('WorkspacePanelContent billing banner', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMembers.value = []
|
||||
mockGovernedWorkspaceId.value = 'workspace-one'
|
||||
mockWorkspaceRole.value = 'owner'
|
||||
mockWorkspaceType.value = 'team'
|
||||
})
|
||||
|
||||
it('hosts a single banner slot above the tab content, so it shows on every tab', () => {
|
||||
renderComponent()
|
||||
|
||||
const banner = screen.getByTestId('billing-banner')
|
||||
const planPanel = screen.getByText('workspacePanel.tabs.planCredits')
|
||||
expect(
|
||||
planPanel.compareDocumentPosition(banner) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspacePanelContent members tab label', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMembers.value = []
|
||||
mockGovernedWorkspaceId.value = 'workspace-one'
|
||||
mockWorkspaceRole.value = 'owner'
|
||||
mockWorkspaceType.value = 'team'
|
||||
})
|
||||
|
||||
@@ -178,56 +127,3 @@ describe('WorkspacePanelContent members tab label', () => {
|
||||
expect(mockFetchPendingInvites).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspacePanelContent allowlist tab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGovernedWorkspaceId.value = 'workspace-one'
|
||||
mockWorkspaceRole.value = 'owner'
|
||||
mockWorkspaceType.value = 'team'
|
||||
})
|
||||
|
||||
it('shows the allowlist for an eligible workspace owner', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the allowlist from workspace members', () => {
|
||||
mockWorkspaceRole.value = 'member'
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the allowlist when governance is ineligible', () => {
|
||||
mockGovernedWorkspaceId.value = null
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to a valid tab when allowlist access is lost', async () => {
|
||||
renderComponent('allowlist')
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
mockWorkspaceRole.value = 'member'
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
|
||||
).toBeNull()
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'workspacePanel.tabs.planCredits' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,48 +39,28 @@
|
||||
: $t('workspacePanel.members.header')
|
||||
}}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
v-if="showAllowlistTab"
|
||||
value="allowlist"
|
||||
:class="
|
||||
cn(
|
||||
tabTriggerBase,
|
||||
activeTab === 'allowlist' ? tabTriggerActive : tabTriggerInactive
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ $t('workspacePanel.tabs.allowlist') }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<BillingStatusBanner class="mt-4" />
|
||||
|
||||
<TabsContent value="plan" class="mt-4">
|
||||
<SubscriptionPanelContentWorkspace />
|
||||
</TabsContent>
|
||||
<TabsContent value="members" class="mt-4">
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</TabsContent>
|
||||
<TabsContent v-if="showAllowlistTab" value="allowlist" class="mt-4">
|
||||
<PartnerNodeAllowlistPanel />
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
|
||||
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
|
||||
import PartnerNodeAllowlistPanel from '@/platform/workspace/components/dialogs/settings/PartnerNodeAllowlistPanel.vue'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
@@ -100,19 +80,9 @@ const { workspaceName, members } = storeToRefs(workspaceStore)
|
||||
const { fetchMembers, fetchPendingInvites } = workspaceStore
|
||||
|
||||
const { workspaceType, workspaceRole } = useWorkspaceUI()
|
||||
const { governedWorkspaceId } = storeToRefs(usePartnerNodeGovernanceStore())
|
||||
const isPersonalWorkspace = computed(() => workspaceType.value === 'personal')
|
||||
const showAllowlistTab = computed(
|
||||
() => !!governedWorkspaceId.value && workspaceRole.value === 'owner'
|
||||
)
|
||||
const activeTab = ref(defaultTab)
|
||||
|
||||
watch(showAllowlistTab, (isVisible) => {
|
||||
if (!isVisible && activeTab.value === 'allowlist') {
|
||||
activeTab.value = defaultTab === 'allowlist' ? 'plan' : defaultTab
|
||||
}
|
||||
})
|
||||
|
||||
// Per design, the tab counts members only when there is more than the owner
|
||||
const showMembersTabCount = computed(
|
||||
() => !isPersonalWorkspace.value && members.value.length > 1
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { BillingBannerInputs } from './useBillingBanner'
|
||||
import { deriveBillingBanner } from './useBillingBanner'
|
||||
|
||||
const funded: BillingBannerInputs = {
|
||||
billingControlEnabled: true,
|
||||
isTeamPlan: true,
|
||||
isLoaded: true,
|
||||
isActiveSubscription: true,
|
||||
billingStatus: 'paid',
|
||||
hasFunds: true,
|
||||
isCancelled: false,
|
||||
endDate: null,
|
||||
canManage: true,
|
||||
outOfCreditsDismissed: false
|
||||
}
|
||||
|
||||
// The backend folds billing_status into is_active, so every spend-denying status
|
||||
// arrives paired with is_active=false. Pinning that pairing is what keeps these
|
||||
// cases honest — spreading `funded` instead would assert an input the backend
|
||||
// cannot emit, and pass no matter where the check sits.
|
||||
const paused: Partial<BillingBannerInputs> = {
|
||||
billingStatus: 'paused',
|
||||
isActiveSubscription: false
|
||||
}
|
||||
|
||||
const paymentFailed: Partial<BillingBannerInputs> = {
|
||||
billingStatus: 'payment_failed',
|
||||
isActiveSubscription: false
|
||||
}
|
||||
|
||||
function derive(overrides: Partial<BillingBannerInputs>) {
|
||||
return deriveBillingBanner({ ...funded, ...overrides })
|
||||
}
|
||||
|
||||
describe('deriveBillingBanner', () => {
|
||||
it('shows no banner for a healthy funded team', () => {
|
||||
expect(derive({})).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no banner outside a team plan', () => {
|
||||
expect(derive({ isTeamPlan: false, hasFunds: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no banner when billing control is rolled back, even out of credits', () => {
|
||||
expect(derive({ billingControlEnabled: false, hasFunds: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no banner until the subscription snapshot has loaded', () => {
|
||||
expect(derive({ isLoaded: false, hasFunds: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces out of credits when the balance is exhausted', () => {
|
||||
expect(derive({ hasFunds: false })).toBe('outOfCredits')
|
||||
})
|
||||
|
||||
it('shows out of credits to members too', () => {
|
||||
expect(derive({ hasFunds: false, canManage: false })).toBe('outOfCredits')
|
||||
})
|
||||
|
||||
it('hides out of credits once dismissed', () => {
|
||||
expect(derive({ hasFunds: false, outOfCreditsDismissed: true })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows payment failed to owners even though the backend reports the plan inactive', () => {
|
||||
expect(derive(paymentFailed)).toBe('paymentFailed')
|
||||
})
|
||||
|
||||
it('prioritizes payment failure over out of credits for owners', () => {
|
||||
expect(derive({ ...paymentFailed, hasFunds: false })).toBe('paymentFailed')
|
||||
})
|
||||
|
||||
it('hides payment failed from members, who get the run-lock modal instead', () => {
|
||||
expect(derive({ ...paymentFailed, canManage: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('prioritizes paused above everything, for owners and members', () => {
|
||||
expect(derive({ ...paused, hasFunds: false })).toBe('paused')
|
||||
expect(derive({ ...paused, canManage: false })).toBe('paused')
|
||||
})
|
||||
|
||||
it('shows paused even though the backend reports the workspace inactive', () => {
|
||||
expect(derive(paused)).toBe('paused')
|
||||
})
|
||||
|
||||
it('surfaces the ending banner for a cancelled-but-active owner', () => {
|
||||
expect(
|
||||
derive({
|
||||
isCancelled: true,
|
||||
endDate: '2026-08-01T00:00:00Z'
|
||||
})
|
||||
).toBe('ending')
|
||||
})
|
||||
|
||||
it('does not show the ending banner until the end date is populated', () => {
|
||||
expect(
|
||||
derive({
|
||||
isCancelled: true,
|
||||
endDate: null
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the ending banner from members', () => {
|
||||
expect(
|
||||
derive({
|
||||
isCancelled: true,
|
||||
endDate: '2026-08-01T00:00:00Z',
|
||||
canManage: false
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no banner for an inactive subscription (that is a run-lock modal)', () => {
|
||||
expect(
|
||||
derive({ isActiveSubscription: false, billingStatus: 'inactive' })
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,99 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
billing: null as {
|
||||
isActiveSubscription: { value: boolean }
|
||||
isTeamPlan: { value: boolean }
|
||||
billingStatus: { value: string | null }
|
||||
subscription: { value: { hasFunds: boolean } | null }
|
||||
} | null,
|
||||
billingControlEnabled: null as { value: boolean } | null
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({ isCloud: true }))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const billingControlEnabled = ref(true)
|
||||
mocks.billingControlEnabled = billingControlEnabled
|
||||
return {
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get billingControlEnabled() {
|
||||
return billingControlEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const billing = {
|
||||
isActiveSubscription: ref(true),
|
||||
isTeamPlan: ref(true),
|
||||
billingStatus: ref<string | null>('paid'),
|
||||
subscription: ref<{ hasFunds: boolean } | null>({ hasFunds: true })
|
||||
}
|
||||
mocks.billing = billing
|
||||
return { useBillingContext: () => billing }
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', async () => {
|
||||
const { computed } = await import('vue')
|
||||
return {
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: computed(() => ({
|
||||
canManageSubscription: true,
|
||||
canManageSubscriptionLifecycle: true,
|
||||
canTopUp: true
|
||||
}))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
import { useBillingBanner } from './useBillingBanner'
|
||||
|
||||
describe('useBillingBanner', () => {
|
||||
beforeEach(() => {
|
||||
const b = mocks.billing!
|
||||
b.isActiveSubscription.value = true
|
||||
b.isTeamPlan.value = true
|
||||
b.billingStatus.value = 'paid'
|
||||
b.subscription.value = { hasFunds: true }
|
||||
mocks.billingControlEnabled!.value = true
|
||||
})
|
||||
|
||||
it('suppresses the banner entirely when billing control is rolled back', async () => {
|
||||
const b = mocks.billing!
|
||||
const { kind } = useBillingBanner()
|
||||
|
||||
b.subscription.value = { hasFunds: false }
|
||||
await nextTick()
|
||||
expect(kind.value).toBe('outOfCredits')
|
||||
|
||||
mocks.billingControlEnabled!.value = false
|
||||
await nextTick()
|
||||
expect(kind.value).toBeNull()
|
||||
})
|
||||
|
||||
it('re-shows the out-of-credits banner after a top-up and a later exhaustion', async () => {
|
||||
const b = mocks.billing!
|
||||
const { kind, dismiss } = useBillingBanner()
|
||||
|
||||
b.subscription.value = { hasFunds: false }
|
||||
await nextTick()
|
||||
expect(kind.value).toBe('outOfCredits')
|
||||
|
||||
dismiss()
|
||||
await nextTick()
|
||||
expect(kind.value).toBeNull()
|
||||
|
||||
b.subscription.value = { hasFunds: true }
|
||||
await nextTick()
|
||||
b.subscription.value = { hasFunds: false }
|
||||
await nextTick()
|
||||
expect(kind.value).toBe('outOfCredits')
|
||||
})
|
||||
})
|
||||
@@ -1,106 +0,0 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { BillingStatus } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
|
||||
export type BillingBannerKind =
|
||||
| 'paused'
|
||||
| 'paymentFailed'
|
||||
| 'outOfCredits'
|
||||
| 'ending'
|
||||
|
||||
export interface BillingBannerInputs {
|
||||
billingControlEnabled: boolean
|
||||
isTeamPlan: boolean
|
||||
isLoaded: boolean
|
||||
isActiveSubscription: boolean
|
||||
billingStatus: BillingStatus | null
|
||||
hasFunds: boolean | null
|
||||
isCancelled: boolean
|
||||
endDate: string | null
|
||||
canManage: boolean
|
||||
outOfCreditsDismissed: boolean
|
||||
}
|
||||
|
||||
// The single billing banner slot, in priority order: paused > paymentFailed >
|
||||
// outOfCredits > ending. billingControlEnabled is the FE-1246 kill switch: the
|
||||
// whole banner is behind it so a PostHog rollback hides it for everyone. Then
|
||||
// gated on the team PLAN rather than the workspace type, because personal
|
||||
// workspaces are due to gain team plans (BE-1526) — a workspace-type gate would
|
||||
// then hide the banner from real team subscribers.
|
||||
export function deriveBillingBanner(
|
||||
inputs: BillingBannerInputs
|
||||
): BillingBannerKind | null {
|
||||
if (!inputs.billingControlEnabled || !inputs.isTeamPlan || !inputs.isLoaded) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Both sit above the isActiveSubscription gate because the backend folds
|
||||
// billing_status into is_active: paused and payment_failed each report
|
||||
// is_active=false, so either check would be dead code below it.
|
||||
if (inputs.billingStatus === 'paused') return 'paused'
|
||||
if (inputs.billingStatus === 'payment_failed' && inputs.canManage) {
|
||||
return 'paymentFailed'
|
||||
}
|
||||
|
||||
// Inactive workspaces surface a run-lock modal, not this banner. Members hit
|
||||
// this on payment_failed, which is per design — only billing managers see it.
|
||||
if (!inputs.isActiveSubscription) return null
|
||||
|
||||
if (inputs.hasFunds === false && !inputs.outOfCreditsDismissed) {
|
||||
return 'outOfCredits'
|
||||
}
|
||||
if (inputs.isCancelled && inputs.endDate && inputs.canManage) {
|
||||
return 'ending'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function useBillingBannerInternal() {
|
||||
const { isActiveSubscription, billingStatus, subscription, isTeamPlan } =
|
||||
useBillingContext()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const dismissed = ref(false)
|
||||
|
||||
const kind = computed<BillingBannerKind | null>(() => {
|
||||
if (!isCloud) return null
|
||||
return deriveBillingBanner({
|
||||
billingControlEnabled: flags.billingControlEnabled,
|
||||
isTeamPlan: isTeamPlan.value,
|
||||
isLoaded: subscription.value !== null,
|
||||
isActiveSubscription: isActiveSubscription.value,
|
||||
billingStatus: billingStatus.value,
|
||||
hasFunds: subscription.value?.hasFunds ?? null,
|
||||
isCancelled: subscription.value?.isCancelled ?? false,
|
||||
endDate: subscription.value?.endDate ?? null,
|
||||
canManage: permissions.value.canManageSubscription,
|
||||
outOfCreditsDismissed: dismissed.value
|
||||
})
|
||||
})
|
||||
|
||||
// Dismiss silences only the out-of-credits banner, and only for the current
|
||||
// exhaustion episode: reset once the workspace is funded again so a later
|
||||
// exhaustion re-shows. Shared state, so it survives the settings panel
|
||||
// unmounting when the dialog closes.
|
||||
const hasExhaustedFunds = computed(
|
||||
() => subscription.value?.hasFunds === false
|
||||
)
|
||||
watch(hasExhaustedFunds, (exhausted) => {
|
||||
if (!exhausted) dismissed.value = false
|
||||
})
|
||||
|
||||
function dismiss() {
|
||||
dismissed.value = true
|
||||
}
|
||||
|
||||
return { kind, dismiss }
|
||||
}
|
||||
|
||||
export const useBillingBanner = createSharedComposable(useBillingBannerInternal)
|
||||
@@ -1,430 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type * as PartnerNodePolicyApi from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import { PartnerNodePolicyApiError } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
const mockGetPartnerNodePolicy = vi.hoisted(() => vi.fn())
|
||||
const mockUpdatePartnerNodePolicy = vi.hoisted(() => vi.fn())
|
||||
const mockFlags = vi.hoisted(() => ({
|
||||
teamWorkspacesEnabled: true,
|
||||
partnerNodeGovernanceEnabled: true
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/api/partnerNodePolicyApi',
|
||||
async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof PartnerNodePolicyApi>()
|
||||
return {
|
||||
...actual,
|
||||
getPartnerNodePolicy: mockGetPartnerNodePolicy,
|
||||
updatePartnerNodePolicy: mockUpdatePartnerNodePolicy
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function nodeDef(
|
||||
name: string,
|
||||
overrides: Partial<ComfyNodeDef> = {}
|
||||
): ComfyNodeDef {
|
||||
return {
|
||||
name,
|
||||
display_name: `Display ${name}`,
|
||||
category: 'partner/image/Provider',
|
||||
python_module: 'comfy_api_nodes.provider',
|
||||
description: '',
|
||||
input: {},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
output_node: false,
|
||||
api_node: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function activateWorkspace(id: string, type: 'personal' | 'team' = 'team') {
|
||||
const store = useTeamWorkspaceStore()
|
||||
store.workspaces = [
|
||||
{
|
||||
id,
|
||||
name: id,
|
||||
type,
|
||||
role: 'owner',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z',
|
||||
isSubscribed: false,
|
||||
subscriptionPlan: null,
|
||||
subscriptionTier: null,
|
||||
members: [],
|
||||
pendingInvites: []
|
||||
}
|
||||
]
|
||||
store.activeWorkspaceId = id
|
||||
}
|
||||
|
||||
async function createLoadedStore() {
|
||||
const store = usePartnerNodeGovernanceStore()
|
||||
await vi.waitFor(() => expect(store.status).not.toBe('loading'))
|
||||
return store
|
||||
}
|
||||
|
||||
describe('partnerNodeGovernanceStore', () => {
|
||||
let store: ReturnType<typeof usePartnerNodeGovernanceStore> | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.partnerNodeGovernanceEnabled = true
|
||||
mockGetPartnerNodePolicy.mockResolvedValue(null)
|
||||
mockUpdatePartnerNodePolicy.mockImplementation(
|
||||
async (policy: PartnerNodePolicy) => policy
|
||||
)
|
||||
activateWorkspace('workspace-one')
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
nodeDef('AllowedNode'),
|
||||
nodeDef('DisabledNode'),
|
||||
nodeDef('UnreviewedNode'),
|
||||
nodeDef('CoreNode', {
|
||||
api_node: false,
|
||||
category: 'sampling',
|
||||
python_module: 'nodes'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete LiteGraph.registered_node_types.LegacyGovernedNode
|
||||
store?.$dispose()
|
||||
store = undefined
|
||||
})
|
||||
|
||||
it('composes partner-node catalog metadata from object info', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
nodeDef('PartnerNode', {
|
||||
display_name: 'Partner Node',
|
||||
category: 'partner/video/Acme'
|
||||
}),
|
||||
nodeDef('CoreNode', { api_node: false })
|
||||
])
|
||||
|
||||
store = await createLoadedStore()
|
||||
|
||||
expect(store.partnerNodes).toEqual([
|
||||
{ id: 'PartnerNode', name: 'Partner Node', provider: 'Acme' }
|
||||
])
|
||||
})
|
||||
|
||||
it('treats 404 as unconfigured and allows every node', async () => {
|
||||
store = await createLoadedStore()
|
||||
|
||||
expect(store.status).toBe('unconfigured')
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not block nodes while enforcement is off', async () => {
|
||||
mockGetPartnerNodePolicy.mockResolvedValue({
|
||||
enforcementEnabled: false,
|
||||
nodes: { AllowedNode: true, DisabledNode: false }
|
||||
} satisfies PartnerNodePolicy)
|
||||
|
||||
store = await createLoadedStore()
|
||||
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows only explicit true rules while enforcement is on', async () => {
|
||||
mockGetPartnerNodePolicy.mockResolvedValue({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true, DisabledNode: false }
|
||||
} satisfies PartnerNodePolicy)
|
||||
|
||||
store = await createLoadedStore()
|
||||
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(true)
|
||||
expect(store.isNodeDisabled('UnreviewedNode')).toBe(true)
|
||||
expect(store.isNodeDisabled('CoreNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('filters disabled nodes out of discovery', async () => {
|
||||
mockGetPartnerNodePolicy.mockResolvedValue({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true, DisabledNode: false }
|
||||
} satisfies PartnerNodePolicy)
|
||||
|
||||
store = await createLoadedStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
expect(nodeDefStore.visibleNodeDefs.map((node) => node.name)).toEqual([
|
||||
'AllowedNode',
|
||||
'CoreNode'
|
||||
])
|
||||
expect(nodeDefStore.nodeSearchService.searchNode('DisabledNode')).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the litegraph legacy menu in sync with policy changes', async () => {
|
||||
class LegacyGovernedNode extends LGraphNode {}
|
||||
LiteGraph.registered_node_types.LegacyGovernedNode = LegacyGovernedNode
|
||||
useNodeDefStore().addNodeDef(nodeDef('LegacyGovernedNode'))
|
||||
mockGetPartnerNodePolicy.mockResolvedValue({
|
||||
enforcementEnabled: true,
|
||||
nodes: { LegacyGovernedNode: false }
|
||||
} satisfies PartnerNodePolicy)
|
||||
|
||||
store = await createLoadedStore()
|
||||
|
||||
expect(LegacyGovernedNode.skip_list).toBe(true)
|
||||
|
||||
mockGetPartnerNodePolicy.mockResolvedValue({
|
||||
enforcementEnabled: false,
|
||||
nodes: { LegacyGovernedNode: false }
|
||||
} satisfies PartnerNodePolicy)
|
||||
await store.loadPolicy()
|
||||
await nextTick()
|
||||
|
||||
expect(LegacyGovernedNode.skip_list).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed for a 503 from an enforcing workspace', async () => {
|
||||
mockGetPartnerNodePolicy.mockRejectedValue(
|
||||
new PartnerNodePolicyApiError(503, 'Service Unavailable')
|
||||
)
|
||||
|
||||
store = await createLoadedStore()
|
||||
|
||||
expect(store.status).toBe('unavailable')
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(true)
|
||||
expect(store.isNodeDisabled('CoreNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('fails open during initial loading and a generic failure', async () => {
|
||||
let rejectLoad!: (error: Error) => void
|
||||
mockGetPartnerNodePolicy.mockReturnValue(
|
||||
new Promise((_, reject) => {
|
||||
rejectLoad = reject
|
||||
})
|
||||
)
|
||||
|
||||
store = usePartnerNodeGovernanceStore()
|
||||
|
||||
expect(store.status).toBe('loading')
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
|
||||
rejectLoad(new Error('Network error'))
|
||||
await vi.waitFor(() => expect(store?.status).toBe('error'))
|
||||
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('stays fail-closed while retrying an unavailable policy', async () => {
|
||||
mockGetPartnerNodePolicy.mockRejectedValueOnce(
|
||||
new PartnerNodePolicyApiError(503, 'Service Unavailable')
|
||||
)
|
||||
store = await createLoadedStore()
|
||||
let rejectRetry!: (error: Error) => void
|
||||
mockGetPartnerNodePolicy.mockReturnValueOnce(
|
||||
new Promise((_, reject) => {
|
||||
rejectRetry = reject
|
||||
})
|
||||
)
|
||||
|
||||
const retry = store.loadPolicy()
|
||||
|
||||
expect(store.status).toBe('unavailable')
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(true)
|
||||
rejectRetry(new Error('Network error'))
|
||||
await retry
|
||||
expect(store.status).toBe('unavailable')
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves the last enforcing policy after a generic refresh error', async () => {
|
||||
mockGetPartnerNodePolicy.mockResolvedValue({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true, DisabledNode: false }
|
||||
} satisfies PartnerNodePolicy)
|
||||
store = await createLoadedStore()
|
||||
mockGetPartnerNodePolicy.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await store.loadPolicy()
|
||||
|
||||
expect(store.status).toBe('error')
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(true)
|
||||
})
|
||||
|
||||
it('stays inactive when partner-node governance is disabled', async () => {
|
||||
mockFlags.partnerNodeGovernanceEnabled = false
|
||||
|
||||
store = usePartnerNodeGovernanceStore()
|
||||
await nextTick()
|
||||
|
||||
expect(mockGetPartnerNodePolicy).not.toHaveBeenCalled()
|
||||
expect(store.status).toBe('inactive')
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('stays inactive in a personal workspace', async () => {
|
||||
activateWorkspace('personal-workspace', 'personal')
|
||||
|
||||
store = usePartnerNodeGovernanceStore()
|
||||
await nextTick()
|
||||
|
||||
expect(mockGetPartnerNodePolicy).not.toHaveBeenCalled()
|
||||
expect(store.status).toBe('inactive')
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a stale response after switching workspaces', async () => {
|
||||
let resolveFirst!: (policy: PartnerNodePolicy) => void
|
||||
mockGetPartnerNodePolicy
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
enforcementEnabled: true,
|
||||
nodes: { DisabledNode: true }
|
||||
} satisfies PartnerNodePolicy)
|
||||
store = usePartnerNodeGovernanceStore()
|
||||
await vi.waitFor(() =>
|
||||
expect(mockGetPartnerNodePolicy).toHaveBeenCalledTimes(1)
|
||||
)
|
||||
|
||||
activateWorkspace('workspace-two')
|
||||
await vi.waitFor(() => expect(store?.status).toBe('configured'))
|
||||
resolveFirst({
|
||||
enforcementEnabled: true,
|
||||
nodes: { DisabledNode: false }
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(store.governedWorkspaceId).toBe('workspace-two')
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('saves and installs the validated whole policy', async () => {
|
||||
store = await createLoadedStore()
|
||||
const nextPolicy = {
|
||||
enforcementEnabled: false,
|
||||
nodes: { AllowedNode: true, DisabledNode: true }
|
||||
} satisfies PartnerNodePolicy
|
||||
|
||||
await expect(store.savePolicy(nextPolicy)).resolves.toBe(true)
|
||||
|
||||
expect(mockUpdatePartnerNodePolicy).toHaveBeenCalledWith(nextPolicy)
|
||||
expect(store.policy).toEqual(nextPolicy)
|
||||
expect(store.status).toBe('configured')
|
||||
})
|
||||
|
||||
it('does not install a save response after switching workspaces', async () => {
|
||||
store = await createLoadedStore()
|
||||
let resolveSave!: (policy: PartnerNodePolicy) => void
|
||||
mockUpdatePartnerNodePolicy.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveSave = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const save = store.savePolicy({
|
||||
enforcementEnabled: false,
|
||||
nodes: { AllowedNode: true }
|
||||
})
|
||||
activateWorkspace('workspace-two')
|
||||
await vi.waitFor(() =>
|
||||
expect(store?.governedWorkspaceId).toBe('workspace-two')
|
||||
)
|
||||
resolveSave({
|
||||
enforcementEnabled: false,
|
||||
nodes: { AllowedNode: true }
|
||||
})
|
||||
|
||||
await expect(save).resolves.toBe(false)
|
||||
await vi.waitFor(() => expect(store?.status).toBe('unconfigured'))
|
||||
expect(store.policy).toBeNull()
|
||||
})
|
||||
|
||||
it('does not install a save response after switching away and back', async () => {
|
||||
store = await createLoadedStore()
|
||||
let resolveSave!: (policy: PartnerNodePolicy) => void
|
||||
mockUpdatePartnerNodePolicy.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveSave = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const save = store.savePolicy({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true }
|
||||
})
|
||||
activateWorkspace('workspace-two')
|
||||
await vi.waitFor(() =>
|
||||
expect(store?.governedWorkspaceId).toBe('workspace-two')
|
||||
)
|
||||
activateWorkspace('workspace-one')
|
||||
await vi.waitFor(() =>
|
||||
expect(store?.governedWorkspaceId).toBe('workspace-one')
|
||||
)
|
||||
resolveSave({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true }
|
||||
})
|
||||
|
||||
await expect(save).resolves.toBe(false)
|
||||
await vi.waitFor(() => expect(store?.status).toBe('unconfigured'))
|
||||
expect(store.policy).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a stale save rejection after switching workspaces', async () => {
|
||||
store = await createLoadedStore()
|
||||
let rejectSave!: (error: Error) => void
|
||||
mockUpdatePartnerNodePolicy.mockReturnValueOnce(
|
||||
new Promise((_, reject) => {
|
||||
rejectSave = reject
|
||||
})
|
||||
)
|
||||
|
||||
const save = store.savePolicy({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true }
|
||||
})
|
||||
activateWorkspace('workspace-two')
|
||||
await vi.waitFor(() =>
|
||||
expect(store?.governedWorkspaceId).toBe('workspace-two')
|
||||
)
|
||||
rejectSave(new Error('Conflict'))
|
||||
|
||||
await expect(save).resolves.toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses to save before governance is ready', async () => {
|
||||
mockFlags.partnerNodeGovernanceEnabled = false
|
||||
store = usePartnerNodeGovernanceStore()
|
||||
await nextTick()
|
||||
|
||||
await expect(
|
||||
store.savePolicy({ enforcementEnabled: false, nodes: {} })
|
||||
).rejects.toThrow('Partner node governance is not ready')
|
||||
expect(mockUpdatePartnerNodePolicy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,213 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef, watch, watchEffect } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
getPartnerNodePolicy,
|
||||
PartnerNodePolicyApiError,
|
||||
updatePartnerNodePolicy
|
||||
} from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
export interface PartnerNodeCatalogItem {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export type PartnerNodePolicyStatus =
|
||||
| 'inactive'
|
||||
| 'loading'
|
||||
| 'unconfigured'
|
||||
| 'configured'
|
||||
| 'unavailable'
|
||||
| 'error'
|
||||
|
||||
const DISCOVERY_FILTER_ID = 'workspace.partner-node-governance'
|
||||
|
||||
export const usePartnerNodeGovernanceStore = defineStore(
|
||||
'partnerNodeGovernance',
|
||||
() => {
|
||||
const { flags } = useFeatureFlags()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const policy = shallowRef<PartnerNodePolicy | null>(null)
|
||||
const policyWorkspaceId = ref<string | null>(null)
|
||||
const status = ref<PartnerNodePolicyStatus>('inactive')
|
||||
const error = shallowRef<Error | null>(null)
|
||||
let loadVersion = 0
|
||||
let workspaceVersion = 0
|
||||
|
||||
const governedWorkspaceId = computed(() => {
|
||||
const workspace = workspaceStore.activeWorkspace
|
||||
return flags.teamWorkspacesEnabled &&
|
||||
flags.partnerNodeGovernanceEnabled &&
|
||||
workspace?.type === 'team'
|
||||
? workspace.id
|
||||
: null
|
||||
})
|
||||
|
||||
const partnerNodes = computed<PartnerNodeCatalogItem[]>(() =>
|
||||
Object.values(nodeDefStore.nodeDefsByName)
|
||||
.filter((nodeDef) => nodeDef.api_node)
|
||||
.map((nodeDef) => ({
|
||||
id: nodeDef.name,
|
||||
name: nodeDef.display_name || nodeDef.name,
|
||||
provider:
|
||||
nodeDef.category.split('/')[2] ||
|
||||
nodeDef.category ||
|
||||
nodeDef.python_module
|
||||
}))
|
||||
)
|
||||
|
||||
function isNodeDisabled(nodeType: string): boolean {
|
||||
if (!nodeDefStore.nodeDefsByName[nodeType]?.api_node) return false
|
||||
const workspaceId = governedWorkspaceId.value
|
||||
if (!workspaceId || policyWorkspaceId.value !== workspaceId) return false
|
||||
if (status.value === 'unavailable') return true
|
||||
return (
|
||||
policy.value?.enforcementEnabled === true &&
|
||||
policy.value.nodes[nodeType] !== true
|
||||
)
|
||||
}
|
||||
|
||||
nodeDefStore.registerNodeDefFilter({
|
||||
id: DISCOVERY_FILTER_ID,
|
||||
name: 'Workspace partner-node governance',
|
||||
predicate: (nodeDef) => !isNodeDisabled(nodeDef.name)
|
||||
})
|
||||
|
||||
const legacyHiddenNodeTypes = new Set<string>()
|
||||
watchEffect(() => {
|
||||
const showDevOnly = nodeDefStore.showDevOnly
|
||||
for (const nodeDef of Object.values(nodeDefStore.nodeDefsByName)) {
|
||||
if (!nodeDef.api_node) continue
|
||||
const nodeType = LiteGraph.registered_node_types[nodeDef.name]
|
||||
if (!nodeType) continue
|
||||
|
||||
if (isNodeDisabled(nodeDef.name)) {
|
||||
if (!nodeType.skip_list) {
|
||||
nodeType.skip_list = true
|
||||
legacyHiddenNodeTypes.add(nodeDef.name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (legacyHiddenNodeTypes.delete(nodeDef.name)) {
|
||||
nodeType.skip_list = nodeDef.dev_only && !showDevOnly
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function loadPolicy(): Promise<void> {
|
||||
const workspaceId = governedWorkspaceId.value
|
||||
const version = ++loadVersion
|
||||
if (!workspaceId) {
|
||||
policy.value = null
|
||||
policyWorkspaceId.value = null
|
||||
status.value = 'inactive'
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const workspaceChanged = policyWorkspaceId.value !== workspaceId
|
||||
if (workspaceChanged) {
|
||||
policy.value = null
|
||||
policyWorkspaceId.value = workspaceId
|
||||
status.value = 'loading'
|
||||
} else if (status.value !== 'unavailable') {
|
||||
status.value = 'loading'
|
||||
}
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const nextPolicy = await getPartnerNodePolicy()
|
||||
if (
|
||||
version !== loadVersion ||
|
||||
governedWorkspaceId.value !== workspaceId
|
||||
) {
|
||||
return
|
||||
}
|
||||
policy.value = nextPolicy
|
||||
status.value = nextPolicy ? 'configured' : 'unconfigured'
|
||||
} catch (loadError) {
|
||||
if (
|
||||
version !== loadVersion ||
|
||||
governedWorkspaceId.value !== workspaceId
|
||||
) {
|
||||
return
|
||||
}
|
||||
error.value =
|
||||
loadError instanceof Error
|
||||
? loadError
|
||||
: new Error('Failed to load partner node policy')
|
||||
if (
|
||||
loadError instanceof PartnerNodePolicyApiError &&
|
||||
loadError.status === 503
|
||||
) {
|
||||
policy.value = null
|
||||
status.value = 'unavailable'
|
||||
return
|
||||
}
|
||||
if (status.value !== 'unavailable') status.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
async function savePolicy(nextPolicy: PartnerNodePolicy): Promise<boolean> {
|
||||
const workspaceId = governedWorkspaceId.value
|
||||
const version = workspaceVersion
|
||||
if (!workspaceId || policyWorkspaceId.value !== workspaceId) {
|
||||
throw new Error('Partner node governance is not ready')
|
||||
}
|
||||
|
||||
let savedPolicy: PartnerNodePolicy
|
||||
try {
|
||||
savedPolicy = await updatePartnerNodePolicy(nextPolicy)
|
||||
} catch (saveError) {
|
||||
if (
|
||||
workspaceVersion !== version ||
|
||||
governedWorkspaceId.value !== workspaceId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
throw saveError
|
||||
}
|
||||
if (
|
||||
workspaceVersion !== version ||
|
||||
governedWorkspaceId.value !== workspaceId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
policy.value = savedPolicy
|
||||
policyWorkspaceId.value = workspaceId
|
||||
status.value = 'configured'
|
||||
error.value = null
|
||||
return true
|
||||
}
|
||||
|
||||
watch(
|
||||
governedWorkspaceId,
|
||||
() => {
|
||||
workspaceVersion++
|
||||
void loadPolicy()
|
||||
},
|
||||
{ immediate: true, flush: 'sync' }
|
||||
)
|
||||
|
||||
return {
|
||||
policy,
|
||||
status,
|
||||
error,
|
||||
governedWorkspaceId,
|
||||
partnerNodes,
|
||||
isNodeDisabled,
|
||||
loadPolicy,
|
||||
savePolicy
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -12,7 +12,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const billingMock = vi.hoisted(() => ({
|
||||
canRunWorkflows: true
|
||||
isActiveSubscription: true
|
||||
}))
|
||||
|
||||
const overlayMock = vi.hoisted(() => ({
|
||||
@@ -22,7 +22,7 @@ const overlayMock = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
canRunWorkflows: billingMock.canRunWorkflows
|
||||
isActiveSubscription: billingMock.isActiveSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -77,14 +77,14 @@ const nodeErrors: Record<string, NodeError> = {
|
||||
|
||||
function renderControls({
|
||||
hasError = false,
|
||||
canRunWorkflows = true,
|
||||
isActiveSubscription = true,
|
||||
mobile = false
|
||||
}: {
|
||||
hasError?: boolean
|
||||
canRunWorkflows?: boolean
|
||||
isActiveSubscription?: boolean
|
||||
mobile?: boolean
|
||||
} = {}) {
|
||||
billingMock.canRunWorkflows = canRunWorkflows
|
||||
billingMock.isActiveSubscription = isActiveSubscription
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
@@ -120,7 +120,7 @@ function renderControls({
|
||||
describe('LinearControls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
billingMock.canRunWorkflows = true
|
||||
billingMock.isActiveSubscription = true
|
||||
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
|
||||
overlayMock.overlayTitle = 'Required input missing'
|
||||
})
|
||||
@@ -187,7 +187,7 @@ describe('LinearControls', () => {
|
||||
({ mobile }) => {
|
||||
renderControls({
|
||||
hasError: true,
|
||||
canRunWorkflows: false,
|
||||
isActiveSubscription: false,
|
||||
mobile
|
||||
})
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -29,7 +28,7 @@ const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
const settingStore = useSettingStore()
|
||||
const { canRunWorkflows } = useBillingContext()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
@@ -55,7 +54,7 @@ const linearRunButtonTestId = 'linear-run-button'
|
||||
const showRunErrorWarning = computed(
|
||||
() =>
|
||||
hasAnyError.value &&
|
||||
toValue(canRunWorkflows) &&
|
||||
toValue(isActiveSubscription) &&
|
||||
toValue(overlayMessage).trim().length > 0
|
||||
)
|
||||
|
||||
@@ -153,7 +152,10 @@ function handleDragDrop() {
|
||||
class="border-t border-node-component-border p-4 pb-6"
|
||||
>
|
||||
<LinearRunErrorWarning v-if="showRunErrorWarning" />
|
||||
<SubscribeToRunButton v-if="!canRunWorkflows" class="mt-4 w-full" />
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
class="mt-4 w-full"
|
||||
/>
|
||||
<div v-else class="mt-4 flex">
|
||||
<PartnerNodesList mobile />
|
||||
<Popover side="top" @open-auto-focus.prevent>
|
||||
@@ -208,7 +210,10 @@ function handleDragDrop() {
|
||||
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
|
||||
class="h-7 min-w-40"
|
||||
/>
|
||||
<SubscribeToRunButton v-if="!canRunWorkflows" class="mt-4 w-full" />
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
class="mt-4 w-full"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
@@ -224,7 +229,6 @@ function handleDragDrop() {
|
||||
<i aria-hidden="true" class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
<FreeTierQuota />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,17 +4,33 @@ import {
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, toValue } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import { useCreditsBadgesInGraph } from '@/composables/node/usePriceBadge'
|
||||
import { usePriceBadge } from '@/composables/node/usePriceBadge'
|
||||
import PartnerNodeItem from '@/renderer/extensions/linearMode/PartnerNodeItem.vue'
|
||||
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
|
||||
import { app } from '@/scripts/app'
|
||||
import { mapAllNodes } from '@/utils/graphTraversalUtil'
|
||||
|
||||
defineProps<{ mobile?: boolean }>()
|
||||
|
||||
const creditsBadges = useCreditsBadgesInGraph()
|
||||
const { isCreditsBadge } = usePriceBadge()
|
||||
const { t } = useI18n()
|
||||
|
||||
const creditsBadges = computed(() =>
|
||||
mapAllNodes(app.graph, (node) => {
|
||||
if (node.isSubgraphNode()) return
|
||||
|
||||
const priceBadge = node.badges.find(isCreditsBadge)
|
||||
if (!priceBadge) return
|
||||
|
||||
trackNodePrice(node)
|
||||
return [node.title, toValue(priceBadge).text, node.id] as const
|
||||
})
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<Popover v-if="mobile && creditsBadges.length" side="top">
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { registerQueuePromptGuard } from '@/services/queuePromptGuardService'
|
||||
|
||||
const {
|
||||
mockApiKeyAuthStore,
|
||||
@@ -190,93 +189,6 @@ describe('ComfyApp', () => {
|
||||
})
|
||||
|
||||
describe('queuePrompt', () => {
|
||||
it('blocks before adding a disallowed prompt to the client queue', async () => {
|
||||
const unregisterGuard = registerQueuePromptGuard(
|
||||
'app.test.before-queue',
|
||||
() => false
|
||||
)
|
||||
const dispatchEvent = vi
|
||||
.spyOn(api, 'dispatchCustomEvent')
|
||||
.mockImplementation(() => true)
|
||||
const queuePrompt = vi.spyOn(api, 'queuePrompt')
|
||||
|
||||
try {
|
||||
await expect(app.queuePrompt(0)).resolves.toBe(false)
|
||||
} finally {
|
||||
unregisterGuard()
|
||||
}
|
||||
|
||||
expect(mockAuthStore.getAuthToken).not.toHaveBeenCalled()
|
||||
expect(dispatchEvent).not.toHaveBeenCalled()
|
||||
expect(queuePrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('checks again after deferred authentication resolves', async () => {
|
||||
const graph = new LGraph()
|
||||
Reflect.set(app, 'rootGraphInternal', graph)
|
||||
let resolveAuth: (token: string | undefined) => void = () => {}
|
||||
mockAuthStore.getAuthToken.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveAuth = resolve
|
||||
})
|
||||
)
|
||||
let guardCalls = 0
|
||||
const unregisterGuard = registerQueuePromptGuard(
|
||||
'app.test.after-auth',
|
||||
() => ++guardCalls === 1
|
||||
)
|
||||
const queuePrompt = vi.spyOn(api, 'queuePrompt')
|
||||
|
||||
try {
|
||||
const result = app.queuePrompt(0)
|
||||
await vi.waitFor(() => {
|
||||
expect(mockAuthStore.getAuthToken).toHaveBeenCalledOnce()
|
||||
})
|
||||
resolveAuth(undefined)
|
||||
|
||||
await expect(result).resolves.toBe(false)
|
||||
} finally {
|
||||
unregisterGuard()
|
||||
}
|
||||
expect(guardCalls).toBe(2)
|
||||
expect(queuePrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dispatches queued progress when a later batch item is blocked', async () => {
|
||||
const graph = new LGraph()
|
||||
Reflect.set(app, 'rootGraphInternal', graph)
|
||||
vi.spyOn(app, 'graphToPrompt').mockResolvedValue({
|
||||
output: {},
|
||||
workflow: createWorkflowGraphData()
|
||||
})
|
||||
vi.spyOn(api, 'queuePrompt').mockResolvedValue({
|
||||
prompt_id: 'job-1',
|
||||
node_errors: {},
|
||||
error: ''
|
||||
})
|
||||
const dispatchEvent = vi
|
||||
.spyOn(api, 'dispatchCustomEvent')
|
||||
.mockImplementation(() => true)
|
||||
let guardCalls = 0
|
||||
const unregisterGuard = registerQueuePromptGuard(
|
||||
'app.test.partial-batch',
|
||||
() => ++guardCalls < 3
|
||||
)
|
||||
|
||||
try {
|
||||
await expect(app.queuePrompt(0, 2)).resolves.toBe(false)
|
||||
} finally {
|
||||
unregisterGuard()
|
||||
}
|
||||
|
||||
expect(api.queuePrompt).toHaveBeenCalledOnce()
|
||||
expect(dispatchEvent).toHaveBeenCalledWith('promptQueued', {
|
||||
number: 0,
|
||||
batchCount: 1,
|
||||
requestId: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the error overlay for successful prompt responses with node errors', async () => {
|
||||
const graph = new LGraph()
|
||||
const workflow = new ComfyWorkflow({
|
||||
|
||||
@@ -24,7 +24,6 @@ import { snapPoint } from '@/lib/litegraph/src/measure'
|
||||
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -68,7 +67,6 @@ import { resolveAccountPrecondition } from '@/platform/errorCatalog/accountPreco
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { runQueuePromptGuards } from '@/services/queuePromptGuardService'
|
||||
import { useSubgraphService } from '@/services/subgraphService'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -1612,17 +1610,11 @@ export class ComfyApp {
|
||||
})
|
||||
}
|
||||
|
||||
private async isQueuePromptAllowed(): Promise<boolean> {
|
||||
return await runQueuePromptGuards(this)
|
||||
}
|
||||
|
||||
async queuePrompt(
|
||||
number: number,
|
||||
batchCount: number = 1,
|
||||
queueNodeIds?: NodeExecutionId[]
|
||||
): Promise<boolean> {
|
||||
if (!(await this.isQueuePromptAllowed())) return false
|
||||
|
||||
const requestId = this.nextQueueRequestId++
|
||||
this.queueItems.push({ number, batchCount, queueNodeIds, requestId })
|
||||
api.dispatchCustomEvent('promptQueueing', {
|
||||
@@ -1655,18 +1647,6 @@ export class ComfyApp {
|
||||
|
||||
const isPartialExecution = !!queueNodeIds?.length
|
||||
for (let i = 0; i < batchCount; i++) {
|
||||
if (!(await this.isQueuePromptAllowed())) {
|
||||
this.queueItems.length = 0
|
||||
if (queuedCount > 0) {
|
||||
api.dispatchCustomEvent('promptQueued', {
|
||||
number,
|
||||
batchCount: queuedCount,
|
||||
requestId
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Allow widgets to run callbacks before a prompt has been queued
|
||||
// e.g. random seed before every gen
|
||||
forEachNode(this.rootGraph, (node) => {
|
||||
@@ -1833,7 +1813,6 @@ export class ComfyApp {
|
||||
isPartialExecution
|
||||
})
|
||||
}
|
||||
useFreeTierQuota().trackRun()
|
||||
this.canvas.draw(true, true)
|
||||
await this.ui.queue.update()
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* showTopUpCreditsDialog must route team members (who cannot top up) to the
|
||||
* read-only contact-admin notice instead of the purchase dialog, while
|
||||
* owners/personal/legacy users keep the purchase flow.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const showDialog = vi.hoisted(() => vi.fn())
|
||||
const closeDialog = vi.hoisted(() => vi.fn())
|
||||
const state = vi.hoisted(() => ({
|
||||
isActiveSubscription: true,
|
||||
isFreeTier: false,
|
||||
type: 'workspace' as 'workspace' | 'legacy',
|
||||
canTopUp: true
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ showDialog, closeDialog })
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackEvent: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: { value: state.isActiveSubscription },
|
||||
isFreeTier: { value: state.isFreeTier },
|
||||
type: { value: state.type }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: { value: { canTopUp: state.canTopUp } }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: vi.fn() })
|
||||
}))
|
||||
|
||||
const showSubscriptionDialog = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({ show: showSubscriptionDialog })
|
||||
})
|
||||
)
|
||||
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
describe('showTopUpCreditsDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
state.isActiveSubscription = true
|
||||
state.isFreeTier = false
|
||||
state.type = 'workspace'
|
||||
state.canTopUp = true
|
||||
})
|
||||
|
||||
it('shows the purchase dialog to users who can top up', async () => {
|
||||
await useDialogService().showTopUpCreditsDialog({
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('top-up-credits')
|
||||
})
|
||||
|
||||
it('shows the contact-admin notice to team members instead of the purchase dialog', async () => {
|
||||
state.canTopUp = false
|
||||
|
||||
await useDialogService().showTopUpCreditsDialog({
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('insufficient-credits-member')
|
||||
// The member notice draws its own header + close button, so it must open
|
||||
// headless or Reka wraps it in duplicate chrome.
|
||||
expect(args.dialogComponentProps.headless).toBe(true)
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
|
||||
args.props.onClose()
|
||||
expect(closeDialog).toHaveBeenCalledWith({
|
||||
key: 'insufficient-credits-member'
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores workspace permissions on legacy billing', async () => {
|
||||
state.type = 'legacy'
|
||||
state.canTopUp = false
|
||||
|
||||
await useDialogService().showTopUpCreditsDialog()
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('top-up-credits')
|
||||
})
|
||||
|
||||
it('routes a member of an inactive team to the subscription-required flow, not the credits notice', async () => {
|
||||
state.isActiveSubscription = false
|
||||
state.canTopUp = false
|
||||
|
||||
await useDialogService().showTopUpCreditsDialog({
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
|
||||
expect(showSubscriptionDialog).toHaveBeenCalledWith({
|
||||
reason: 'out_of_credits'
|
||||
})
|
||||
expect(showDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -5,9 +5,7 @@ import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationD
|
||||
import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue'
|
||||
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
|
||||
import TopUpCreditsDialogContentLegacy from '@/components/dialog/content/TopUpCreditsDialogContentLegacy.vue'
|
||||
import InsufficientCreditsMemberDialog from '@/platform/workspace/components/InsufficientCreditsMemberDialog.vue'
|
||||
import TopUpCreditsDialogContentWorkspace from '@/platform/workspace/components/TopUpCreditsDialogContentWorkspace.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { t } from '@/i18n'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
@@ -341,28 +339,6 @@ export const useDialogService = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Members can't top up a team workspace, so they get a read-only
|
||||
// "ask your workspace admins" notice instead of the purchase dialog.
|
||||
if (
|
||||
type.value === 'workspace' &&
|
||||
!useWorkspaceUI().permissions.value.canTopUp
|
||||
) {
|
||||
return dialogStore.showDialog({
|
||||
key: 'insufficient-credits-member',
|
||||
component: InsufficientCreditsMemberDialog,
|
||||
props: {
|
||||
onClose: () =>
|
||||
dialogStore.closeDialog({ key: 'insufficient-credits-member' })
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
contentClass:
|
||||
'w-[min(360px,95vw)] max-w-[min(360px,95vw)] sm:max-w-[min(360px,95vw)] border-0 bg-transparent shadow-none'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const component =
|
||||
type.value === 'workspace'
|
||||
? TopUpCreditsDialogContentWorkspace
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
export interface QueuePromptGuardContext {
|
||||
readonly rootGraph: LGraph
|
||||
}
|
||||
|
||||
export type QueuePromptGuard = (
|
||||
context: QueuePromptGuardContext
|
||||
) => boolean | Promise<boolean>
|
||||
|
||||
const guards = new Map<string, QueuePromptGuard>()
|
||||
|
||||
export function registerQueuePromptGuard(
|
||||
id: string,
|
||||
guard: QueuePromptGuard
|
||||
): () => void {
|
||||
guards.set(id, guard)
|
||||
return () => {
|
||||
if (guards.get(id) === guard) guards.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
export async function runQueuePromptGuards(
|
||||
context: QueuePromptGuardContext
|
||||
): Promise<boolean> {
|
||||
const results = await Promise.all(
|
||||
[...guards.values()].map((guard) => guard(context))
|
||||
)
|
||||
return results.every((result) => result !== false)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { NodeDefFilter } from '@/stores/nodeDefStore'
|
||||
|
||||
describe('useNodeDefStore', () => {
|
||||
@@ -22,10 +21,6 @@ describe('useNodeDefStore', () => {
|
||||
store = useNodeDefStore()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const createMockNodeDef = (
|
||||
overrides: Partial<ComfyNodeDef> = {}
|
||||
): ComfyNodeDef => ({
|
||||
@@ -291,30 +286,6 @@ describe('useNodeDefStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('node frequency visibility', () => {
|
||||
it('excludes node definitions hidden by registered filters', async () => {
|
||||
vi.spyOn(axios, 'get').mockResolvedValue({
|
||||
data: { HiddenNode: 2, VisibleNode: 1 }
|
||||
})
|
||||
store.updateNodeDefs([
|
||||
createMockNodeDef({ name: 'HiddenNode' }),
|
||||
createMockNodeDef({ name: 'VisibleNode' })
|
||||
])
|
||||
store.registerNodeDefFilter({
|
||||
id: 'test.hide-node',
|
||||
name: 'Hide node',
|
||||
predicate: (nodeDef) => nodeDef.name !== 'HiddenNode'
|
||||
})
|
||||
|
||||
const frequencyStore = useNodeFrequencyStore()
|
||||
await frequencyStore.loadNodeFrequencies()
|
||||
|
||||
expect(frequencyStore.topNodeDefs.map((nodeDef) => nodeDef.name)).toEqual(
|
||||
['VisibleNode']
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('allNodeDefsByName', () => {
|
||||
it('should include all node defs by name', () => {
|
||||
const node1 = createMockNodeDef({ name: 'Node1' })
|
||||
|
||||
@@ -562,15 +562,9 @@ export const useNodeFrequencyStore = defineStore('nodeFrequency', () => {
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const topNodeDefs = computed<ComfyNodeDefImpl[]>(() => {
|
||||
const visibleNodeNames = new Set(
|
||||
nodeDefStore.visibleNodeDefs.map((nodeDef) => nodeDef.name)
|
||||
)
|
||||
return nodeNamesByFrequency.value
|
||||
.map((nodeName: string) => nodeDefStore.nodeDefsByName[nodeName])
|
||||
.filter(
|
||||
(nodeDef): nodeDef is ComfyNodeDefImpl =>
|
||||
nodeDef !== undefined && visibleNodeNames.has(nodeDef.name)
|
||||
)
|
||||
.filter((nodeDef: ComfyNodeDefImpl) => nodeDef !== undefined)
|
||||
.slice(0, topNodeDefLimit.value)
|
||||
})
|
||||
|
||||
|
||||
@@ -2,42 +2,14 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import type { BillingContext } from '@/composables/billing/types'
|
||||
|
||||
type Subscription = BillingContext['subscription']['value']
|
||||
|
||||
/** The billing state a story wants the stub to report. */
|
||||
export interface BillingContextMockState {
|
||||
subscription: Subscription
|
||||
isActiveSubscription: boolean
|
||||
isTeamPlan: boolean
|
||||
billingStatus: BillingContext['billingStatus']['value']
|
||||
subscriptionStatus: BillingContext['subscriptionStatus']['value']
|
||||
renewalDate: string | null
|
||||
}
|
||||
|
||||
const defaultState: BillingContextMockState = {
|
||||
subscription: null,
|
||||
isActiveSubscription: false,
|
||||
isTeamPlan: false,
|
||||
billingStatus: null,
|
||||
subscriptionStatus: null,
|
||||
renewalDate: null
|
||||
}
|
||||
|
||||
const state = ref<BillingContextMockState>({ ...defaultState })
|
||||
|
||||
/** Drives the stub from a story's `beforeEach`. */
|
||||
export function setBillingContextMock(next: Partial<BillingContextMockState>) {
|
||||
state.value = { ...defaultState, ...next }
|
||||
}
|
||||
|
||||
/**
|
||||
* Storybook mock for `useBillingContext`.
|
||||
*
|
||||
* The real facade lazily instantiates the legacy billing adapter, which pulls
|
||||
* in Firebase auth (`setPersistence`) and crashes in the Storybook environment
|
||||
* (no Firebase). This stub lets billing components — e.g. UnifiedPricingTable,
|
||||
* BillingStatusBanner — render without any network or auth. It defaults to the
|
||||
* unsubscribed state; call `setBillingContextMock` to drive a specific one.
|
||||
* (no Firebase). This static stub lets presentational billing components — e.g.
|
||||
* UnifiedPricingTable — render against their `TIER_PRICING` / DES-197 fallbacks
|
||||
* without any network or auth.
|
||||
*
|
||||
* Typed against `BillingContext` so the stub stays in lockstep with the real
|
||||
* composable's return shape: drifted or removed keys fail to compile.
|
||||
@@ -46,7 +18,7 @@ export function useBillingContext(): BillingContext {
|
||||
return {
|
||||
type: computed(() => 'legacy' as const),
|
||||
isInitialized: ref(true),
|
||||
subscription: computed(() => state.value.subscription),
|
||||
subscription: computed(() => null),
|
||||
balance: computed(() => null),
|
||||
plans: computed(() => []),
|
||||
currentPlanSlug: computed(() => null),
|
||||
@@ -54,15 +26,13 @@ export function useBillingContext(): BillingContext {
|
||||
currentTeamCreditStop: computed(() => null),
|
||||
isLoading: ref(false),
|
||||
error: ref<string | null>(null),
|
||||
isActiveSubscription: computed(() => state.value.isActiveSubscription),
|
||||
canRunWorkflows: computed(() => state.value.isActiveSubscription),
|
||||
isActiveSubscription: computed(() => false),
|
||||
isFreeTier: computed(() => false),
|
||||
isLegacyTeamPlan: computed(() => false),
|
||||
isTeamPlan: computed(() => state.value.isTeamPlan),
|
||||
billingStatus: computed(() => state.value.billingStatus),
|
||||
subscriptionStatus: computed(() => state.value.subscriptionStatus),
|
||||
billingStatus: computed(() => null),
|
||||
subscriptionStatus: computed(() => null),
|
||||
tier: computed(() => null),
|
||||
renewalDate: computed(() => state.value.renewalDate),
|
||||
renewalDate: computed(() => null),
|
||||
getMaxSeats: (tierKey: string) => ({ creator: 5, pro: 20 })[tierKey] ?? 1,
|
||||
initialize: async () => {},
|
||||
fetchStatus: async () => {},
|
||||
|
||||
@@ -26,14 +26,14 @@ export enum ServerFeatureFlag {
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
BILLING_CONTROL_ENABLED = 'billing_control_enabled'
|
||||
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled'
|
||||
}
|
||||
|
||||
export function useFeatureFlags() {
|
||||
return {
|
||||
flags: {
|
||||
teamWorkspacesEnabled: true,
|
||||
billingControlEnabled: true
|
||||
consolidatedBillingEnabled: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { WorkspaceType } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
/** The workspace role/permission state a story wants the stub to report. */
|
||||
export interface WorkspaceUIMockState {
|
||||
workspaceType: WorkspaceType
|
||||
canManageSubscription: boolean
|
||||
canManageSubscriptionLifecycle: boolean
|
||||
canTopUp: boolean
|
||||
}
|
||||
|
||||
const defaultState: WorkspaceUIMockState = {
|
||||
workspaceType: 'team',
|
||||
canManageSubscription: true,
|
||||
canManageSubscriptionLifecycle: true,
|
||||
canTopUp: true
|
||||
}
|
||||
|
||||
const state = ref<WorkspaceUIMockState>({ ...defaultState })
|
||||
|
||||
/** Drives the stub from a story's `beforeEach`. */
|
||||
export function setWorkspaceUIMock(next: Partial<WorkspaceUIMockState>) {
|
||||
state.value = { ...defaultState, ...next }
|
||||
}
|
||||
|
||||
/**
|
||||
* Storybook mock for `useWorkspaceUI`.
|
||||
*
|
||||
* The real composable derives permissions from `useCurrentUser` (Firebase auth)
|
||||
* and the team workspace store, neither of which is available in Storybook. This
|
||||
* stub exposes only the role surface the billing banner reads; add keys here as
|
||||
* other stories need them.
|
||||
*/
|
||||
export function useWorkspaceUI() {
|
||||
return {
|
||||
workspaceType: computed(() => state.value.workspaceType),
|
||||
permissions: computed(() => ({
|
||||
canManageSubscription: state.value.canManageSubscription,
|
||||
canManageSubscriptionLifecycle:
|
||||
state.value.canManageSubscriptionLifecycle,
|
||||
canTopUp: state.value.canTopUp
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ describe('useManagerState', () => {
|
||||
})
|
||||
|
||||
describe('managerUIState property', () => {
|
||||
it('should return DISABLED state when --enable-manager is NOT present', () => {
|
||||
it('should return DISABLED state when no manager flags are present', () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: systemStatsFixture(['python', 'main.py']),
|
||||
isInitialized: true
|
||||
@@ -141,7 +141,6 @@ describe('useManagerState', () => {
|
||||
systemStats: systemStatsFixture([
|
||||
'python',
|
||||
'main.py',
|
||||
'--enable-manager',
|
||||
'--enable-manager-legacy-ui'
|
||||
]),
|
||||
isInitialized: true
|
||||
|
||||
@@ -67,21 +67,13 @@ export function useManagerState() {
|
||||
'extension.manager.supports_csrf_post'
|
||||
)
|
||||
|
||||
// Check command line args first (highest priority)
|
||||
// --enable-manager flag enables the manager (opposite of old --disable-manager)
|
||||
const hasEnableManager =
|
||||
systemStats.value?.system?.argv?.includes('--enable-manager')
|
||||
|
||||
// If --enable-manager is NOT present, manager is disabled
|
||||
if (!hasEnableManager) {
|
||||
return ManagerUIState.DISABLED
|
||||
}
|
||||
|
||||
if (
|
||||
systemStats.value?.system?.argv?.includes('--enable-manager-legacy-ui')
|
||||
) {
|
||||
const argv = systemStats.value?.system?.argv
|
||||
if (argv?.includes('--enable-manager-legacy-ui')) {
|
||||
return ManagerUIState.LEGACY_UI
|
||||
}
|
||||
if (!argv?.includes('--enable-manager')) {
|
||||
return ManagerUIState.DISABLED
|
||||
}
|
||||
|
||||
// Server exposes v4 but is missing the CSRF-hardened POST endpoints
|
||||
// (Manager < 4.2.1). Treat as INCOMPATIBLE — hide manager UI and
|
||||
|
||||
Reference in New Issue
Block a user