Compare commits

..

32 Commits

Author SHA1 Message Date
DrJKL
5ffdaf4b46 chore: update typescript to 6.0.3
TypeScript 7 (native/tsgo) is blocked: vue-tsc crashes on the v7
package layout (ERR_PACKAGE_PATH_NOT_EXPORTED) and typescript-eslint
caps its peer range at <6.1.0. 6.0.3 is the last JS-based major and
passes the whole toolchain.

Cast typegpu's vendored GPUDevice to @webgpu/types' GPUDevice where
they disagree on adapterInfo.subgroupMaxSize optionality.
2026-07-17 02:03:04 -07:00
DrJKL
d28394e62b test: guard te() active-locale-only contract 2026-07-17 01:10:12 -07:00
DrJKL
8f94684c64 fix: fall back to command id for unlabeled keybinding rows
vue-i18n 11 returns the default message verbatim, so t(key, '') renders
an empty command cell for commands without a label or translation (v9
fell back to the key text). Default to command.id so the row stays
visible and clickable.
2026-07-17 00:50:10 -07:00
DrJKL
6ba4801663 fix: default te() to the active locale
vue-i18n 11's te() consults the fallback locale, which broke every
raw-fallback path built on the exported te (error catalog raw API copy,
st/stRaw custom-node strings, sidebar titles). Wrap the export to
preserve the v9 active-locale semantics instead of patching call sites,
so existing @/i18n test mocks keep working.
2026-07-17 00:18:31 -07:00
DrJKL
c9b5647773 fix: scope error catalog te() checks to the active locale
vue-i18n 11's te() consults the fallback locale, which defeated the
catalog's raw-API-copy fallback for locales missing a key.
2026-07-16 23:57:24 -07:00
DrJKL
0cea758dfa chore: restore screenshot expectations to main's bytes
The regenerated goldens were pixel-identical to main's (re-encoded
only); drop the churn to match the base branch.
2026-07-16 23:34:09 -07:00
DrJKL
7fd38c9144 Merge branch 'drjkl/update-deps' into drjkl/update-vue-i18n 2026-07-16 23:33:50 -07:00
DrJKL
b19d8d968f fix: clipboard copy and industry normalization regressions
- useCopyToClipboard: use navigator.clipboard.writeText directly.
  vueuse 14.3's useClipboard copy() no longer rejects on failure (it
  swallows errors into an internal legacy path gated on an async
  permission query), which bypassed our execCommand fallback and left
  the clipboard empty in the error-dialog e2e tests.
- Pin fuse.js to 7.0.0: 7.5 enforces minMatchCharLength strictly, so
  4-char queries like 'film' stopped matching survey industry
  categories. Retuning the fuzzy config for 7.5 is follow-up work.
2026-07-16 23:33:13 -07:00
DrJKL
04c14d98c5 ci: don't fail website screenshot regen on label cleanup
The restricted pull_request token can't always remove the trigger
label; that 403 was failing otherwise-successful runs.
2026-07-16 23:33:12 -07:00
DrJKL
ba2d7c5774 ci: bump website Playwright image to v1.61.1
Match @playwright/test 1.61.1 from the catalog; the stale v1.58.1 image
broke website-e2e and made screenshot regeneration a no-op.
2026-07-16 23:33:11 -07:00
DrJKL
5e2ce2099d Merge branch 'drjkl/update-deps' into drjkl/update-vue-i18n 2026-07-16 21:39:08 -07:00
DrJKL
a0d3379b98 fix: clipboard copy and industry normalization regressions
- useCopyToClipboard: use navigator.clipboard.writeText directly.
  vueuse 14.3's useClipboard copy() no longer rejects on failure (it
  swallows errors into an internal legacy path gated on an async
  permission query), which bypassed our execCommand fallback and left
  the clipboard empty in the error-dialog e2e tests.
- Pin fuse.js to 7.0.0: 7.5 enforces minMatchCharLength strictly, so
  4-char queries like 'film' stopped matching survey industry
  categories. Retuning the fuzzy config for 7.5 is follow-up work.
2026-07-16 21:23:21 -07:00
DrJKL
31fa8fe952 ci: don't fail website screenshot regen on label cleanup
The restricted pull_request token can't always remove the trigger
label; that 403 was failing otherwise-successful runs.
2026-07-16 20:59:59 -07:00
DrJKL
62ffad0c3c Merge branch 'drjkl/update-deps' into drjkl/update-vue-i18n 2026-07-16 20:58:06 -07:00
DrJKL
a0be6ac430 ci: bump website Playwright image to v1.61.1
Match @playwright/test 1.61.1 from the catalog; the stale v1.58.1 image
broke website-e2e and made screenshot regeneration a no-op.
2026-07-16 20:52:09 -07:00
DrJKL
d15ee851f0 [automated] Update test expectations 2026-07-16 20:45:22 -07:00
DrJKL
99faedfa81 fix: pin primevue family to 4.2.5
The lockfile regeneration floated primevue 4.2.5 -> 4.5.5, whose
@primeuix/styled 0.7.4 production bundle emits the dark-theme rule as
unmatchable nested CSS ('.dark-theme ... { & :root ... }'). Built apps
lost :root color-scheme: dark, flipping native form controls (e.g. the
legacy menu's Extra options checkbox) to light rendering. Dev builds use
the package's development bundle, which emits the flat selector - which
is why pnpm dev looked fine while pnpm preview did not.

Pin the family to main's resolved 4.2.5 until the upstream production
CSS generation is fixed.
2026-07-16 20:45:18 -07:00
DrJKL
5e7629560b Merge branch 'drjkl/update-deps' into drjkl/update-vue-i18n 2026-07-16 18:03:01 -07:00
DrJKL
2182166da0 Merge remote-tracking branch 'origin/main' into drjkl/update-deps
# Conflicts:
#	pnpm-lock.yaml
#	pnpm-workspace.yaml
2026-07-16 18:02:26 -07:00
DrJKL
66e5d8c5f0 Merge branch 'drjkl/update-deps' into drjkl/update-vue-i18n 2026-07-16 17:58:27 -07:00
DrJKL
ecfd1f3816 fix: restore fallow tooling dropped in merge resolution
The branch merge of main accidentally reverted #13271 (fallow dep,
scripts, config, gitignore entry). Restore it from main.
2026-07-16 17:57:22 -07:00
Alexander Brown
f79f0e6066 Merge branch 'main' into drjkl/update-deps 2026-07-16 17:33:50 -07:00
Alexander Brown
c924d62f0c Update container target to v0.0.21 2026-07-17 00:33:20 +00:00
DrJKL
d833ffcc89 Merge branch 'drjkl/update-deps' into drjkl/update-vue-i18n 2026-07-16 17:13:33 -07:00
DrJKL
797fd0c78c chore: hold dompurify at 3.4.7 for happy-dom compat
DOMPurify >=3.4.8 changed its node iterator ('better template
scrubbing') in a way that silently strips legitimate tags (h1, etc.)
under happy-dom - real browsers and jsdom are unaffected. Pin to 3.4.7
(still ahead of main's 3.4.5, keeps the 3.4.6/3.4.7 DOM-clobbering
fixes) until the upstream incompatibility is resolved.
2026-07-16 17:09:44 -07:00
DrJKL
f6811a5f57 chore: update vue-i18n to v11
Two-major jump 9.14 -> 11.4.6. App is pure composition API
(legacy: false, no $tc/v-t), so no code changes required.
2026-07-16 16:33:41 -07:00
Alexander Brown
5e1f0ddd03 Merge branch 'main' into drjkl/update-deps 2026-07-16 15:25:23 -07:00
DrJKL
4adf9bdda5 ci: update e2e container to comfyui-ci-container 0.0.19
Matches the Playwright 1.61 bump in this PR.
2026-07-16 15:22:15 -07:00
DrJKL
0e360677cf chore: respect minimumReleaseAge instead of exempting fresh releases
Drop the auto-added minimumReleaseAgeExclude block and lower range floors
so resolution picks the newest age-compliant versions: vue 3.5.39,
vite 8.1.4, tailwindcss 4.3.2, storybook 10.5.0. The <24h releases flow
in automatically once they age past the gate.
2026-07-16 15:10:06 -07:00
DrJKL
3f656030f7 pnpm dedupe 2026-07-16 14:49:04 -07:00
DrJKL
08c34f2680 chore: reformat with oxfmt 0.59 2026-07-16 14:39:58 -07:00
DrJKL
935142428d chore: update core dev dependencies
- vue 3.5.40, vite 8.1.5, oxlint 1.74, oxfmt 0.59, eslint 10.7,
  vitest 4.1.10, vue-tsc 3.3.7, tailwindcss 4.3.3, playwright 1.61.1,
  storybook 10.5.2, knip 6.27, typescript-eslint 8.64 + ecosystem minors
- pnpm 11.3.0 -> 11.13.1 (packageManager)
- happy-dom held at 20.9.0: 20.10.x throws NotSupportedError on external
  script append, breaking GTM/Typeform loader tests
- fix defineModel default typing for vue 3.5.40 (default factory param
  is now Record<string, unknown>)
- fix useClipboard mock for vueuse 14.3 (ComputedRef fields + copyPending)
- drop knip ignores obsoleted by knip 6.27
- rename invalid CSS ident --color-dark-elevation-1.5 (dot) to -1-5;
  oxfmt 0.59 hard-errors on it
2026-07-16 14:37:52 -07:00
66 changed files with 5356 additions and 6267 deletions

View File

@@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -63,7 +63,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -121,7 +121,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -28,7 +28,7 @@ jobs:
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.1-noble
image: mcr.microsoft.com/playwright:v1.61.1-noble
timeout-minutes: 15
permissions:
contents: read

View File

@@ -77,7 +77,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -14,7 +14,7 @@ jobs:
update-screenshots:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.1-noble
image: mcr.microsoft.com/playwright:v1.61.1-noble
timeout-minutes: 15
permissions:
contents: write
@@ -128,6 +128,9 @@ jobs:
- name: Remove label
if: always() && github.event_name == 'pull_request'
# Label cleanup is best-effort; the restricted pull_request token may
# lack permission to remove labels and must not fail the run.
continue-on-error: true
uses: actions/github-script@v8
env:
PR_NUMBER: ${{ steps.pr-info.outputs.pr-number }}

View File

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

View File

@@ -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')
"

View File

@@ -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 DesktopCodex 和 OpenClaw 不需要。CursorHermes 目前需要 Comfy API 密钥。只需复制 https://docs.comfy.org/agent-tools/cloud你的智能体就会为你完成安装。'
'Claude Code、Claude DesktopCodex 不需要。CursorHermes 和 OpenClaw 目前需要 Comfy API 密钥。只需复制 https://docs.comfy.org/agent-tools/cloud你的智能体就会为你完成安装。'
},
'mcp.faq.4.q': {
en: 'Does it cost anything?',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,9 +17,7 @@ import { mockSystemStats } from '../fixtures/data/systemStats'
// Extend the base set with test-specific nodes
const nodeDefs = createMockNodeDefinitions({
MyCustomNode: {
/* ... */
}
MyCustomNode: {/* ... */}
})
await page.route('**/api/object_info', (route) =>

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,9 +31,7 @@ export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
/* props */
}
args: {/* props */}
}
```

View File

@@ -37,10 +37,8 @@ const config: KnipConfig = {
entry: ['src/scripts/**/*.ts']
}
},
ignoreBinaries: ['python3'],
ignoreDependencies: [
// Weird importmap things
'@iconify-json/lucide',
'@iconify/json',
'@primeuix/forms',
'@primeuix/styled',

View File

@@ -97,7 +97,7 @@
"@vee-validate/zod": "catalog:",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"@vueuse/router": "^14.2.0",
"@vueuse/router": "^14.3.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/xterm": "^5.5.0",
@@ -111,7 +111,7 @@
"extendable-media-recorder": "^9.2.27",
"extendable-media-recorder-wav-encoder": "^7.0.129",
"firebase": "catalog:",
"fuse.js": "^7.0.0",
"fuse.js": "7.0.0",
"glob": "catalog:",
"jsonata": "catalog:",
"loglevel": "^1.9.2",
@@ -219,5 +219,5 @@
"node": ">=25 <26",
"pnpm": ">=11.3"
},
"packageManager": "pnpm@11.3.0"
"packageManager": "pnpm@11.13.1"
}

View File

@@ -78,9 +78,9 @@
--color-interface-panel-job-progress-border: var(--base-foreground);
--color-blue-selection: rgb(from var(--color-azure-600) r g b / 0.3);
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b/ 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b/ 0.1);
--color-modal-tag: rgb(from var(--color-smoke-400) r g b/ 0.4);
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b / 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b / 0.1);
--color-modal-tag: rgb(from var(--color-smoke-400) r g b / 0.4);
--color-alpha-charcoal-600-30: color-mix(
in srgb,
var(--color-charcoal-600) 30%,
@@ -107,7 +107,7 @@
--color-highlight: var(--p-primary-color);
/* Special Colors (temporary) */
--color-dark-elevation-1.5: rgba(from white r g b/ 0.015);
--color-dark-elevation-1-5: rgba(from white r g b / 0.015);
--color-dark-elevation-2: rgba(from white r g b / 0.03);
}
@@ -1873,9 +1873,9 @@ audio.comfy-audio.empty-audio-widget {
/* Shadow Cover TOP */
linear-gradient(--value(--color-*) 30%, transparent) center top,
/* Shadow Cover BOTTOM */
linear-gradient(transparent, --value(--color-*) 70%) center bottom,
linear-gradient(transparent, --value(--color-*) 70%) center bottom,
/* Shadow TOP */
radial-gradient(
radial-gradient(
farthest-side at 50% 0,
color-mix(in oklab, --value(--color-*), #777777 35%),
60%,
@@ -1883,7 +1883,7 @@ audio.comfy-audio.empty-audio-widget {
)
center top,
/* Shadow BOTTOM */
radial-gradient(
radial-gradient(
farthest-side at 50% 100%,
color-mix(in oklab, --value(--color-*), #777777 35%),
60%,

9622
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,26 +24,26 @@ catalog:
'@iconify/tailwind4': ^1.2.3
'@iconify/tools': ^5.0.3
'@iconify/utils': ^3.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.5.0
'@intlify/eslint-plugin-vue-i18n': ^4.5.1
'@lobehub/i18n-cli': ^1.26.1
'@lucide/vue': ^1.17.0
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.58.1
'@playwright/test': ^1.61.1
'@primeuix/forms': 0.0.2
'@primeuix/styled': 0.3.2
'@primeuix/utils': ^0.3.2
'@primevue/core': ^4.2.5
'@primevue/forms': ^4.2.5
'@primevue/core': 4.2.5
'@primevue/forms': 4.2.5
'@primevue/icons': 4.2.5
'@primevue/themes': ^4.2.5
'@primevue/themes': 4.2.5
'@sentry/vite-plugin': ^4.6.0
'@sentry/vue': ^10.32.1
'@sparkjsdev/spark': ^2.1.0
'@storybook/addon-docs': ^10.2.10
'@storybook/addon-docs': ^10.5.0
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.2.10
'@storybook/vue3-vite': ^10.2.10
'@tailwindcss/vite': ^4.3.0
'@storybook/vue3': ^10.5.0
'@storybook/vue3-vite': ^10.5.0
'@tailwindcss/vite': ^4.3.2
'@tanstack/vue-virtual': ^3.13.12
'@testing-library/jest-dom': ^6.9.1
'@testing-library/user-event': ^14.6.1
@@ -64,11 +64,11 @@ catalog:
'@types/three': ^0.184.1
'@vee-validate/zod': ^4.15.1
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
'@vitest/coverage-v8': ^4.0.16
'@vitest/ui': ^4.0.16
'@vueuse/core': ^14.2.0
'@vueuse/integrations': ^14.2.0
'@vitejs/plugin-vue': ^6.0.8
'@vitest/coverage-v8': ^4.1.10
'@vitest/ui': ^4.1.10
'@vueuse/core': ^14.3.0
'@vueuse/integrations': ^14.3.0
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
astro: ^6.4.2
@@ -76,75 +76,75 @@ catalog:
class-variance-authority: ^0.7.1
cross-env: ^10.1.0
cva: 1.0.0-beta.4
dompurify: ^3.4.5
dompurify: 3.4.7
dotenv: ^16.4.5
eslint: ^10.4.0
eslint: ^10.7.0
eslint-config-prettier: ^10.1.8
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.2
eslint-plugin-oxlint: 1.69.0
eslint-plugin-playwright: ^2.10.1
eslint-plugin-storybook: ^10.2.10
eslint-plugin-testing-library: ^7.16.1
eslint-import-resolver-typescript: ^4.4.5
eslint-plugin-better-tailwindcss: ^4.6.1
eslint-plugin-import-x: ^4.17.1
eslint-plugin-oxlint: 1.73.0
eslint-plugin-playwright: ^2.10.5
eslint-plugin-storybook: ^10.5.0
eslint-plugin-testing-library: ^7.16.2
eslint-plugin-unused-imports: ^4.4.1
eslint-plugin-vue: ^10.9.1
eslint-plugin-vue: ^10.9.2
fallow: ^2.102.0
fast-check: ^4.5.3
firebase: ^11.6.0
glob: ^13.0.6
globals: ^16.5.0
gsap: ^3.14.2
happy-dom: ^20.8.9
happy-dom: 20.9.0
husky: ^9.1.7
jiti: 2.6.1
jiti: 2.7.0
jsdom: ^27.4.0
jsonata: ^2.1.0
knip: ^6.15.0
knip: ^6.27.0
lenis: ^1.3.21
lint-staged: ^16.2.7
markdown-table: ^3.0.4
minisearch: ^7.2.0
mixpanel-browser: ^2.71.0
monocart-coverage-reports: ^2.12.9
oxfmt: ^0.54.0
oxlint: ^1.69.0
oxlint-tsgolint: ^0.23.0
oxfmt: ^0.59.0
oxlint: ^1.74.0
oxlint-tsgolint: ^0.24.0
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0
posthog-js: ^1.358.1
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5
primevue: 4.2.5
reka-ui: 2.5.0
rollup-plugin-visualizer: ^6.0.4
storybook: ^10.2.10
storybook: ^10.5.0
stylelint: ^16.26.1
tailwindcss: ^4.3.0
tailwindcss: ^4.3.2
tailwindcss-primeui: ^0.6.1
three: ^0.184.0
tsx: ^4.15.6
tw-animate-css: ^1.3.8
typegpu: ^0.8.2
typescript: ^5.9.3
typescript-eslint: ^8.60.0
typescript: ^6.0.3
typescript-eslint: ^8.64.0
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
uuid: ^11.1.1
vee-validate: ^4.15.1
vite: ^8.0.13
vite: ^8.1.4
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0
vitest: ^4.1.0
vue: ^3.5.34
vue-component-type-helpers: ^3.2.1
vue-eslint-parser: ^10.4.0
vue-i18n: ^9.14.5
vite-plugin-vue-devtools: ^8.1.5
vitest: ^4.1.10
vue: ^3.5.39
vue-component-type-helpers: ^3.3.7
vue-eslint-parser: ^10.4.1
vue-i18n: ^11.4.6
vue-router: ^4.4.3
vue-tsc: ^3.2.5
vue-tsc: ^3.3.7
vuefire: ^3.2.1
wwobjloader2: ^6.2.1
yjs: ^13.6.27

View File

@@ -32,11 +32,11 @@ let _runWhenIdle: (
* Falls back to setTimeout-based scheduling in browsers without native support.
*/
export let runWhenGlobalIdle: (
callback: (idle: IdleDeadline) => void,
timeout?: number
) => IDisposable
callback: (idle: IdleDeadline) => void,
timeout?: number
) => IDisposable
// Self-invoking function to set up the idle callback implementation
// Self-invoking function to set up the idle callback implementation
;(function () {
const safeGlobal: GlobalWindow = globalThis as GlobalWindow

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -429,7 +429,7 @@ const commandsData = computed<ICommandData[]>(() => {
id: command.id,
label: t(
`commands.${normalizeI18nKey(command.id)}.label`,
command.label ?? ''
command.label ?? command.id
),
keybindings: keybindingStore.getKeybindingsByCommandId(command.id),
source: command.source,

View File

@@ -120,5 +120,4 @@ export interface BillingContext extends BillingState, BillingActions {
*/
isTeamPlan: ComputedRef<boolean>
getMaxSeats: (tierKey: TierKey) => number
canRunWorkflows: ComputedRef<boolean>
}

View File

@@ -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,
@@ -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' &&
@@ -323,7 +312,6 @@ function useBillingContextInternal(): BillingContext {
isLoading,
error,
isActiveSubscription,
canRunWorkflows,
isFreeTier,
isLegacyTeamPlan,
isTeamPlan,

View File

@@ -207,7 +207,8 @@ export function useGPUResources() {
async function initTypeGPU(): Promise<void> {
if (store.tgpuRoot) {
/* c8 ignore start */
device = store.tgpuRoot.device
// typegpu vendors its own WebGPU types; align with @webgpu/types
device = store.tgpuRoot.device as GPUDevice
return
/* c8 ignore stop */
}
@@ -215,7 +216,7 @@ export function useGPUResources() {
/* c8 ignore start — requires functional WebGPU hardware */
const root = await tgpu.init()
store.tgpuRoot = root
device = root.device
device = root.device as GPUDevice
console.warn('✅ TypeGPU initialized! Root:', root)
console.warn('Device info:', root.device.limits)
/* c8 ignore stop */

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,8 @@
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockCopy = vi.fn()
const mockWriteText = vi.fn()
const mockToastAdd = vi.fn()
vi.mock('@vueuse/core', () => ({
useClipboard: vi.fn(() => ({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => true)
}))
}))
vi.mock('primevue/usetoast', () => ({
useToast: vi.fn(() => ({
add: mockToastAdd
@@ -22,34 +13,31 @@ vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
import { useClipboard } from '@vueuse/core'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
describe('useCopyToClipboard', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.mocked(useClipboard).mockReturnValue({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => true),
text: ref('')
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: mockWriteText }
})
})
it('shows success toast when modern clipboard succeeds', async () => {
mockCopy.mockResolvedValue(undefined)
mockWriteText.mockResolvedValue(undefined)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(mockCopy).toHaveBeenCalledWith('hello')
expect(mockWriteText).toHaveBeenCalledWith('hello')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('falls back to legacy when modern clipboard fails', async () => {
mockCopy.mockRejectedValue(new Error('Not allowed'))
mockWriteText.mockRejectedValue(new Error('Not allowed'))
document.execCommand = vi.fn(() => true)
const { copyToClipboard } = useCopyToClipboard()
@@ -62,7 +50,7 @@ describe('useCopyToClipboard', () => {
})
it('shows error toast when both modern and legacy fail', async () => {
mockCopy.mockRejectedValue(new Error('Not allowed'))
mockWriteText.mockRejectedValue(new Error('Not allowed'))
document.execCommand = vi.fn(() => false)
const { copyToClipboard } = useCopyToClipboard()
@@ -73,19 +61,17 @@ describe('useCopyToClipboard', () => {
)
})
it('falls through to legacy when isSupported is false', async () => {
vi.mocked(useClipboard).mockReturnValue({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => false),
text: ref('')
it('falls through to legacy when clipboard API is unavailable', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined
})
document.execCommand = vi.fn(() => true)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(mockCopy).not.toHaveBeenCalled()
expect(mockWriteText).not.toHaveBeenCalled()
expect(document.execCommand).toHaveBeenCalledWith('copy')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })

View File

@@ -1,4 +1,3 @@
import { useClipboard } from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import { t } from '@/i18n'
@@ -20,15 +19,14 @@ function legacyCopy(text: string): boolean {
}
export function useCopyToClipboard() {
const { copy, isSupported } = useClipboard()
const toast = useToast()
async function copyToClipboard(text: string) {
let success = false
try {
if (isSupported.value) {
await copy(text)
if (navigator.clipboard) {
await navigator.clipboard.writeText(text)
success = true
}
} catch {

View File

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

View File

@@ -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',
@@ -34,7 +33,6 @@ export enum ServerFeatureFlag {
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',
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,
@@ -211,16 +202,6 @@ export function useFeatureFlags() {
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
)
},
get signupTurnstileMode() {
return resolveFlag(
ServerFeatureFlag.SIGNUP_TURNSTILE,

View File

@@ -7,6 +7,7 @@ let loadLocale: typeof I18nModule.loadLocale
let mergeCustomNodesI18n: typeof I18nModule.mergeCustomNodesI18n
let resolveSupportedLocale: typeof I18nModule.resolveSupportedLocale
let setActiveLocale: typeof I18nModule.setActiveLocale
let te: typeof I18nModule.te
async function importI18nModule() {
const i18nModule = await import('./i18n')
@@ -15,10 +16,13 @@ async function importI18nModule() {
mergeCustomNodesI18n = i18nModule.mergeCustomNodesI18n
resolveSupportedLocale = i18nModule.resolveSupportedLocale
setActiveLocale = i18nModule.setActiveLocale
te = i18nModule.te
}
// Mock the JSON imports before importing i18n module
vi.mock('./locales/en/main.json', () => ({ default: { welcome: 'Welcome' } }))
vi.mock('./locales/en/main.json', () => ({
default: { welcome: 'Welcome', enOnly: 'English only' }
}))
vi.mock('./locales/en/nodeDefs.json', () => ({
default: { testNode: 'Test Node' }
}))
@@ -228,6 +232,16 @@ describe('i18n', () => {
})
})
describe('te', () => {
it('checks only the active locale, never the fallback locale', async () => {
expect(te('enOnly')).toBe(true)
await setActiveLocale('zh')
expect(te('welcome')).toBe(true)
expect(te('enOnly')).toBe(false)
})
})
describe('resolveSupportedLocale', () => {
it('returns the canonical tag when the input is shipped', () => {
expect(resolveSupportedLocale('en')).toBe('en')

View File

@@ -155,7 +155,10 @@ export const i18n = createI18n({
/** Convenience shorthand: i18n.global */
export const t: (typeof i18n.global)['t'] = i18n.global.t
export const te: (typeof i18n.global)['te'] = i18n.global.te
// vue-i18n 11's te() consults the fallback locale; default to the active
// locale to preserve the v9 behavior our fallback paths rely on.
export const te: (typeof i18n.global)['te'] = (key, locale) =>
i18n.global.te(key, locale ?? i18n.global.locale.value)
export const d: (typeof i18n.global)['d'] = i18n.global.d
const tm = i18n.global.tm

View File

@@ -3587,9 +3587,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"
},

View File

@@ -19,7 +19,9 @@ export const hasNonEmptyValue = (
return true
}
export const isOtherValue = (current: string | string[] | undefined): boolean =>
export const isOtherValue = (
current: string | string[] | undefined
): boolean =>
Array.isArray(current) ? current.includes('other') : current === 'other'
const conditionMatches = (

View File

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

View File

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

View File

@@ -107,15 +107,9 @@ 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

View File

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

View File

@@ -1,70 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getPartnerNodePolicy,
PartnerNodePolicyApiError
} 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'
})
})
})

View File

@@ -1,39 +0,0 @@
import { z } from 'zod'
import { api } from '@/scripts/api'
const partnerNodePolicyResponseSchema = z.object({
enforcement_enabled: z.boolean(),
nodes: z.record(z.string(), z.boolean())
})
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'
}
}
export async function getPartnerNodePolicy(): Promise<PartnerNodePolicy | null> {
const response = await api.fetchApi('/workspace/partner-node-policy', {
cache: 'no-store'
})
if (response.status === 404) return null
if (!response.ok) {
throw new PartnerNodePolicyApiError(response.status, response.statusText)
}
const data = partnerNodePolicyResponseSchema.parse(await response.json())
return {
enforcementEnabled: data.enforcement_enabled,
nodes: data.nodes
}
}

View File

@@ -1,276 +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 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 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
}
}
)
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)
activateWorkspace('workspace-one')
useNodeDefStore().updateNodeDefs([
nodeDef('AllowedNode'),
nodeDef('DisabledNode'),
nodeDef('UnreviewedNode'),
nodeDef('CoreNode', {
api_node: false,
category: 'sampling',
python_module: 'nodes'
})
])
})
afterEach(() => {
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('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)
})
})

View File

@@ -1,139 +0,0 @@
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, watch } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
getPartnerNodePolicy,
PartnerNodePolicyApiError
} 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'
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
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
)
}
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'
}
}
watch(governedWorkspaceId, () => void loadPolicy(), { immediate: true })
return {
policy,
status,
error,
governedWorkspaceId,
partnerNodes,
isNodeDisabled,
loadPolicy
}
}
)

View File

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

View File

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

View File

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

View File

@@ -224,9 +224,10 @@ function resolveValues(values: unknown): string[] {
}
const modelValue = defineModel<string | undefined>({
default(modelProps: Props) {
default(modelProps: Record<string, unknown>) {
try {
const values = modelProps.widget.options?.values
const modelWidget = modelProps.widget as Props['widget'] | undefined
const values = modelWidget?.options?.values
const resolved = typeof values === 'function' ? values() : values
const firstValue = Array.isArray(resolved)
? resolved.find((value) => value !== null && value !== undefined)

View File

@@ -41,8 +41,9 @@ provide(
)
const modelValue = defineModel<string | undefined>({
default(props: Props) {
const values = props.widget.options?.values
default(modelProps: Record<string, unknown>) {
const modelWidget = modelProps.widget as Props['widget'] | undefined
const values = modelWidget?.options?.values
return (Array.isArray(values) ? values[0] : undefined) ?? ''
}
})

View File

@@ -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'
@@ -1814,7 +1813,6 @@ export class ComfyApp {
isPartialExecution
})
}
useFreeTierQuota().trackRun()
this.canvas.draw(true, true)
await this.ui.queue.update()
}

View File

@@ -124,7 +124,7 @@ const findElementsInTag = (
end: number,
id: Uint8Array
): EbmlElementRange | null => {
for (let pos = start; pos < end - 1; ) {
for (let pos = start; pos < end - 1;) {
if (matchesId(data, pos, id)) {
const size = readVint(data, pos + 2)
if (size && size.value > 0) {
@@ -307,7 +307,7 @@ const ebmlToString = (
const parseMetadata = (data: Uint8Array): ComfyMetadata => {
const meta: ComfyMetadata = {}
for (let pos = 0; pos < data.length - 2; ) {
for (let pos = 0; pos < data.length - 2;) {
const tagInfo = findNextTag(data, pos)
if (!tagInfo) {
pos++

View File

@@ -306,13 +306,9 @@ export function useNodeFactory() {
// Create node based on type and configuration
switch (type) {
case 'basic':
return {
/* basic node implementation */
}
return {/* basic node implementation */}
case 'complex':
return {
/* complex node implementation */
}
return {/* complex node implementation */}
default:
throw new Error(`Unknown node type: ${type}`)
}
@@ -347,9 +343,7 @@ Services in ComfyUI can be tested effectively using different approaches dependi
```typescript
// Manual instantiation required
const mockData = [
/* test data */
]
const mockData = [/* test data */]
const service = new NodeSearchService(mockData)
```

View File

@@ -147,9 +147,7 @@ describe('useAuthStore', () => {
let authStateCallback: (user: User | null) => void
let idTokenCallback: (user: User | null) => void
const mockAuth: MockAuth = {
/* mock Auth object */
}
const mockAuth: MockAuth = {/* mock Auth object */}
const mockUser: MockUser = {
uid: 'test-user-id',

View File

@@ -55,7 +55,6 @@ export function useBillingContext(): BillingContext {
isLoading: ref(false),
error: ref<string | null>(null),
isActiveSubscription: computed(() => state.value.isActiveSubscription),
canRunWorkflows: computed(() => state.value.isActiveSubscription),
isFreeTier: computed(() => false),
isLegacyTeamPlan: computed(() => false),
isTeamPlan: computed(() => state.value.isTeamPlan),

View File

@@ -132,9 +132,9 @@ export function fixBadLinks(
// We can set to null (delete), so undefined means we haven't set it at all.
if (patchedNode['inputs']![slot] !== undefined) {
logger.log(
` > Already set ${node.id}.inputs[${slot}] to ${patchedNode[
'inputs'
]![slot]!} Skipping.`
` > Already set ${node.id}.inputs[${slot}] to ${
patchedNode['inputs']![slot]!
} Skipping.`
)
return false
}