Compare commits
19 Commits
matt/be-22
...
codex/cove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78fb7da4b6 | ||
|
|
0e3610b4ee | ||
|
|
182229c157 | ||
|
|
1989d76bbd | ||
|
|
d85ce2bf9e | ||
|
|
fa87c46f90 | ||
|
|
941f151520 | ||
|
|
df5f5b3367 | ||
|
|
156f2f59b7 | ||
|
|
d855466fdf | ||
|
|
9d5719871a | ||
|
|
7610a61250 | ||
|
|
47c8b09ebf | ||
|
|
65b4c53bcb | ||
|
|
15b31d69ea | ||
|
|
471236e08d | ||
|
|
4cc0402325 | ||
|
|
a2adfe5124 | ||
|
|
49a90d4e2e |
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc directly.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(vue-tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running vue-tsc directly.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc via npx.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc via pnpx.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpm exec tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of `pnpm exec tsc`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx eslint *)",
|
||||
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of npx eslint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx eslint *)",
|
||||
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of pnpx eslint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx prettier *)",
|
||||
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx prettier *)",
|
||||
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx oxlint *)",
|
||||
"command": "echo 'Use `pnpm oxlint` instead of npx oxlint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx stylelint *)",
|
||||
"command": "echo 'Use `pnpm stylelint` instead of npx stylelint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx knip *)",
|
||||
"command": "echo 'Use `pnpm knip` instead of npx knip.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx knip *)",
|
||||
"command": "echo 'Use `pnpm knip` instead of pnpx knip.' >&2 && exit 2"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
21
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -134,6 +134,27 @@ jobs:
|
||||
fi
|
||||
echo '✅ No Customer.io references found'
|
||||
|
||||
- name: Scan dist for Syft telemetry references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Scanning for Syft references...'
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
-e '(?i)syft' \
|
||||
-e '(?i)sy-d\.io' \
|
||||
dist; then
|
||||
echo '❌ ERROR: Syft references found in dist assets!'
|
||||
echo 'Syft must be properly tree-shaken from OSS builds.'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
|
||||
echo '2. Call telemetry via useTelemetry() hook'
|
||||
echo '3. Use conditional dynamic imports behind isCloud checks'
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No Syft references found'
|
||||
|
||||
- name: Scan dist for Cloudflare Turnstile sitekey references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
2
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -121,7 +121,7 @@ jobs:
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source,unmapped,range \
|
||||
--ignore-errors source,unmapped \
|
||||
--synthesize-missing
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
|
||||
1
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -95,6 +95,7 @@ jobs:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|| (github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.fork == false
|
||||
&& startsWith(github.head_ref, 'version-bump-')
|
||||
&& (needs.changes.outputs.storybook-changes == 'true'
|
||||
|| needs.changes.outputs.app-frontend-changes == 'true'
|
||||
|
||||
3
.github/workflows/ci-tests-unit.yaml
vendored
@@ -55,6 +55,3 @@ jobs:
|
||||
flags: unit
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Enforce critical coverage gate
|
||||
run: pnpm test:coverage:critical
|
||||
|
||||
@@ -30,7 +30,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy-preview:
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
10
.github/workflows/ci-website-e2e.yaml
vendored
@@ -67,7 +67,15 @@ jobs:
|
||||
|
||||
- name: Deploy report to Cloudflare
|
||||
id: deploy
|
||||
if: always() && !cancelled()
|
||||
if: >-
|
||||
${{
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
(
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
)
|
||||
}}
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
13
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -32,12 +32,13 @@ jobs:
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
(github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
|
||||
(github.event.pull_request.head.repo.fork == false &&
|
||||
((github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build client payload
|
||||
|
||||
@@ -21,6 +21,7 @@ jobs:
|
||||
# - Preview label specifically removed
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.pull_request.head.repo.fork == false &&
|
||||
((github.event.action == 'closed' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
|
||||
@@ -56,7 +56,7 @@ const columnClass: Record<ColumnCount, string> = {
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader :label="eyebrow" align="start">
|
||||
<SectionHeader max-width="xl" :label="eyebrow" align="start">
|
||||
{{ heading }}
|
||||
<template v-if="subtitle" #subtitle>
|
||||
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
|
||||
|
||||
@@ -33,36 +33,41 @@ useHeroAnimation({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section ref="sectionRef" class="px-4 py-20 lg:flex lg:px-20 lg:py-24">
|
||||
<section
|
||||
ref="sectionRef"
|
||||
class="px-4 py-20 lg:flex lg:gap-16 lg:px-20 lg:py-24"
|
||||
>
|
||||
<!-- Left column: intro + image -->
|
||||
<div class="lg:w-1/2">
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
<div class="lg:max-w-xl">
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line lg:text-5xl"
|
||||
>
|
||||
{{ t(tk('heading'), locale) }}
|
||||
</h1>
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="mt-4 text-3xl font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t(tk('heading'), locale) }}
|
||||
</h1>
|
||||
|
||||
<div ref="descRef">
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</p>
|
||||
<div ref="descRef">
|
||||
<p class="mt-4 text-sm text-primary-comfy-canvas">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</p>
|
||||
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('supportLink'), locale) }}
|
||||
<a
|
||||
href="https://docs.comfy.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow underline"
|
||||
>
|
||||
{{ t(tk('supportLinkCta'), locale) }}
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-4 text-sm text-primary-comfy-canvas">
|
||||
{{ t(tk('supportLink'), locale) }}
|
||||
<a
|
||||
href="https://docs.comfy.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow underline"
|
||||
>
|
||||
{{ t(tk('supportLinkCta'), locale) }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="imageRef" class="mt-8 overflow-hidden rounded-2xl lg:-ml-20">
|
||||
|
||||
@@ -40,13 +40,13 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
{
|
||||
label: t('nav.products', locale),
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/featured-model-card.jpg',
|
||||
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
|
||||
imageAlt: t('nav.featuredProductsAlt', locale),
|
||||
title: t('nav.featuredProductsTitle', locale),
|
||||
cta: {
|
||||
label: t('cta.tryWorkflow', locale),
|
||||
label: t('cta.getStarted', locale),
|
||||
ariaLabel: t('nav.featuredProductsCtaAria', locale),
|
||||
href: 'https://comfy.org/workflows/api_seedance2_0_r2v-64f4db9e3e33/'
|
||||
href: routes.mcp
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
|
||||
@@ -26,6 +26,10 @@ const translations = {
|
||||
en: 'Try Workflow',
|
||||
'zh-CN': '试用工作流'
|
||||
},
|
||||
'cta.getStarted': {
|
||||
en: 'GET STARTED',
|
||||
'zh-CN': '快速开始'
|
||||
},
|
||||
'cta.watchNow': {
|
||||
en: 'Watch Now',
|
||||
'zh-CN': '立即观看'
|
||||
@@ -2196,16 +2200,16 @@ const translations = {
|
||||
// Featured dropdown cards — keys are keyed by parent nav item, not card content,
|
||||
// so the copy can be swapped without renaming the key.
|
||||
'nav.featuredProductsTitle': {
|
||||
en: 'New Release: Seedance 2.0',
|
||||
'zh-CN': '全新发布:Seedance 2.0'
|
||||
en: 'NEW: COMFY MCP',
|
||||
'zh-CN': '全新发布:Comfy MCP'
|
||||
},
|
||||
'nav.featuredProductsAlt': {
|
||||
en: 'Seedance 2.0 release feature image',
|
||||
'zh-CN': 'Seedance 2.0 发布精选图片'
|
||||
en: 'Comfy MCP feature image',
|
||||
'zh-CN': 'Comfy MCP 精选图片'
|
||||
},
|
||||
'nav.featuredProductsCtaAria': {
|
||||
en: 'Try the Seedance 2.0 workflow',
|
||||
'zh-CN': '试用 Seedance 2.0 工作流'
|
||||
en: 'Get started with Comfy MCP',
|
||||
'zh-CN': '开始使用 Comfy MCP'
|
||||
},
|
||||
'nav.featuredCommunityTitle': {
|
||||
en: 'Sky Replacement',
|
||||
|
||||
@@ -537,7 +537,6 @@ export const comfyPageFixture = base.extend<{
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.Queue.MaxHistoryItems': 64,
|
||||
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
|
||||
'Comfy.VueNodes.AutoScaleLayout': false,
|
||||
// Disable toast warning about version compatibility, as they may or
|
||||
// may not appear - depending on upstream ComfyUI dependencies
|
||||
'Comfy.VersionCompatibility.DisableWarnings': true,
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 34 KiB |
@@ -46,6 +46,7 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage, maskEditor }) => {
|
||||
const { nodeId } = await maskEditor.loadImageOnNode()
|
||||
await comfyPage.canvasOps.pan({ x: 0, y: 40 }, { x: 300, y: 300 })
|
||||
|
||||
const nodeHeader = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 93 KiB |
@@ -691,7 +691,8 @@ test(
|
||||
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
|
||||
await comfyPage.canvas.hover({ position: emptySlotPos })
|
||||
await comfyPage.page.mouse.down()
|
||||
await stepsSlot.hover()
|
||||
const { width, height } = (await stepsSlot.boundingBox())!
|
||||
await stepsSlot.hover({ position: { x: (width * 3) / 4, y: height / 2 } })
|
||||
await expect.poll(hasSnap).toBe(true)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -1238,7 +1238,7 @@ test(
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyMouse, comfyPage }) => {
|
||||
async function performDisconnect(slot: Locator, isFast: boolean) {
|
||||
await comfyMouse.dragElementBy(slot, { x: isFast ? -25 : -80 })
|
||||
await comfyMouse.dragElementBy(slot, { x: isFast ? -30 : -80 })
|
||||
|
||||
if (!isFast) {
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
|
||||
@@ -1251,7 +1251,7 @@ test(
|
||||
|
||||
const ksamplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
const ksampler = new VueNodeFixture(ksamplerLocator)
|
||||
await comfyMouse.dragElementBy(ksamplerLocator, { x: 100 })
|
||||
await comfyMouse.dragElementBy(ksampler.title, { x: 100 })
|
||||
|
||||
await test.step('Disconnection with normal links', async () => {
|
||||
await performDisconnect(ksampler.getSlot('model'), true)
|
||||
|
||||
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 60 KiB |
@@ -234,7 +234,8 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.page
|
||||
.context()
|
||||
.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.searchBoxV2.addNode('Load Image')
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
await comfyPage.page
|
||||
.locator('[data-node-id] img')
|
||||
|
||||
@@ -14,7 +14,8 @@ const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.searchBoxV2.addNode('Load Image')
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 89 KiB |
@@ -12,14 +12,14 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
const getHeaderPos = async (
|
||||
comfyPage: ComfyPage,
|
||||
title: string
|
||||
): Promise<{ x: number; y: number; width: number; height: number }> => {
|
||||
): Promise<{ x: number; y: number }> => {
|
||||
const box = await comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.getByTestId('node-title')
|
||||
.first()
|
||||
.boundingBox()
|
||||
if (!box) throw new Error(`${title} header not found`)
|
||||
return box
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
||||
}
|
||||
|
||||
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) =>
|
||||
@@ -84,29 +84,27 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.idleFrames(2)
|
||||
}
|
||||
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
x: 256,
|
||||
y: 256
|
||||
})
|
||||
test('should allow moving nodes by dragging', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const initialHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
await comfyMouse.dragElementBy(node.header, { x: 100, y: 100 })
|
||||
|
||||
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
|
||||
await expectPosChanged(initialHeaderPos, newHeaderPos)
|
||||
})
|
||||
|
||||
test('should not move node when pointer moves less than drag threshold', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
|
||||
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
|
||||
steps: 5
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
await comfyMouse.dragElementBy(node.header, { x: 2, y: 1 })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
@@ -295,14 +293,12 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(3)
|
||||
|
||||
// Re-fetch drag source after clicks in case the header reflowed.
|
||||
const dragSrc = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
const centerX = dragSrc.x + dragSrc.width / 2
|
||||
const centerY = dragSrc.y + dragSrc.height / 2
|
||||
const headerPos = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(centerX + dx, centerY + dy, {
|
||||
await comfyPage.page.mouse.move(headerPos.x + dx, headerPos.y + dy, {
|
||||
steps: 20
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 101 KiB |
@@ -42,7 +42,10 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
await expect(pinIndicator2).toBeHidden()
|
||||
})
|
||||
|
||||
test('should not allow dragging pinned nodes', async ({ comfyPage }) => {
|
||||
test('should not allow dragging pinned nodes', async ({
|
||||
comfyMouse,
|
||||
comfyPage
|
||||
}) => {
|
||||
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
|
||||
await checkpointNodeHeader.click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
@@ -50,10 +53,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
// Try to drag the node
|
||||
const headerPos = await checkpointNodeHeader.boundingBox()
|
||||
if (!headerPos) throw new Error('Failed to get header position')
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: headerPos.x, y: headerPos.y },
|
||||
{ x: headerPos.x + 256, y: headerPos.y + 256 }
|
||||
)
|
||||
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
|
||||
|
||||
// Verify the node is not dragged (same position before and after click-and-drag)
|
||||
await expect
|
||||
@@ -64,11 +64,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
await checkpointNodeHeader.click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
|
||||
// Try to drag the node again
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: headerPos.x, y: headerPos.y },
|
||||
{ x: headerPos.x + 256, y: headerPos.y + 256 }
|
||||
)
|
||||
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
|
||||
|
||||
// Verify the node is dragged
|
||||
await expect
|
||||
|
||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 26 KiB |
@@ -5,12 +5,7 @@ import {
|
||||
|
||||
test.describe('Widget copy button', { tag: ['@ui', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Add a PreviewAny node which has a read-only textarea with a copy button
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('PreviewAny')
|
||||
window.app!.graph.add(node)
|
||||
})
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Preview as Text')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
|
||||
25
global.d.ts
vendored
@@ -41,6 +41,29 @@ interface GtagFunction {
|
||||
(...args: unknown[]): void
|
||||
}
|
||||
|
||||
type SyftDataTraits = Record<string, string | number | null | undefined>
|
||||
|
||||
interface SyftDataPendingFetch {
|
||||
args: unknown[]
|
||||
resolve: (value: unknown) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}
|
||||
|
||||
interface SyftDataClient {
|
||||
identify(email: string, traits?: SyftDataTraits): void
|
||||
signup(email: string, traits?: SyftDataTraits): void
|
||||
track(event: string, traits?: SyftDataTraits): void
|
||||
page(...args: unknown[]): void
|
||||
q?: unknown[][]
|
||||
fi?: SyftDataPendingFetch[]
|
||||
fetchID?: (...args: unknown[]) => Promise<unknown>
|
||||
}
|
||||
|
||||
/** Installed by the Syft UMD instead of SyftDataClient when telemetry is opted out */
|
||||
interface SyftDisabledClient {
|
||||
enable: () => void
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__CONFIG__: {
|
||||
gtm_container_id?: string
|
||||
@@ -78,6 +101,8 @@ interface Window {
|
||||
}
|
||||
dataLayer?: Array<Record<string, unknown>>
|
||||
gtag?: GtagFunction
|
||||
syft?: SyftDataClient | SyftDisabledClient
|
||||
syftc?: { sourceId?: string; enabled?: boolean }
|
||||
ire_o?: string
|
||||
ire?: ImpactQueueFunction
|
||||
rewardful?: RewardfulQueueFunction
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:coverage:critical": "cross-env COVERAGE_CRITICAL=true vitest run --coverage",
|
||||
"test:unit": "vitest run",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
--node-component-executing: var(--color-blue-500);
|
||||
--node-component-header: var(--fg-color);
|
||||
--node-component-header-icon: var(--color-ash-800);
|
||||
--node-component-header-surface: var(--color-smoke-400);
|
||||
--node-component-header-surface: var(--color-smoke-200);
|
||||
--node-component-outline: var(--color-black);
|
||||
--node-component-ring: rgb(from var(--color-smoke-500) r g b / 50%);
|
||||
--node-component-slot-dot-outline-opacity-mult: 1;
|
||||
@@ -343,7 +343,7 @@
|
||||
--node-component-border-executing: var(--color-blue-500);
|
||||
--node-component-border-selected: var(--color-charcoal-200);
|
||||
--node-component-header-icon: var(--color-smoke-800);
|
||||
--node-component-header-surface: var(--color-charcoal-800);
|
||||
--node-component-header-surface: var(--color-charcoal-700);
|
||||
--node-component-outline: var(--color-white);
|
||||
--node-component-ring: rgb(var(--color-smoke-500) / 20%);
|
||||
--node-component-slot-dot-outline-opacity: 10%;
|
||||
@@ -727,14 +727,14 @@ body {
|
||||
/* Shared markdown content styling for consistent rendering across components */
|
||||
.comfy-markdown-content {
|
||||
/* Typography */
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
font-size: var(--comfy-textarea-font-size);
|
||||
line-height: 1.6;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.comfy-markdown-content h1 {
|
||||
font-size: 22px; /* text-[22px] */
|
||||
font-size: calc(22 / 14 * var(--comfy-textarea-font-size));
|
||||
font-weight: 700; /* font-bold */
|
||||
margin-top: 2rem; /* mt-8 */
|
||||
margin-bottom: 1rem; /* mb-4 */
|
||||
@@ -745,7 +745,7 @@ body {
|
||||
}
|
||||
|
||||
.comfy-markdown-content h2 {
|
||||
font-size: 18px; /* text-[18px] */
|
||||
font-size: calc(18 / 14 * var(--comfy-textarea-font-size));
|
||||
font-weight: 700; /* font-bold */
|
||||
margin-top: 2rem; /* mt-8 */
|
||||
margin-bottom: 1rem; /* mb-4 */
|
||||
@@ -756,7 +756,7 @@ body {
|
||||
}
|
||||
|
||||
.comfy-markdown-content h3 {
|
||||
font-size: 16px; /* text-[16px] */
|
||||
font-size: calc(16 / 14 * var(--comfy-textarea-font-size));
|
||||
font-weight: 700; /* font-bold */
|
||||
margin-top: 2rem; /* mt-8 */
|
||||
margin-bottom: 1rem; /* mb-4 */
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div class="mx-1 flex flex-col items-end gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-if="managerState.shouldShowManagerButtons.value"
|
||||
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"
|
||||
>
|
||||
<Button
|
||||
@@ -163,6 +163,7 @@ import {
|
||||
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { useManagerSurveyDialog } from '@/workbench/extensions/manager/composables/useManagerSurveyDialog'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
@@ -170,6 +171,7 @@ const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const managerState = useManagerState()
|
||||
const managerSurveyDialog = useManagerSurveyDialog()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const { t } = useI18n()
|
||||
@@ -337,6 +339,10 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
const openCustomNodeManager = async () => {
|
||||
if (isCloud) {
|
||||
managerSurveyDialog.show()
|
||||
return
|
||||
}
|
||||
try {
|
||||
await managerState.openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
import { HideLayoutFieldKey, WidgetHeightKey } from '@/types/widgetTypes'
|
||||
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
|
||||
@@ -50,6 +50,7 @@ const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
|
||||
)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(WidgetHeightKey, mobile ? 'h-10' : 'h-7')
|
||||
|
||||
const resolvedInputs = useResolvedSelectedInputs()
|
||||
|
||||
@@ -236,7 +237,7 @@ defineExpose({ handleDragDrop })
|
||||
:node-data
|
||||
:class="
|
||||
cn(
|
||||
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
|
||||
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1',
|
||||
nodeData.hasErrors && 'ring-2 ring-node-stroke-error ring-inset'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
|
||||
:class="
|
||||
cn(
|
||||
'flex overflow-hidden rounded-md bg-component-node-widget-background text-xs text-component-node-foreground',
|
||||
useWidgetHeight()
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="background" />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.decrement')"
|
||||
data-testid="decrement"
|
||||
class="aspect-8/7 h-full rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
class="aspect-square h-full rounded-none p-0 hover:bg-component-node-widget-background-hovered disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
:disabled="!canDecrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue = clamp(modelValue - step)"
|
||||
>
|
||||
<i class="pi pi-minus" />
|
||||
<i class="icon-[lucide--minus]" />
|
||||
</Button>
|
||||
<div class="relative my-0.25 min-w-[4ch] flex-1 py-1.5">
|
||||
<input
|
||||
@@ -24,7 +30,7 @@
|
||||
:disabled
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 truncate border-0 bg-transparent p-1 text-sm focus:outline-0'
|
||||
'absolute inset-0 truncate border-0 bg-transparent p-1 text-xs focus:outline-0'
|
||||
)
|
||||
"
|
||||
inputmode="decimal"
|
||||
@@ -54,13 +60,14 @@
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.increment')"
|
||||
data-testid="increment"
|
||||
class="aspect-8/7 h-full rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
class="aspect-square h-full rounded-none p-0 hover:bg-component-node-widget-background-hovered disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
:disabled="!canIncrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue = clamp(modelValue + step)"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
<i class="icon-[lucide--plus]" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -71,6 +78,7 @@ import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWidgetHeight } from '@/types/widgetTypes'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
|
||||
@@ -42,22 +42,34 @@ function withStrictMillisecondParser<T>(run: () => T): T {
|
||||
}
|
||||
|
||||
const mockSubscription = vi.hoisted(() => ({
|
||||
value: null as { endDate: string | null } | null
|
||||
value: null as {
|
||||
endDate: string | null
|
||||
duration?: 'ANNUAL' | 'MONTHLY' | null
|
||||
} | null
|
||||
}))
|
||||
|
||||
const mockCancelSubscription = vi.hoisted(() => vi.fn())
|
||||
const mockFetchStatus = vi.hoisted(() => vi.fn())
|
||||
const mockCloseDialog = vi.hoisted(() => vi.fn())
|
||||
const mockToastAdd = vi.hoisted(() => vi.fn())
|
||||
const mockTier = vi.hoisted(() => ({ value: 'STANDARD' as string | null }))
|
||||
const mockTrackCancellation = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
cancelSubscription: mockCancelSubscription,
|
||||
fetchStatus: mockFetchStatus,
|
||||
subscription: mockSubscription
|
||||
subscription: mockSubscription,
|
||||
tier: mockTier
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackSubscriptionCancellation: mockTrackCancellation
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: vi.fn(() => ({
|
||||
closeDialog: mockCloseDialog
|
||||
@@ -94,6 +106,95 @@ function renderComponent(props: { cancelAt?: string } = {}) {
|
||||
describe('CancelSubscriptionDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTier.value = 'STANDARD'
|
||||
})
|
||||
|
||||
describe('cancellation telemetry', () => {
|
||||
it('tracks flow_opened with tier and end date when the dialog mounts', () => {
|
||||
mockSubscription.value = { endDate: '2026-08-01T00:00:00.000Z' }
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith('flow_opened', {
|
||||
source: 'cancel_plan_menu',
|
||||
current_tier: 'standard',
|
||||
end_date: '2026-08-01T00:00:00.000Z'
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks confirmed before the cancel request and no abandoned on success', async () => {
|
||||
mockSubscription.value = null
|
||||
mockCancelSubscription.mockResolvedValueOnce(undefined)
|
||||
|
||||
const { unmount } = renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /^cancel subscription$/i })
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockCloseDialog).toHaveBeenCalled())
|
||||
unmount()
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'confirmed',
|
||||
expect.objectContaining({ current_tier: 'standard' })
|
||||
)
|
||||
expect(mockTrackCancellation).not.toHaveBeenCalledWith(
|
||||
'abandoned',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks confirmed and failed with message-carrying rejection values', async () => {
|
||||
mockSubscription.value = null
|
||||
mockCancelSubscription.mockRejectedValueOnce({ message: 'timed out' })
|
||||
|
||||
renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /^cancel subscription$/i })
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'failed',
|
||||
expect.objectContaining({ error_message: 'timed out' })
|
||||
)
|
||||
)
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'confirmed',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks abandoned when the user keeps the subscription', async () => {
|
||||
mockSubscription.value = null
|
||||
|
||||
const { unmount } = renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /keep subscription/i })
|
||||
)
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'cancel-subscription'
|
||||
})
|
||||
unmount()
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'abandoned',
|
||||
expect.objectContaining({ current_tier: 'standard' })
|
||||
)
|
||||
expect(mockCancelSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks abandoned when the dialog is dismissed by the shell', () => {
|
||||
mockSubscription.value = null
|
||||
|
||||
const { unmount } = renderComponent()
|
||||
mockTrackCancellation.mockClear()
|
||||
unmount()
|
||||
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'abandoned',
|
||||
expect.objectContaining({ current_tier: 'standard' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancel flow', () => {
|
||||
@@ -138,6 +239,35 @@ describe('CancelSubscriptionDialogContent', () => {
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not track cancellation failure when status refresh fails after cancellation succeeds', async () => {
|
||||
mockSubscription.value = null
|
||||
mockCancelSubscription.mockResolvedValueOnce(undefined)
|
||||
mockFetchStatus.mockRejectedValueOnce(new Error('Refresh failed'))
|
||||
|
||||
const { unmount } = renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /^cancel subscription$/i })
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
)
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'cancel-subscription'
|
||||
})
|
||||
expect(
|
||||
mockTrackCancellation.mock.calls.some(([stage]) => stage === 'failed')
|
||||
).toBe(false)
|
||||
|
||||
unmount()
|
||||
expect(mockTrackCancellation).not.toHaveBeenCalledWith(
|
||||
'abandoned',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formattedEndDate fallbacks', () => {
|
||||
|
||||
@@ -45,13 +45,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { SubscriptionCancellationMetadata } from '@/platform/telemetry/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { parseIsoDateSafe } from '@/utils/dateTimeUtil'
|
||||
import { getErrorMessage } from '@/utils/errorUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
cancelAt?: string
|
||||
@@ -60,9 +63,41 @@ const props = defineProps<{
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
const { cancelSubscription, fetchStatus, subscription } = useBillingContext()
|
||||
const { cancelSubscription, fetchStatus, subscription, tier } =
|
||||
useBillingContext()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const didCancelSucceed = ref(false)
|
||||
|
||||
function cancellationMetadata(): SubscriptionCancellationMetadata {
|
||||
const endDate = props.cancelAt ?? subscription.value?.endDate
|
||||
return {
|
||||
source: 'cancel_plan_menu' as const,
|
||||
current_tier: tier.value?.toLowerCase(),
|
||||
...(subscription.value?.duration
|
||||
? {
|
||||
cycle:
|
||||
subscription.value.duration === 'ANNUAL'
|
||||
? ('yearly' as const)
|
||||
: ('monthly' as const)
|
||||
}
|
||||
: {}),
|
||||
...(endDate ? { end_date: endDate } : {})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
telemetry?.trackSubscriptionCancellation(
|
||||
'flow_opened',
|
||||
cancellationMetadata()
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (didCancelSucceed.value || isLoading.value) return
|
||||
telemetry?.trackSubscriptionCancellation('abandoned', cancellationMetadata())
|
||||
})
|
||||
|
||||
const formattedEndDate = computed(() => {
|
||||
const date = parseIsoDateSafe(props.cancelAt ?? subscription.value?.endDate)
|
||||
@@ -84,24 +119,37 @@ function onClose() {
|
||||
}
|
||||
|
||||
async function onConfirmCancel() {
|
||||
telemetry?.trackSubscriptionCancellation('confirmed', cancellationMetadata())
|
||||
isLoading.value = true
|
||||
try {
|
||||
await cancelSubscription()
|
||||
await fetchStatus()
|
||||
dialogStore.closeDialog({ key: 'cancel-subscription' })
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.cancelSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error)
|
||||
telemetry?.trackSubscriptionCancellation('failed', {
|
||||
...cancellationMetadata(),
|
||||
error_message: errorMessage ?? String(error)
|
||||
})
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.cancelDialog.failed'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
detail: errorMessage ?? t('g.unknownError')
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
didCancelSucceed.value = true
|
||||
try {
|
||||
await fetchStatus()
|
||||
} catch {
|
||||
// Cancellation already succeeded; stale local subscription status should not report failure.
|
||||
}
|
||||
dialogStore.closeDialog({ key: 'cancel-subscription' })
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.cancelSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
isLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -31,7 +31,7 @@ import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
import { HideLayoutFieldKey, WidgetHeightKey } from '@/types/widgetTypes'
|
||||
|
||||
import { GetNodeParentGroupKey } from '../shared'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
@@ -135,6 +135,7 @@ watchDebounced(
|
||||
onBeforeUnmount(() => draggableList.value?.dispose())
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(WidgetHeightKey, 'h-7')
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"color": "Color",
|
||||
"error": "Error",
|
||||
"enter": "Enter",
|
||||
"enterSubgraph": "Enter Subgraph",
|
||||
"enterSubgraph": "Enter subgraph",
|
||||
"inSubgraph": "in subgraph '{name}'",
|
||||
"resizeFromBottomRight": "Resize from bottom-right corner",
|
||||
"resizeFromTopRight": "Resize from top-right corner",
|
||||
@@ -429,6 +429,11 @@
|
||||
},
|
||||
"manager": {
|
||||
"title": "Nodes Manager",
|
||||
"survey": {
|
||||
"title": "Join the waitlist",
|
||||
"intro": "Installing custom nodes is coming soon to Comfy Cloud. Answer a few quick questions to join the waitlist and help shape this feature.",
|
||||
"error": "This survey couldn't be loaded. Please try again later."
|
||||
},
|
||||
"nodePackInfo": "Node Pack Info",
|
||||
"basicInfo": "Basic Info",
|
||||
"actions": "Actions",
|
||||
|
||||
@@ -426,10 +426,6 @@
|
||||
"Comfy_Validation_Workflows": {
|
||||
"name": "Validate workflows"
|
||||
},
|
||||
"Comfy_VueNodes_AutoScaleLayout": {
|
||||
"name": "Auto-scale layout (Nodes 2.0)",
|
||||
"tooltip": "Automatically scale node positions when switching to Nodes 2.0 rendering to prevent overlap"
|
||||
},
|
||||
"Comfy_VueNodes_Enabled": {
|
||||
"name": "Modern Node Design (Nodes 2.0)",
|
||||
"tooltip": "Modern: DOM-based rendering with enhanced interactivity, native browser features, and updated visual design. Classic: Traditional canvas rendering."
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SubscriptionPanelContentLegacy from './SubscriptionPanelContentLegacy.vue'
|
||||
|
||||
const mockAccessBillingPortal = vi.fn()
|
||||
const mockTrackSubscriptionCancellation = vi.fn()
|
||||
const mockShowSubscriptionDialog = vi.fn()
|
||||
const mockHandleRefresh = vi.fn()
|
||||
|
||||
const mockIsActiveSubscription = ref(true)
|
||||
const mockIsCancelled = ref(false)
|
||||
const mockIsFreeTier = ref(false)
|
||||
const mockSubscriptionTier = ref<'STANDARD' | 'CREATOR' | 'PRO' | null>(
|
||||
'STANDARD'
|
||||
)
|
||||
const mockIsYearlySubscription = ref(true)
|
||||
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: () => ({
|
||||
accessBillingPortal: mockAccessBillingPortal
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackSubscriptionCancellation: mockTrackSubscriptionCancellation
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
|
||||
isCancelled: computed(() => mockIsCancelled.value),
|
||||
isFreeTier: computed(() => mockIsFreeTier.value),
|
||||
formattedRenewalDate: computed(() => '2026-08-01'),
|
||||
formattedEndDate: computed(() => '2026-08-01'),
|
||||
subscriptionTier: computed(() => mockSubscriptionTier.value),
|
||||
subscriptionTierName: computed(() => 'Standard'),
|
||||
isYearlySubscription: computed(() => mockIsYearlySubscription.value)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionActions',
|
||||
() => ({
|
||||
useSubscriptionActions: () => ({
|
||||
handleRefresh: mockHandleRefresh
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({
|
||||
show: mockShowSubscriptionDialog
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
subscription: {
|
||||
perMonth: '/ month',
|
||||
manageSubscription: 'Manage subscription',
|
||||
upgradePlan: 'Upgrade plan',
|
||||
subscribeNow: 'Subscribe now',
|
||||
yourPlanIncludes: 'Your plan includes',
|
||||
viewMoreDetailsPlans: 'View more details',
|
||||
renewsDate: 'Renews {date}',
|
||||
expiresDate: 'Expires {date}',
|
||||
monthlyCreditsLabel: 'monthly credits',
|
||||
maxDurationLabel: 'max duration',
|
||||
gpuLabel: 'GPU access',
|
||||
addCreditsLabel: 'Add credits',
|
||||
customLoRAsLabel: 'Custom LoRAs',
|
||||
maxDuration: {
|
||||
standard: '30 min'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
return render(SubscriptionPanelContentLegacy, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
CreditsTile: true,
|
||||
SubscribeButton: true,
|
||||
Button: {
|
||||
template: '<button @click="$emit(\'click\')"><slot /></button>',
|
||||
emits: ['click']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('SubscriptionPanelContentLegacy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAccessBillingPortal.mockResolvedValue(undefined)
|
||||
mockIsActiveSubscription.value = true
|
||||
mockIsCancelled.value = false
|
||||
mockIsFreeTier.value = false
|
||||
mockSubscriptionTier.value = 'STANDARD'
|
||||
mockIsYearlySubscription.value = true
|
||||
})
|
||||
|
||||
it('tracks cancel intent before opening the billing portal', async () => {
|
||||
renderComponent()
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /manage subscription/i })
|
||||
)
|
||||
|
||||
expect(mockTrackSubscriptionCancellation).toHaveBeenCalledExactlyOnceWith(
|
||||
'flow_opened',
|
||||
{
|
||||
source: 'manage_subscription_button',
|
||||
current_tier: 'standard',
|
||||
cycle: 'yearly'
|
||||
}
|
||||
)
|
||||
expect(mockAccessBillingPortal).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -36,11 +36,7 @@
|
||||
v-if="isActiveSubscription && !isFreeTier"
|
||||
variant="secondary"
|
||||
class="ml-auto rounded-lg bg-interface-menu-component-surface-selected px-4 py-2 text-sm font-normal text-text-primary"
|
||||
@click="
|
||||
async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
"
|
||||
@click="handleManageSubscription"
|
||||
>
|
||||
{{ $t('subscription.manageSubscription') }}
|
||||
</Button>
|
||||
@@ -125,6 +121,7 @@ import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import {
|
||||
@@ -160,6 +157,18 @@ const tierPrice = computed(() =>
|
||||
getTierPrice(tierKey.value, isYearlySubscription.value)
|
||||
)
|
||||
|
||||
// The portal is the only place a legacy user can cancel (in-app UI already
|
||||
// covers plan changes), so this click is the closest observable cancel-intent
|
||||
// signal on the mainline path.
|
||||
async function handleManageSubscription() {
|
||||
useTelemetry()?.trackSubscriptionCancellation('flow_opened', {
|
||||
source: 'manage_subscription_button',
|
||||
current_tier: subscriptionTier.value?.toLowerCase(),
|
||||
cycle: isYearlySubscription.value ? 'yearly' : 'monthly'
|
||||
})
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
const tierBenefits = computed((): TierBenefit[] =>
|
||||
getCommonTierBenefits(tierKey.value, t, n)
|
||||
)
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
computeActiveGraphIds,
|
||||
computeAncestorExecutionIds,
|
||||
createVerificationAbortController
|
||||
} from './missingCandidateHelpers'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
rootGraph: null as unknown,
|
||||
getActiveGraphNodeIds: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
get rootGraph() {
|
||||
return mocks.rootGraph
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getActiveGraphNodeIds: mocks.getActiveGraphNodeIds
|
||||
}))
|
||||
|
||||
describe('createVerificationAbortController', () => {
|
||||
it('create returns a fresh, non-aborted controller', () => {
|
||||
const manager = createVerificationAbortController()
|
||||
const controller = manager.create()
|
||||
expect(controller.signal.aborted).toBe(false)
|
||||
})
|
||||
|
||||
it('create aborts the previously issued controller', () => {
|
||||
const manager = createVerificationAbortController()
|
||||
const first = manager.create()
|
||||
manager.create()
|
||||
expect(first.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('abort aborts the current controller', () => {
|
||||
const manager = createVerificationAbortController()
|
||||
const controller = manager.create()
|
||||
manager.abort()
|
||||
expect(controller.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('abort after abort is a no-op (no current controller)', () => {
|
||||
const manager = createVerificationAbortController()
|
||||
manager.create()
|
||||
manager.abort()
|
||||
expect(() => manager.abort()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeAncestorExecutionIds', () => {
|
||||
it('expands each node id into its execution-id prefixes, inclusive', () => {
|
||||
const result = computeAncestorExecutionIds(['65:70:63'])
|
||||
expect([...result]).toEqual(['65', '65:70', '65:70:63'])
|
||||
})
|
||||
|
||||
it('deduplicates shared ancestor prefixes across node ids', () => {
|
||||
const result = computeAncestorExecutionIds(['65:70', '65:71'])
|
||||
expect([...result]).toEqual(['65', '65:70', '65:71'])
|
||||
})
|
||||
|
||||
it('returns an empty set for no node ids', () => {
|
||||
expect(computeAncestorExecutionIds([]).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeActiveGraphIds', () => {
|
||||
beforeEach(() => {
|
||||
mocks.rootGraph = null
|
||||
mocks.getActiveGraphNodeIds.mockReset()
|
||||
})
|
||||
|
||||
it('returns an empty set when the root graph is unavailable', () => {
|
||||
const result = computeActiveGraphIds(null, new Set())
|
||||
expect(result.size).toBe(0)
|
||||
expect(mocks.getActiveGraphNodeIds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('delegates to getActiveGraphNodeIds with the current graph', () => {
|
||||
const rootGraph = { id: 'root' }
|
||||
const currentGraph = { id: 'current' }
|
||||
mocks.rootGraph = rootGraph
|
||||
mocks.getActiveGraphNodeIds.mockReturnValue(new Set(['1']))
|
||||
|
||||
const ancestors = computeAncestorExecutionIds(['65'])
|
||||
const result = computeActiveGraphIds(currentGraph as never, ancestors)
|
||||
|
||||
expect(result).toEqual(new Set(['1']))
|
||||
expect(mocks.getActiveGraphNodeIds).toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
currentGraph,
|
||||
ancestors
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the root graph when no current graph is given', () => {
|
||||
const rootGraph = { id: 'root' }
|
||||
mocks.rootGraph = rootGraph
|
||||
mocks.getActiveGraphNodeIds.mockReturnValue(new Set<string>())
|
||||
|
||||
computeActiveGraphIds(null, new Set())
|
||||
|
||||
expect(mocks.getActiveGraphNodeIds).toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
rootGraph,
|
||||
expect.any(Set)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
|
||||
|
||||
interface VerificationAbortController {
|
||||
create(): AbortController
|
||||
abort(): void
|
||||
}
|
||||
|
||||
export function createVerificationAbortController(): VerificationAbortController {
|
||||
let controller: AbortController | null = null
|
||||
return {
|
||||
create() {
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
return controller
|
||||
},
|
||||
abort() {
|
||||
controller?.abort()
|
||||
controller = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set of all execution ID prefixes derived from the given node IDs,
|
||||
* including the nodes themselves.
|
||||
*
|
||||
* Example: node "65:70:63" → Set { "65", "65:70", "65:70:63" }
|
||||
*/
|
||||
export function computeAncestorExecutionIds(
|
||||
nodeIds: Iterable<string>
|
||||
): Set<NodeExecutionId> {
|
||||
const ids = new Set<NodeExecutionId>()
|
||||
for (const nodeId of nodeIds) {
|
||||
for (const id of getAncestorExecutionIds(nodeId)) {
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
export function computeActiveGraphIds(
|
||||
currentGraph: LGraph | null,
|
||||
ancestorExecutionIds: Set<NodeExecutionId>
|
||||
): Set<string> {
|
||||
if (!app.rootGraph) return new Set()
|
||||
return getActiveGraphNodeIds(
|
||||
app.rootGraph,
|
||||
currentGraph ?? app.rootGraph,
|
||||
ancestorExecutionIds
|
||||
)
|
||||
}
|
||||
@@ -3,12 +3,11 @@ import { computed, ref } from 'vue'
|
||||
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import {
|
||||
computeActiveGraphIds,
|
||||
computeAncestorExecutionIds,
|
||||
createVerificationAbortController
|
||||
} from '@/platform/missing/missingCandidateHelpers'
|
||||
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
/**
|
||||
@@ -32,18 +31,38 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
new Set(missingMediaCandidates.value?.map((m) => String(m.nodeId)) ?? [])
|
||||
)
|
||||
|
||||
const missingMediaAncestorExecutionIds = computed(() =>
|
||||
computeAncestorExecutionIds(missingMediaNodeIds.value)
|
||||
/**
|
||||
* Set of all execution ID prefixes derived from missing media node IDs,
|
||||
* including the missing media nodes themselves.
|
||||
*/
|
||||
const missingMediaAncestorExecutionIds = computed<Set<NodeExecutionId>>(
|
||||
() => {
|
||||
const ids = new Set<NodeExecutionId>()
|
||||
for (const nodeId of missingMediaNodeIds.value) {
|
||||
for (const id of getAncestorExecutionIds(nodeId)) {
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
)
|
||||
|
||||
const activeMissingMediaGraphIds = computed(() =>
|
||||
computeActiveGraphIds(
|
||||
canvasStore.currentGraph,
|
||||
const activeMissingMediaGraphIds = computed<Set<string>>(() => {
|
||||
if (!app.rootGraph) return new Set()
|
||||
return getActiveGraphNodeIds(
|
||||
app.rootGraph,
|
||||
canvasStore.currentGraph ?? app.rootGraph,
|
||||
missingMediaAncestorExecutionIds.value
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const verificationAbortController = createVerificationAbortController()
|
||||
let _verificationAbortController: AbortController | null = null
|
||||
|
||||
function createVerificationAbortController(): AbortController {
|
||||
_verificationAbortController?.abort()
|
||||
_verificationAbortController = new AbortController()
|
||||
return _verificationAbortController
|
||||
}
|
||||
|
||||
function setMissingMedia(media: MissingMediaCandidate[]) {
|
||||
missingMediaCandidates.value = media.length ? media : null
|
||||
@@ -113,7 +132,8 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
}
|
||||
|
||||
function clearMissingMedia() {
|
||||
verificationAbortController.abort()
|
||||
_verificationAbortController?.abort()
|
||||
_verificationAbortController = null
|
||||
missingMediaCandidates.value = null
|
||||
}
|
||||
|
||||
@@ -131,7 +151,7 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
removeMissingMediaByNodeId,
|
||||
removeMissingMediaByPrefix,
|
||||
clearMissingMedia,
|
||||
createVerificationAbortController: verificationAbortController.create,
|
||||
createVerificationAbortController,
|
||||
|
||||
isContainerWithMissingMedia
|
||||
}
|
||||
|
||||
@@ -9,12 +9,9 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
computeActiveGraphIds,
|
||||
computeAncestorExecutionIds,
|
||||
createVerificationAbortController
|
||||
} from '@/platform/missing/missingCandidateHelpers'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
|
||||
|
||||
/**
|
||||
* Missing model error state and interaction state.
|
||||
@@ -54,16 +51,32 @@ export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
return keys
|
||||
})
|
||||
|
||||
const missingModelAncestorExecutionIds = computed(() =>
|
||||
computeAncestorExecutionIds(missingModelNodeIds.value)
|
||||
/**
|
||||
* Set of all execution ID prefixes derived from missing model node IDs,
|
||||
* including the missing model nodes themselves.
|
||||
*
|
||||
* Example: missing model on node "65:70:63" → Set { "65", "65:70", "65:70:63" }
|
||||
*/
|
||||
const missingModelAncestorExecutionIds = computed<Set<NodeExecutionId>>(
|
||||
() => {
|
||||
const ids = new Set<NodeExecutionId>()
|
||||
for (const nodeId of missingModelNodeIds.value) {
|
||||
for (const id of getAncestorExecutionIds(nodeId)) {
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
)
|
||||
|
||||
const activeMissingModelGraphIds = computed(() =>
|
||||
computeActiveGraphIds(
|
||||
canvasStore.currentGraph,
|
||||
const activeMissingModelGraphIds = computed<Set<string>>(() => {
|
||||
if (!app.rootGraph) return new Set()
|
||||
return getActiveGraphNodeIds(
|
||||
app.rootGraph,
|
||||
canvasStore.currentGraph ?? app.rootGraph,
|
||||
missingModelAncestorExecutionIds.value
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
// Persists across component re-mounts so that download progress
|
||||
// survives tab switches within the right-side panel.
|
||||
@@ -73,7 +86,13 @@ export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
const folderPaths = ref<Record<string, string[]>>({})
|
||||
const fileSizes = ref<Record<string, number>>({})
|
||||
|
||||
const verificationAbortController = createVerificationAbortController()
|
||||
let _verificationAbortController: AbortController | null = null
|
||||
|
||||
function createVerificationAbortController(): AbortController {
|
||||
_verificationAbortController?.abort()
|
||||
_verificationAbortController = new AbortController()
|
||||
return _verificationAbortController
|
||||
}
|
||||
|
||||
function setMissingModels(models: MissingModelCandidate[]) {
|
||||
missingModelCandidates.value = models.length ? models : null
|
||||
@@ -227,7 +246,8 @@ export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
}
|
||||
|
||||
function clearMissingModels() {
|
||||
verificationAbortController.abort()
|
||||
_verificationAbortController?.abort()
|
||||
_verificationAbortController = null
|
||||
missingModelCandidates.value = null
|
||||
modelExpandState.value = {}
|
||||
selectedLibraryModel.value = {}
|
||||
@@ -278,7 +298,7 @@ export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
removeMissingModelsBySourceScope,
|
||||
clearMissingModels,
|
||||
refreshMissingModels,
|
||||
createVerificationAbortController: verificationAbortController.create,
|
||||
createVerificationAbortController,
|
||||
|
||||
hasMissingModelOnNode,
|
||||
isWidgetMissingModel,
|
||||
|
||||
@@ -82,6 +82,7 @@ export type RemoteConfig = {
|
||||
posthog_project_token?: string
|
||||
posthog_api_host?: string
|
||||
posthog_config?: Partial<PostHogConfig>
|
||||
syftdata_source_id?: string
|
||||
customer_io?: {
|
||||
write_key?: string
|
||||
site_id?: string
|
||||
@@ -101,6 +102,8 @@ export type RemoteConfig = {
|
||||
private_models_enabled?: boolean
|
||||
onboarding_survey_enabled?: boolean
|
||||
onboarding_survey?: OnboardingSurvey
|
||||
/** Full hosted (external) survey URL embedded in the Nodes Manager modal on Cloud. */
|
||||
manager_survey_url?: string
|
||||
linear_toggle_enabled?: boolean
|
||||
team_workspaces_enabled?: boolean
|
||||
user_secrets_enabled?: boolean
|
||||
|
||||
176
src/platform/settings/components/ColorPaletteMessage.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ColorPaletteMessage from './ColorPaletteMessage.vue'
|
||||
|
||||
import type * as Pinia from 'pinia'
|
||||
|
||||
const testI18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
const mockSettingStore = vi.hoisted(() => ({
|
||||
set: vi.fn()
|
||||
}))
|
||||
|
||||
const mockColorPaletteService = vi.hoisted(() => ({
|
||||
exportColorPalette: vi.fn(),
|
||||
importColorPalette: vi.fn(),
|
||||
deleteCustomColorPalette: vi.fn()
|
||||
}))
|
||||
|
||||
const mockColorPaletteState = vi.hoisted(() => ({
|
||||
refs: null as null | {
|
||||
palettes: {
|
||||
value: Array<{ id: string; name: string }>
|
||||
}
|
||||
activePaletteId: {
|
||||
value: string
|
||||
}
|
||||
},
|
||||
customPaletteIds: new Set<string>()
|
||||
}))
|
||||
|
||||
vi.mock('pinia', async (importOriginal: () => Promise<typeof Pinia>) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
storeToRefs: (store: object) => store
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/services/colorPaletteService', () => ({
|
||||
useColorPaletteService: () => mockColorPaletteService
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
|
||||
const palettes = ref([
|
||||
{ id: 'builtin', name: 'Builtin' },
|
||||
{ id: 'custom', name: 'Custom' }
|
||||
])
|
||||
const activePaletteId = ref('builtin')
|
||||
|
||||
mockColorPaletteState.refs = {
|
||||
palettes,
|
||||
activePaletteId
|
||||
}
|
||||
|
||||
return {
|
||||
useColorPaletteStore: () => ({
|
||||
palettes,
|
||||
activePaletteId,
|
||||
isCustomPalette: (paletteId: string) =>
|
||||
mockColorPaletteState.customPaletteIds.has(paletteId)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: {
|
||||
props: ['title', 'disabled'],
|
||||
emits: ['click'],
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
:title="title"
|
||||
:disabled="disabled"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/message', () => ({
|
||||
default: {
|
||||
template: '<section><slot /></section>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
default: {
|
||||
props: ['modelValue', 'options'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
data-testid="palette-select"
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="option in options" :key="option.id" :value="option.id">
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
function renderMessage() {
|
||||
return render(ColorPaletteMessage, {
|
||||
global: {
|
||||
plugins: [testI18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ColorPaletteMessage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSettingStore.set.mockResolvedValue(undefined)
|
||||
mockColorPaletteService.importColorPalette.mockResolvedValue(null)
|
||||
mockColorPaletteState.customPaletteIds = new Set(['custom'])
|
||||
if (mockColorPaletteState.refs) {
|
||||
mockColorPaletteState.refs.activePaletteId.value = 'builtin'
|
||||
mockColorPaletteState.refs.palettes.value = [
|
||||
{ id: 'builtin', name: 'Builtin' },
|
||||
{ id: 'custom', name: 'Custom' }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
it('exports and deletes the active custom palette', async () => {
|
||||
renderMessage()
|
||||
|
||||
await userEvent.click(screen.getByTitle('g.export'))
|
||||
expect(mockColorPaletteService.exportColorPalette).toHaveBeenCalledWith(
|
||||
'builtin'
|
||||
)
|
||||
expect(screen.getByTitle('g.delete')).toBeDisabled()
|
||||
|
||||
await fireEvent.update(screen.getByTestId('palette-select'), 'custom')
|
||||
await userEvent.click(screen.getByTitle('g.delete'))
|
||||
|
||||
expect(
|
||||
mockColorPaletteService.deleteCustomColorPalette
|
||||
).toHaveBeenCalledWith('custom')
|
||||
})
|
||||
|
||||
it('persists imported palettes only when import returns a palette', async () => {
|
||||
renderMessage()
|
||||
|
||||
await userEvent.click(screen.getByTitle('g.import'))
|
||||
expect(mockSettingStore.set).not.toHaveBeenCalled()
|
||||
|
||||
mockColorPaletteService.importColorPalette.mockResolvedValue({
|
||||
id: 'imported',
|
||||
name: 'Imported'
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTitle('g.import'))
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.ColorPalette',
|
||||
'imported'
|
||||
)
|
||||
})
|
||||
})
|
||||
306
src/platform/settings/components/ExtensionPanel.test.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ExtensionPanel from './ExtensionPanel.vue'
|
||||
|
||||
const testI18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
interface MockExtension {
|
||||
name: string
|
||||
}
|
||||
|
||||
const mockSettingStore = vi.hoisted(() => ({
|
||||
set: vi.fn()
|
||||
}))
|
||||
|
||||
const mockExtensionState = vi.hoisted(() => ({
|
||||
store: {
|
||||
extensions: [
|
||||
{ name: 'core.color' },
|
||||
{ name: 'custom.pack' },
|
||||
{ name: 'readonly.pack' }
|
||||
] as MockExtension[],
|
||||
inactiveDisabledExtensionNames: ['inactive.pack'],
|
||||
hasThirdPartyExtensions: true,
|
||||
enabled: new Set(['core.color', 'custom.pack', 'readonly.pack']),
|
||||
core: new Set(['core.color']),
|
||||
readOnly: new Set(['readonly.pack']),
|
||||
isExtensionEnabled(name: string) {
|
||||
return this.enabled.has(name)
|
||||
},
|
||||
isCoreExtension(name: string) {
|
||||
return this.core.has(name)
|
||||
},
|
||||
isExtensionReadOnly(name: string) {
|
||||
return this.readOnly.has(name)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@primevue/core/api', () => ({
|
||||
FilterMatchMode: {
|
||||
CONTAINS: 'contains'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/extensionStore', () => ({
|
||||
useExtensionStore: () => mockExtensionState.store
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
|
||||
default: {
|
||||
props: ['modelValue', 'placeholder'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
data-testid="extension-search"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: {
|
||||
props: ['disabled'],
|
||||
emits: ['click'],
|
||||
template: `
|
||||
<button type="button" :disabled="disabled" @click="$emit('click', $event)">
|
||||
<slot />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/message', () => ({
|
||||
default: {
|
||||
template: '<section data-testid="extension-message"><slot /></section>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/selectbutton', () => ({
|
||||
default: {
|
||||
props: ['modelValue', 'options'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<div data-testid="extension-filter">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
@click="$emit('update:modelValue', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/datatable', () => ({
|
||||
default: {
|
||||
props: ['value', 'selection'],
|
||||
emits: ['update:selection'],
|
||||
template: `
|
||||
<section data-testid="extension-table">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="select-visible"
|
||||
@click="$emit('update:selection', value)"
|
||||
>
|
||||
select
|
||||
</button>
|
||||
<div v-for="ext in value" :key="ext.name" data-testid="extension-row">
|
||||
{{ ext.name }}
|
||||
</div>
|
||||
<slot />
|
||||
</section>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/column', () => ({
|
||||
default: {
|
||||
template: '<div><slot name="header" /><slot /></div>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/contextmenu', () => ({
|
||||
default: {
|
||||
props: ['model'],
|
||||
methods: {
|
||||
show: vi.fn()
|
||||
},
|
||||
template: `
|
||||
<div data-testid="extension-menu">
|
||||
<button
|
||||
v-for="item in model.filter((entry) => !entry.separator)"
|
||||
:key="item.label"
|
||||
type="button"
|
||||
:disabled="item.disabled"
|
||||
@click="item.command?.()"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/tag', () => ({
|
||||
default: {
|
||||
props: ['value'],
|
||||
template: '<span>{{ value }}</span>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/toggleswitch', () => ({
|
||||
default: {
|
||||
props: ['modelValue', 'disabled'],
|
||||
emits: ['update:modelValue', 'change'],
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
data-testid="extension-toggle"
|
||||
@click="$emit('update:modelValue', !modelValue); $emit('change')"
|
||||
>
|
||||
{{ String(modelValue) }}
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
function renderPanel() {
|
||||
return render(ExtensionPanel, {
|
||||
global: {
|
||||
plugins: [testI18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ExtensionPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSettingStore.set.mockResolvedValue(undefined)
|
||||
mockExtensionState.store.extensions = [
|
||||
{ name: 'core.color' },
|
||||
{ name: 'custom.pack' },
|
||||
{ name: 'readonly.pack' }
|
||||
]
|
||||
mockExtensionState.store.inactiveDisabledExtensionNames = ['inactive.pack']
|
||||
mockExtensionState.store.hasThirdPartyExtensions = true
|
||||
mockExtensionState.store.enabled = new Set([
|
||||
'core.color',
|
||||
'custom.pack',
|
||||
'readonly.pack'
|
||||
])
|
||||
mockExtensionState.store.core = new Set(['core.color'])
|
||||
mockExtensionState.store.readOnly = new Set(['readonly.pack'])
|
||||
})
|
||||
|
||||
it('filters extensions by all, core, and custom categories', async () => {
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByTestId('extension-table')).toHaveTextContent(
|
||||
'core.color'
|
||||
)
|
||||
expect(screen.getByTestId('extension-table')).toHaveTextContent(
|
||||
'custom.pack'
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'g.core' }))
|
||||
expect(screen.getByTestId('extension-table')).toHaveTextContent(
|
||||
'core.color'
|
||||
)
|
||||
expect(screen.getByTestId('extension-table')).not.toHaveTextContent(
|
||||
'custom.pack'
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'g.custom' }))
|
||||
expect(screen.getByTestId('extension-table')).not.toHaveTextContent(
|
||||
'core.color'
|
||||
)
|
||||
expect(screen.getByTestId('extension-table')).toHaveTextContent(
|
||||
'custom.pack'
|
||||
)
|
||||
expect(screen.getByTestId('extension-table')).toHaveTextContent(
|
||||
'readonly.pack'
|
||||
)
|
||||
})
|
||||
|
||||
it('applies selected extension commands without changing read-only rows', async () => {
|
||||
renderPanel()
|
||||
|
||||
await userEvent.click(screen.getByTestId('select-visible'))
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'g.disableSelected' })
|
||||
)
|
||||
|
||||
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
|
||||
'Comfy.Extension.Disabled',
|
||||
['inactive.pack', 'core.color', 'custom.pack']
|
||||
)
|
||||
expect(screen.getByTestId('extension-message')).toHaveTextContent(
|
||||
'core.color'
|
||||
)
|
||||
expect(screen.getByTestId('extension-message')).toHaveTextContent(
|
||||
'custom.pack'
|
||||
)
|
||||
expect(screen.getByTestId('extension-message')).not.toHaveTextContent(
|
||||
'readonly.pack'
|
||||
)
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'g.enableSelected' })
|
||||
)
|
||||
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
|
||||
'Comfy.Extension.Disabled',
|
||||
['inactive.pack']
|
||||
)
|
||||
})
|
||||
|
||||
it('applies bulk commands and disables third-party command when unavailable', async () => {
|
||||
const { unmount } = renderPanel()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'g.disableAll' }))
|
||||
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
|
||||
'Comfy.Extension.Disabled',
|
||||
['inactive.pack', 'core.color', 'custom.pack']
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'g.enableAll' }))
|
||||
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
|
||||
'Comfy.Extension.Disabled',
|
||||
['inactive.pack']
|
||||
)
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'g.disableThirdParty' })
|
||||
)
|
||||
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
|
||||
'Comfy.Extension.Disabled',
|
||||
['inactive.pack', 'custom.pack', 'readonly.pack']
|
||||
)
|
||||
|
||||
unmount()
|
||||
mockExtensionState.store.hasThirdPartyExtensions = false
|
||||
renderPanel()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'g.disableThirdParty' })
|
||||
).toBeDisabled()
|
||||
})
|
||||
})
|
||||
317
src/platform/settings/components/ServerConfigPanel.test.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ServerConfigPanel from './ServerConfigPanel.vue'
|
||||
import type * as Pinia from 'pinia'
|
||||
|
||||
const testI18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
const mockSettingStore = vi.hoisted(() => ({
|
||||
set: vi.fn()
|
||||
}))
|
||||
|
||||
const mockToastStore = vi.hoisted(() => ({
|
||||
add: vi.fn()
|
||||
}))
|
||||
|
||||
const mockCopy = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockElectronAPI = vi.hoisted(() => ({
|
||||
restartApp: vi.fn()
|
||||
}))
|
||||
|
||||
const mockServerConfigStore = vi.hoisted(() => ({
|
||||
refs: null as null | {
|
||||
serverConfigsByCategory: {
|
||||
value: Record<
|
||||
string,
|
||||
Array<{
|
||||
id: string
|
||||
name: string
|
||||
value: string
|
||||
initialValue: string
|
||||
tooltip?: string
|
||||
}>
|
||||
>
|
||||
}
|
||||
serverConfigValues: { value: Record<string, string> }
|
||||
launchArgs: { value: string[] }
|
||||
commandLineArgs: { value: string }
|
||||
modifiedConfigs: {
|
||||
value: Array<{
|
||||
id: string
|
||||
name: string
|
||||
value: string
|
||||
initialValue: string
|
||||
}>
|
||||
}
|
||||
},
|
||||
revertChanges: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('pinia', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof Pinia>()
|
||||
return {
|
||||
...actual,
|
||||
storeToRefs: (store: object) => store
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => mockToastStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/serverConfigStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
|
||||
const serverConfigsByCategory = ref({
|
||||
general: [
|
||||
{
|
||||
id: 'listen',
|
||||
name: 'Listen',
|
||||
value: 'true',
|
||||
initialValue: 'false',
|
||||
tooltip: 'Enable listen mode'
|
||||
},
|
||||
{
|
||||
id: 'preview',
|
||||
name: 'Preview',
|
||||
value: 'auto',
|
||||
initialValue: 'auto'
|
||||
}
|
||||
]
|
||||
})
|
||||
const serverConfigValues = ref({ listen: 'true' })
|
||||
const launchArgs = ref(['--listen'])
|
||||
const commandLineArgs = ref('python main.py --listen')
|
||||
const modifiedConfigs = ref([
|
||||
{
|
||||
id: 'listen',
|
||||
name: 'Listen',
|
||||
value: 'true',
|
||||
initialValue: 'false'
|
||||
}
|
||||
])
|
||||
|
||||
mockServerConfigStore.refs = {
|
||||
serverConfigsByCategory,
|
||||
serverConfigValues,
|
||||
launchArgs,
|
||||
commandLineArgs,
|
||||
modifiedConfigs
|
||||
}
|
||||
|
||||
return {
|
||||
useServerConfigStore: () => ({
|
||||
serverConfigsByCategory,
|
||||
serverConfigValues,
|
||||
launchArgs,
|
||||
commandLineArgs,
|
||||
modifiedConfigs,
|
||||
revertChanges: mockServerConfigStore.revertChanges
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
useCopyToClipboard: () => ({
|
||||
copyToClipboard: mockCopy
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: () => mockElectronAPI
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/FormItem.vue', () => ({
|
||||
default: {
|
||||
props: ['id', 'item', 'labelClass'],
|
||||
template: `
|
||||
<label
|
||||
:data-testid="'server-config-' + id"
|
||||
:data-highlighted="String(Boolean(labelClass?.['text-highlight']))"
|
||||
:title="item.tooltip"
|
||||
>
|
||||
{{ item.name }}={{ item.value }}
|
||||
</label>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: {
|
||||
props: ['ariaLabel'],
|
||||
emits: ['click'],
|
||||
template: `
|
||||
<button type="button" :aria-label="ariaLabel" @click="$emit('click')">
|
||||
<slot />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/divider', () => ({
|
||||
default: {
|
||||
template: '<hr />'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/message', () => ({
|
||||
default: {
|
||||
template: '<section><slot name="icon" /><slot /></section>'
|
||||
}
|
||||
}))
|
||||
|
||||
function renderPanel() {
|
||||
return render(ServerConfigPanel, {
|
||||
global: {
|
||||
plugins: [testI18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ServerConfigPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSettingStore.set.mockResolvedValue(undefined)
|
||||
mockCopy.mockResolvedValue(undefined)
|
||||
mockElectronAPI.restartApp.mockResolvedValue(undefined)
|
||||
mockServerConfigStore.revertChanges.mockReset()
|
||||
if (mockServerConfigStore.refs) {
|
||||
mockServerConfigStore.refs.serverConfigsByCategory.value = {
|
||||
general: [
|
||||
{
|
||||
id: 'listen',
|
||||
name: 'Listen',
|
||||
value: 'true',
|
||||
initialValue: 'false',
|
||||
tooltip: 'Enable listen mode'
|
||||
},
|
||||
{
|
||||
id: 'preview',
|
||||
name: 'Preview',
|
||||
value: 'auto',
|
||||
initialValue: 'auto'
|
||||
}
|
||||
]
|
||||
}
|
||||
mockServerConfigStore.refs.serverConfigValues.value = { listen: 'true' }
|
||||
mockServerConfigStore.refs.launchArgs.value = ['--listen']
|
||||
mockServerConfigStore.refs.commandLineArgs.value =
|
||||
'python main.py --listen'
|
||||
mockServerConfigStore.refs.modifiedConfigs.value = [
|
||||
{
|
||||
id: 'listen',
|
||||
name: 'Listen',
|
||||
value: 'true',
|
||||
initialValue: 'false'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
it('renders modified configs, translates form items, and copies command line args', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByText('serverConfig.modifiedConfigs')).toBeInTheDocument()
|
||||
expect(screen.getByText('Listen: false → true')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('server-config-listen')).toHaveAttribute(
|
||||
'data-highlighted',
|
||||
'true'
|
||||
)
|
||||
expect(screen.getByTestId('server-config-listen')).toHaveAttribute(
|
||||
'title',
|
||||
'Enable listen mode'
|
||||
)
|
||||
expect(screen.getByTestId('server-config-preview')).toHaveAttribute(
|
||||
'data-highlighted',
|
||||
'false'
|
||||
)
|
||||
|
||||
await user.click(screen.getByLabelText('g.copyToClipboard'))
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('python main.py --listen')
|
||||
})
|
||||
|
||||
it('reverts, restarts, and suppresses the unmount warning after restart', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { unmount } = renderPanel()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'serverConfig.revertChanges' })
|
||||
)
|
||||
expect(mockServerConfigStore.revertChanges).toHaveBeenCalledTimes(1)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'serverConfig.restart' })
|
||||
)
|
||||
expect(mockElectronAPI.restartApp).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockToastStore.add).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists launch args and server config values through watchers', async () => {
|
||||
renderPanel()
|
||||
|
||||
if (!mockServerConfigStore.refs) {
|
||||
throw new Error('server config refs were not initialized')
|
||||
}
|
||||
|
||||
mockServerConfigStore.refs.launchArgs.value = ['--cpu']
|
||||
await nextTick()
|
||||
mockServerConfigStore.refs.serverConfigValues.value = { listen: 'false' }
|
||||
await nextTick()
|
||||
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Server.LaunchArgs',
|
||||
['--cpu']
|
||||
)
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Server.ServerConfigValues',
|
||||
{ listen: 'false' }
|
||||
)
|
||||
})
|
||||
|
||||
it('warns on unmount only when modified configs remain', () => {
|
||||
if (!mockServerConfigStore.refs) {
|
||||
throw new Error('server config refs were not initialized')
|
||||
}
|
||||
|
||||
mockServerConfigStore.refs.modifiedConfigs.value = []
|
||||
const empty = renderPanel()
|
||||
empty.unmount()
|
||||
expect(mockToastStore.add).not.toHaveBeenCalled()
|
||||
|
||||
mockServerConfigStore.refs.modifiedConfigs.value = [
|
||||
{
|
||||
id: 'listen',
|
||||
name: 'Listen',
|
||||
value: 'true',
|
||||
initialValue: 'false'
|
||||
}
|
||||
]
|
||||
const modified = renderPanel()
|
||||
modified.unmount()
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'warn',
|
||||
summary: 'serverConfig.restartRequiredToastSummary',
|
||||
detail: 'serverConfig.restartRequiredToastDetail',
|
||||
life: 10_000
|
||||
})
|
||||
})
|
||||
})
|
||||
543
src/platform/settings/components/SettingDialog.test.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
|
||||
|
||||
import SettingDialog from './SettingDialog.vue'
|
||||
|
||||
const testI18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
type MockSettingData = Omit<SettingParams, 'id' | 'type' | 'defaultValue'> & {
|
||||
id: string
|
||||
}
|
||||
|
||||
type MockSettingTreeNode = Omit<SettingTreeNode, 'data' | 'children'> & {
|
||||
data?: MockSettingData
|
||||
children?: MockSettingTreeNode[]
|
||||
}
|
||||
|
||||
type MockSettingGroup = Omit<ISettingGroup, 'settings'> & {
|
||||
settings: MockSettingData[]
|
||||
}
|
||||
|
||||
const mockFetchBalance = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockSettingUI = vi.hoisted(() => ({
|
||||
defaultPanel: undefined as string | undefined,
|
||||
refs: null as null | {
|
||||
settingCategories: {
|
||||
value: MockSettingTreeNode[]
|
||||
}
|
||||
navGroups: {
|
||||
value: Array<{
|
||||
title: string
|
||||
items: Array<{
|
||||
id: string
|
||||
label: string
|
||||
icon?: string
|
||||
badge?: string
|
||||
}>
|
||||
}>
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const mockSettingSearch = vi.hoisted(() => ({
|
||||
refs: null as null | {
|
||||
searchQuery: { value: string }
|
||||
inSearch: { value: boolean }
|
||||
searchResultsCategories: { value: Set<string> }
|
||||
matchedNavItemKeys: { value: Set<string> }
|
||||
results: { value: MockSettingGroup[] }
|
||||
},
|
||||
handleSearch: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
fetchBalance: mockFetchBalance
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/searchQuery/useSearchQueryTracking', () => ({
|
||||
useSearchQueryTracking: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/widget/layout/BaseModalLayout.vue', () => ({
|
||||
default: {
|
||||
template: `
|
||||
<section data-testid="settings-dialog">
|
||||
<header data-testid="left-title"><slot name="leftPanelHeaderTitle" /></header>
|
||||
<aside data-testid="left-panel"><slot name="leftPanel" /></aside>
|
||||
<div data-testid="header"><slot name="header" /></div>
|
||||
<div data-testid="header-actions"><slot name="header-right-area" /></div>
|
||||
<main data-testid="content"><slot name="content" /></main>
|
||||
</section>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
|
||||
default: {
|
||||
props: ['modelValue', 'placeholder', 'autofocus'],
|
||||
emits: ['update:modelValue', 'search'],
|
||||
template: `
|
||||
<input
|
||||
data-testid="settings-search"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:data-autofocus="String(autofocus)"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
@change="$emit('search', $event.target.value)"
|
||||
/>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/widget/nav/NavTitle.vue', () => ({
|
||||
default: {
|
||||
props: ['title'],
|
||||
template: '<h3>{{ title }}</h3>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/widget/nav/NavItem.vue', () => ({
|
||||
default: {
|
||||
props: ['icon', 'badge', 'active'],
|
||||
emits: ['click'],
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
:data-nav-id="$attrs['data-nav-id']"
|
||||
:data-active="String(active)"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/dialog/content/setting/CurrentUserMessage.vue', () => ({
|
||||
default: {
|
||||
template: '<p data-testid="current-user-message">current user</p>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/components/ColorPaletteMessage.vue', () => ({
|
||||
default: {
|
||||
template: '<p data-testid="color-palette-message">palette</p>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/components/SettingsPanel.vue', () => ({
|
||||
default: {
|
||||
props: ['settingGroups'],
|
||||
template: `
|
||||
<div data-testid="settings-panel">
|
||||
<section v-for="group in settingGroups" :key="group.label">
|
||||
<h4>{{ group.label }}</h4>
|
||||
<span v-for="setting in group.settings" :key="setting.id">
|
||||
{{ setting.id }}
|
||||
</span>
|
||||
</section>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/composables/useSettingUI', async () => {
|
||||
const { computed, defineComponent, h, ref } = await import('vue')
|
||||
|
||||
const settingCategories = ref<MockSettingTreeNode[]>([
|
||||
{
|
||||
key: 'Comfy',
|
||||
label: 'Comfy',
|
||||
children: [
|
||||
{
|
||||
key: 'General',
|
||||
label: 'General',
|
||||
children: [
|
||||
{
|
||||
key: 'Comfy.High',
|
||||
label: 'High',
|
||||
leaf: true,
|
||||
data: { id: 'Comfy.High', name: 'High', sortOrder: 30 }
|
||||
},
|
||||
{
|
||||
key: 'Comfy.Low',
|
||||
label: 'Low',
|
||||
leaf: true,
|
||||
data: { id: 'Comfy.Low', name: 'Low', sortOrder: 10 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'Advanced',
|
||||
label: 'Advanced',
|
||||
children: [
|
||||
{
|
||||
key: 'Comfy.Advanced',
|
||||
label: 'Advanced',
|
||||
leaf: true,
|
||||
data: { id: 'Comfy.Advanced', name: 'Advanced' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'Appearance',
|
||||
label: 'Appearance',
|
||||
children: [
|
||||
{
|
||||
key: 'Palette',
|
||||
label: 'Palette',
|
||||
children: [
|
||||
{
|
||||
key: 'Appearance.Palette',
|
||||
label: 'Palette',
|
||||
leaf: true,
|
||||
data: {
|
||||
id: 'Appearance.Palette',
|
||||
name: 'Palette',
|
||||
sortOrder: 20
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const navGroups = ref([
|
||||
{
|
||||
title: 'Core',
|
||||
items: [
|
||||
{ id: 'Comfy', label: 'Comfy', icon: 'settings' },
|
||||
{ id: 'Appearance', label: 'Appearance', icon: 'palette' },
|
||||
{ id: 'keybinding', label: 'Keybinding', icon: 'keyboard' },
|
||||
{ id: 'credits', label: 'Credits', icon: 'coins' }
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const keybindingPanel = {
|
||||
node: { key: 'keybinding', label: 'Keybinding', children: [] },
|
||||
component: defineComponent({
|
||||
name: 'MockKeybindingPanel',
|
||||
setup: () => () => h('div', { 'data-testid': 'keybinding-panel' }, 'keys')
|
||||
})
|
||||
}
|
||||
|
||||
mockSettingUI.refs = {
|
||||
settingCategories,
|
||||
navGroups
|
||||
}
|
||||
|
||||
return {
|
||||
useSettingUI: vi.fn((defaultPanel?: string) => ({
|
||||
defaultCategory: computed(
|
||||
() =>
|
||||
settingCategories.value.find((c) => c.key === defaultPanel) ??
|
||||
settingCategories.value[0]
|
||||
),
|
||||
settingCategories,
|
||||
navGroups,
|
||||
findCategoryByKey: (key: string) =>
|
||||
settingCategories.value.find((c) => c.key === key) ?? null,
|
||||
findPanelByKey: (key: string) =>
|
||||
key === 'keybinding' ? keybindingPanel : null
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/composables/useSettingSearch', async () => {
|
||||
const { computed, ref } = await import('vue')
|
||||
|
||||
const searchQuery = ref('')
|
||||
const inSearch = ref(false)
|
||||
const searchResultsCategories = ref(new Set<string>())
|
||||
const matchedNavItemKeys = ref(new Set<string>())
|
||||
const results = ref<MockSettingGroup[]>([
|
||||
{
|
||||
label: 'Search Group',
|
||||
category: 'Comfy',
|
||||
settings: [{ id: 'Comfy.SearchResult', name: 'Search Result' }]
|
||||
}
|
||||
])
|
||||
|
||||
mockSettingSearch.refs = {
|
||||
searchQuery,
|
||||
inSearch,
|
||||
searchResultsCategories,
|
||||
matchedNavItemKeys,
|
||||
results
|
||||
}
|
||||
|
||||
mockSettingSearch.handleSearch.mockImplementation(
|
||||
(query: string, navItems: Array<{ key: string; label: string }> = []) => {
|
||||
searchQuery.value = query
|
||||
inSearch.value = query.length > 0
|
||||
searchResultsCategories.value = query.includes('appearance')
|
||||
? new Set(['Appearance'])
|
||||
: new Set()
|
||||
matchedNavItemKeys.value = new Set(
|
||||
navItems
|
||||
.filter((item) => item.label.toLowerCase().includes(query))
|
||||
.map((item) => item.key)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
useSettingSearch: vi.fn(() => ({
|
||||
searchQuery,
|
||||
inSearch,
|
||||
searchResultsCategories: computed(() => searchResultsCategories.value),
|
||||
matchedNavItemKeys: computed(() => matchedNavItemKeys.value),
|
||||
handleSearch: mockSettingSearch.handleSearch,
|
||||
getSearchResults: vi.fn(() => results.value)
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
function renderDialog(
|
||||
props: Partial<InstanceType<typeof SettingDialog>['$props']> = {}
|
||||
) {
|
||||
return render(SettingDialog, {
|
||||
props: {
|
||||
onClose: vi.fn(),
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [testI18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('SettingDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchBalance.mockReset()
|
||||
if (mockSettingSearch.refs) {
|
||||
mockSettingSearch.refs.searchQuery.value = ''
|
||||
mockSettingSearch.refs.inSearch.value = false
|
||||
mockSettingSearch.refs.searchResultsCategories.value = new Set()
|
||||
mockSettingSearch.refs.matchedNavItemKeys.value = new Set()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the default category panel with sorted groups and settings', () => {
|
||||
renderDialog()
|
||||
|
||||
expect(screen.getByTestId('current-user-message')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('settings-panel')).toHaveTextContent('General')
|
||||
expect(screen.getByTestId('settings-panel')).toHaveTextContent('Advanced')
|
||||
expect(screen.getByTestId('settings-panel').textContent).toMatch(
|
||||
/Comfy\.High.*Comfy\.Low/
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Comfy' })).toHaveAttribute(
|
||||
'data-active',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('switches category from the nav and fetches credits balance for credits', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Appearance' }))
|
||||
expect(screen.getByTestId('color-palette-message')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Appearance' })).toHaveAttribute(
|
||||
'data-active',
|
||||
'true'
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Credits' }))
|
||||
await nextTick()
|
||||
|
||||
expect(mockFetchBalance).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders panel header slots and disables search autofocus for keybindings', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Keybinding' }))
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('keybinding-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('header')).not.toBeEmptyDOMElement()
|
||||
expect(screen.getByTestId('header-actions')).not.toBeEmptyDOMElement()
|
||||
expect(screen.getByTestId('settings-search')).toHaveAttribute(
|
||||
'data-autofocus',
|
||||
'false'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders search results and activates the first matching nav item', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
const input = screen.getByTestId('settings-search')
|
||||
await user.type(input, 'appearance')
|
||||
await user.tab()
|
||||
await nextTick()
|
||||
|
||||
expect(mockSettingSearch.handleSearch).toHaveBeenCalledWith(
|
||||
'appearance',
|
||||
expect.arrayContaining([{ key: 'Appearance', label: 'Appearance' }])
|
||||
)
|
||||
expect(screen.getByTestId('settings-panel')).toHaveTextContent(
|
||||
'Comfy.SearchResult'
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Appearance' })).toHaveAttribute(
|
||||
'data-active',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps search mode active when no nav item or category matches', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
const input = screen.getByTestId('settings-search')
|
||||
await user.type(input, 'unmatched')
|
||||
await user.tab()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('settings-panel')).toHaveTextContent(
|
||||
'Comfy.SearchResult'
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Comfy' })).toHaveAttribute(
|
||||
'data-active',
|
||||
'false'
|
||||
)
|
||||
})
|
||||
|
||||
it('restores the default category after clearing search', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
const input = screen.getByTestId('settings-search')
|
||||
await user.type(input, 'unmatched')
|
||||
await user.tab()
|
||||
await nextTick()
|
||||
await user.clear(input)
|
||||
await user.tab()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('current-user-message')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Comfy' })).toHaveAttribute(
|
||||
'data-active',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('sorts groups by label when group sort order ties', async () => {
|
||||
const refs = mockSettingUI.refs
|
||||
if (!refs) throw new Error('Expected setting UI refs')
|
||||
|
||||
const originalCategories = refs.settingCategories.value
|
||||
const originalNavGroups = refs.navGroups.value
|
||||
refs.settingCategories.value = [
|
||||
...originalCategories,
|
||||
{
|
||||
key: 'Tie',
|
||||
label: 'Tie',
|
||||
children: [
|
||||
{
|
||||
key: 'Beta',
|
||||
label: 'Beta',
|
||||
children: [
|
||||
{
|
||||
key: 'Tie.Beta',
|
||||
label: 'Beta',
|
||||
leaf: true,
|
||||
data: { id: 'Tie.Beta', name: 'Beta', sortOrder: 5 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'Alpha',
|
||||
label: 'Alpha',
|
||||
children: [
|
||||
{
|
||||
key: 'Tie.Alpha',
|
||||
label: 'Alpha',
|
||||
leaf: true,
|
||||
data: { id: 'Tie.Alpha', name: 'Alpha', sortOrder: 5 }
|
||||
},
|
||||
{
|
||||
key: 'Tie.NoSort',
|
||||
label: 'NoSort',
|
||||
leaf: true,
|
||||
data: { id: 'Tie.NoSort', name: 'NoSort' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
refs.navGroups.value = [
|
||||
{
|
||||
title: 'Core',
|
||||
items: [
|
||||
...originalNavGroups[0].items,
|
||||
{ id: 'Tie', label: 'Tie', icon: 'settings' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
try {
|
||||
renderDialog()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Tie' }))
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('settings-panel').textContent).toMatch(
|
||||
/Alpha.*Beta/
|
||||
)
|
||||
expect(screen.getByTestId('settings-panel').textContent).toMatch(
|
||||
/Tie\.Alpha.*Tie\.NoSort/
|
||||
)
|
||||
} finally {
|
||||
refs.settingCategories.value = originalCategories
|
||||
refs.navGroups.value = originalNavGroups
|
||||
}
|
||||
})
|
||||
|
||||
it('scrolls to a target setting and removes its highlight after animation', async () => {
|
||||
const target = document.createElement('div')
|
||||
target.dataset.settingId = 'Comfy.Target'
|
||||
const scrollIntoView = vi.fn()
|
||||
target.scrollIntoView = scrollIntoView
|
||||
document.body.appendChild(target)
|
||||
|
||||
try {
|
||||
renderDialog({ scrollToSettingId: 'Comfy.Target' })
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
})
|
||||
expect(target.classList.contains('setting-highlight')).toBe(true)
|
||||
|
||||
target.dispatchEvent(new Event('animationend'))
|
||||
|
||||
expect(target.classList.contains('setting-highlight')).toBe(false)
|
||||
} finally {
|
||||
target.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
199
src/platform/settings/composables/useLitegraphSettings.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { nextTick, reactive } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
|
||||
import {
|
||||
CanvasPointer,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
type SettingValue = boolean | number | string
|
||||
|
||||
// The real canvasStore exposes `canvas` via a shallowRef, so the mock must be
|
||||
// reactive for the composable's watchEffects to re-run when the canvas mounts
|
||||
// after setup. `vi.hoisted` runs before imports, hence the dynamic import.
|
||||
const { canvasStore, settings } = await vi.hoisted(async () => {
|
||||
const { reactive } = await import('vue')
|
||||
return {
|
||||
canvasStore: reactive({
|
||||
canvas: undefined as
|
||||
| undefined
|
||||
| {
|
||||
show_info?: SettingValue
|
||||
zoom_speed?: SettingValue
|
||||
auto_pan_speed?: SettingValue
|
||||
links_render_mode?: SettingValue
|
||||
min_font_size_for_lod?: SettingValue
|
||||
linkMarkerShape?: SettingValue
|
||||
maximumFps?: SettingValue
|
||||
dragZoomEnabled?: SettingValue
|
||||
liveSelection?: SettingValue
|
||||
groupSelectChildren?: SettingValue
|
||||
draw: ReturnType<typeof vi.fn>
|
||||
setDirty: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}),
|
||||
settings: {
|
||||
current: {} as Record<string, SettingValue>
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => {
|
||||
class MockCanvasPointer {
|
||||
static doubleClickTime = 0
|
||||
static bufferTime = 0
|
||||
static maxClickDrift = 0
|
||||
}
|
||||
|
||||
class MockLGraphNode {
|
||||
static keepAllLinksOnBypass = false
|
||||
}
|
||||
|
||||
return {
|
||||
CanvasPointer: MockCanvasPointer,
|
||||
LGraphNode: MockLGraphNode,
|
||||
LiteGraph: {
|
||||
Reroute: {},
|
||||
snaps_for_comfy: false,
|
||||
snap_highlights_node: false,
|
||||
middle_click_slot_add_default_node: false,
|
||||
CANVAS_GRID_SIZE: 0,
|
||||
alwaysSnapToGrid: false,
|
||||
context_menu_scaling: 1,
|
||||
canvasNavigationMode: 'legacy',
|
||||
macTrackpadGestures: false,
|
||||
leftMouseClickBehavior: 'select',
|
||||
mouseWheelScroll: 'zoom',
|
||||
saveViewportWithGraph: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => settings.current[key]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => canvasStore
|
||||
}))
|
||||
|
||||
function makeCanvas() {
|
||||
return {
|
||||
draw: vi.fn(),
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
settings.current = reactive({
|
||||
'Comfy.Graph.CanvasInfo': true,
|
||||
'Comfy.Graph.ZoomSpeed': 1.25,
|
||||
'Comfy.Graph.AutoPanSpeed': 0.75,
|
||||
'Comfy.Node.AutoSnapLinkToSlot': true,
|
||||
'Comfy.Node.SnapHighlightsNode': true,
|
||||
'Comfy.Node.BypassAllLinksOnDelete': true,
|
||||
'Comfy.Node.MiddleClickRerouteNode': true,
|
||||
'Comfy.LinkRenderMode': 2,
|
||||
'LiteGraph.Canvas.MinFontSizeForLOD': 9,
|
||||
'Comfy.Graph.LinkMarkers': 'arrow',
|
||||
'LiteGraph.Canvas.MaximumFps': 42,
|
||||
'Comfy.Graph.CtrlShiftZoom': true,
|
||||
'Comfy.Graph.LiveSelection': true,
|
||||
'Comfy.Pointer.DoubleClickTime': 250,
|
||||
'Comfy.Pointer.ClickBufferTime': 80,
|
||||
'Comfy.Pointer.ClickDrift': 4,
|
||||
'Comfy.SnapToGrid.GridSize': 16,
|
||||
'pysssss.SnapToGrid': true,
|
||||
'LiteGraph.ContextMenu.Scaling': 1.5,
|
||||
'LiteGraph.Reroute.SplineOffset': 32,
|
||||
'Comfy.Canvas.NavigationMode': 'standard',
|
||||
'Comfy.Canvas.LeftMouseClickBehavior': 'panning',
|
||||
'Comfy.Canvas.MouseWheelScroll': 'panning',
|
||||
'Comfy.EnableWorkflowViewRestore': true,
|
||||
'LiteGraph.Group.SelectChildrenOnClick': true
|
||||
})
|
||||
canvasStore.canvas = reactive(makeCanvas())
|
||||
})
|
||||
|
||||
describe('useLitegraphSettings', () => {
|
||||
it('applies canvas settings and marks affected layers dirty', () => {
|
||||
useLitegraphSettings()
|
||||
|
||||
expect(canvasStore.canvas?.show_info).toBe(true)
|
||||
expect(canvasStore.canvas?.zoom_speed).toBe(1.25)
|
||||
expect(canvasStore.canvas?.auto_pan_speed).toBe(0.75)
|
||||
expect(canvasStore.canvas?.links_render_mode).toBe(2)
|
||||
expect(canvasStore.canvas?.min_font_size_for_lod).toBe(9)
|
||||
expect(canvasStore.canvas?.linkMarkerShape).toBe('arrow')
|
||||
expect(canvasStore.canvas?.maximumFps).toBe(42)
|
||||
expect(canvasStore.canvas?.dragZoomEnabled).toBe(true)
|
||||
expect(canvasStore.canvas?.liveSelection).toBe(true)
|
||||
expect(canvasStore.canvas?.groupSelectChildren).toBe(true)
|
||||
expect(canvasStore.canvas?.draw).toHaveBeenCalledWith(false, true)
|
||||
expect(canvasStore.canvas?.setDirty).toHaveBeenCalledWith(false, true)
|
||||
expect(canvasStore.canvas?.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('applies global LiteGraph and pointer settings', () => {
|
||||
useLitegraphSettings()
|
||||
|
||||
expect(LiteGraph.snaps_for_comfy).toBe(true)
|
||||
expect(LiteGraph.snap_highlights_node).toBe(true)
|
||||
expect(LGraphNode.keepAllLinksOnBypass).toBe(true)
|
||||
expect(LiteGraph.middle_click_slot_add_default_node).toBe(true)
|
||||
expect(CanvasPointer.doubleClickTime).toBe(250)
|
||||
expect(CanvasPointer.bufferTime).toBe(80)
|
||||
expect(CanvasPointer.maxClickDrift).toBe(4)
|
||||
expect(LiteGraph.CANVAS_GRID_SIZE).toBe(16)
|
||||
expect(LiteGraph.alwaysSnapToGrid).toBe(true)
|
||||
expect(LiteGraph.context_menu_scaling).toBe(1.5)
|
||||
expect(LiteGraph.Reroute.maxSplineOffset).toBe(32)
|
||||
expect(LiteGraph.canvasNavigationMode).toBe('standard')
|
||||
expect(LiteGraph.macTrackpadGestures).toBe(true)
|
||||
expect(LiteGraph.leftMouseClickBehavior).toBe('panning')
|
||||
expect(LiteGraph.mouseWheelScroll).toBe('panning')
|
||||
expect(LiteGraph.saveViewportWithGraph).toBe(true)
|
||||
})
|
||||
|
||||
it('responds when reactive settings change', async () => {
|
||||
useLitegraphSettings()
|
||||
|
||||
settings.current['Comfy.Graph.CanvasInfo'] = false
|
||||
settings.current['Comfy.Canvas.NavigationMode'] = 'custom'
|
||||
settings.current['LiteGraph.Group.SelectChildrenOnClick'] = false
|
||||
await nextTick()
|
||||
|
||||
expect(canvasStore.canvas?.show_info).toBe(false)
|
||||
expect(canvasStore.canvas?.groupSelectChildren).toBe(false)
|
||||
expect(LiteGraph.canvasNavigationMode).toBe('custom')
|
||||
expect(LiteGraph.macTrackpadGestures).toBe(false)
|
||||
})
|
||||
|
||||
it('updates global settings when the canvas is not mounted yet', () => {
|
||||
canvasStore.canvas = undefined
|
||||
|
||||
useLitegraphSettings()
|
||||
|
||||
expect(LiteGraph.snaps_for_comfy).toBe(true)
|
||||
expect(CanvasPointer.doubleClickTime).toBe(250)
|
||||
})
|
||||
|
||||
it('applies canvas settings once the canvas mounts after setup', async () => {
|
||||
canvasStore.canvas = undefined
|
||||
|
||||
useLitegraphSettings()
|
||||
|
||||
canvasStore.canvas = reactive(makeCanvas())
|
||||
await nextTick()
|
||||
|
||||
expect(canvasStore.canvas?.show_info).toBe(true)
|
||||
expect(canvasStore.canvas?.zoom_speed).toBe(1.25)
|
||||
expect(canvasStore.canvas?.links_render_mode).toBe(2)
|
||||
expect(canvasStore.canvas?.draw).toHaveBeenCalledWith(false, true)
|
||||
expect(canvasStore.canvas?.setDirty).toHaveBeenCalledWith(false, true)
|
||||
})
|
||||
})
|
||||
@@ -1,72 +1,80 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { render } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
getSettingInfo,
|
||||
useSettingStore
|
||||
} from '@/platform/settings/settingStore'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
|
||||
import { useSettingUI } from './useSettingUI'
|
||||
|
||||
const env = vi.hoisted(() => {
|
||||
const state = {
|
||||
isCloud: false,
|
||||
isDesktop: false,
|
||||
isLoggedIn: false,
|
||||
teamWorkspacesEnabled: false,
|
||||
userSecretsEnabled: false,
|
||||
isActiveSubscription: false,
|
||||
billingType: 'legacy' as 'legacy' | 'workspace'
|
||||
}
|
||||
const fakeRef = <K extends keyof typeof state>(key: K) => ({
|
||||
get value() {
|
||||
return state[key]
|
||||
}
|
||||
})
|
||||
return { state, fakeRef }
|
||||
const testI18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (_: string, fallback: string) => fallback })
|
||||
function runSettingUI(...args: Parameters<typeof useSettingUI>) {
|
||||
let result!: ReturnType<typeof useSettingUI>
|
||||
render(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = useSettingUI(...args)
|
||||
return {}
|
||||
},
|
||||
template: '<div />'
|
||||
}),
|
||||
{ global: { plugins: [testI18n] } }
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
const { auth, billing, dist, featureFlags, vueFlags } = vi.hoisted(() => ({
|
||||
auth: { isLoggedIn: { value: false } },
|
||||
billing: {
|
||||
isActiveSubscription: { value: false },
|
||||
type: { value: 'legacy' as 'legacy' | 'workspace' }
|
||||
},
|
||||
dist: { isCloud: false, isDesktop: false },
|
||||
featureFlags: { teamWorkspacesEnabled: false, userSecretsEnabled: false },
|
||||
vueFlags: { shouldRenderVueNodes: { value: false } }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ isLoggedIn: env.fakeRef('isLoggedIn') })
|
||||
useCurrentUser: () => ({ isLoggedIn: auth.isLoggedIn })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: env.fakeRef('isActiveSubscription'),
|
||||
type: env.fakeRef('billingType')
|
||||
isActiveSubscription: billing.isActiveSubscription,
|
||||
type: billing.type
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return env.state.teamWorkspacesEnabled
|
||||
},
|
||||
get userSecretsEnabled() {
|
||||
return env.state.userSecretsEnabled
|
||||
}
|
||||
}
|
||||
flags: featureFlags
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useVueFeatureFlags', () => ({
|
||||
useVueFeatureFlags: () => ({ shouldRenderVueNodes: ref(false) })
|
||||
useVueFeatureFlags: () => ({
|
||||
shouldRenderVueNodes: vueFlags.shouldRenderVueNodes
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return env.state.isCloud
|
||||
return dist.isCloud
|
||||
},
|
||||
get isDesktop() {
|
||||
return env.state.isDesktop
|
||||
return dist.isDesktop
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -75,16 +83,10 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
getSettingInfo: vi.fn()
|
||||
}))
|
||||
|
||||
interface MockSettingParams {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
defaultValue: unknown
|
||||
category?: string[]
|
||||
}
|
||||
type SettingFixture = Omit<SettingParams, 'id'> & { id: string }
|
||||
|
||||
describe('useSettingUI', () => {
|
||||
const mockSettings: Record<string, MockSettingParams> = {
|
||||
const mockSettings: Record<string, SettingFixture> = {
|
||||
'Comfy.Locale': {
|
||||
id: 'Comfy.Locale',
|
||||
name: 'Locale',
|
||||
@@ -104,23 +106,24 @@ describe('useSettingUI', () => {
|
||||
defaultValue: 'dark'
|
||||
}
|
||||
}
|
||||
let settingsById: Record<string, SettingFixture>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
vi.clearAllMocks()
|
||||
auth.isLoggedIn.value = false
|
||||
billing.isActiveSubscription.value = false
|
||||
billing.type.value = 'legacy'
|
||||
dist.isCloud = false
|
||||
dist.isDesktop = false
|
||||
featureFlags.teamWorkspacesEnabled = false
|
||||
featureFlags.userSecretsEnabled = false
|
||||
vueFlags.shouldRenderVueNodes.value = false
|
||||
Object.assign(window, { __CONFIG__: {} })
|
||||
|
||||
Object.assign(env.state, {
|
||||
isCloud: false,
|
||||
isDesktop: false,
|
||||
isLoggedIn: false,
|
||||
teamWorkspacesEnabled: false,
|
||||
userSecretsEnabled: false,
|
||||
isActiveSubscription: false,
|
||||
billingType: 'legacy'
|
||||
})
|
||||
|
||||
settingsById = mockSettings
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
settingsById: mockSettings
|
||||
settingsById
|
||||
} as ReturnType<typeof useSettingStore>)
|
||||
|
||||
vi.mocked(getSettingInfo).mockImplementation((setting) => {
|
||||
@@ -140,22 +143,22 @@ describe('useSettingUI', () => {
|
||||
}
|
||||
|
||||
it('defaults to first category when no params are given', () => {
|
||||
const { defaultCategory, settingCategories } = useSettingUI()
|
||||
const { defaultCategory, settingCategories } = runSettingUI()
|
||||
expect(defaultCategory.value).toBe(settingCategories.value[0])
|
||||
})
|
||||
|
||||
it('resolves category from scrollToSettingId', () => {
|
||||
const { defaultCategory, settingCategories } = useSettingUI(
|
||||
const { defaultCategory, settingCategories } = runSettingUI(
|
||||
undefined,
|
||||
'Comfy.Locale'
|
||||
)
|
||||
const comfyCategory = findCategory(settingCategories.value, 'Comfy')
|
||||
expect(comfyCategory).toBeDefined()
|
||||
expect(defaultCategory.value).toBe(comfyCategory)
|
||||
expect(defaultCategory.value).toBe(
|
||||
findCategory(settingCategories.value, 'Comfy')
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves different category from scrollToSettingId', () => {
|
||||
const { defaultCategory, settingCategories } = useSettingUI(
|
||||
const { defaultCategory, settingCategories } = runSettingUI(
|
||||
undefined,
|
||||
'Appearance.Theme'
|
||||
)
|
||||
@@ -163,12 +166,11 @@ describe('useSettingUI', () => {
|
||||
settingCategories.value,
|
||||
'Appearance'
|
||||
)
|
||||
expect(appearanceCategory).toBeDefined()
|
||||
expect(defaultCategory.value).toBe(appearanceCategory)
|
||||
})
|
||||
|
||||
it('falls back to first category for unknown scrollToSettingId', () => {
|
||||
const { defaultCategory, settingCategories } = useSettingUI(
|
||||
const { defaultCategory, settingCategories } = runSettingUI(
|
||||
undefined,
|
||||
'NonExistent.Setting'
|
||||
)
|
||||
@@ -176,29 +178,210 @@ describe('useSettingUI', () => {
|
||||
})
|
||||
|
||||
it('gives defaultPanel precedence over scrollToSettingId', () => {
|
||||
const { defaultCategory } = useSettingUI('about', 'Comfy.Locale')
|
||||
const { defaultCategory } = runSettingUI('about', 'Comfy.Locale')
|
||||
expect(defaultCategory.value.key).toBe('about')
|
||||
})
|
||||
|
||||
it('falls back when defaultPanel is not in the menu', () => {
|
||||
const { defaultCategory, settingCategories } = runSettingUI('subscription')
|
||||
expect(defaultCategory.value).toBe(settingCategories.value[0])
|
||||
})
|
||||
|
||||
it('moves floating settings into Other and hides Vue-node-only settings', () => {
|
||||
settingsById = {
|
||||
Floating: {
|
||||
id: 'Floating',
|
||||
name: 'Floating',
|
||||
type: 'boolean',
|
||||
defaultValue: false
|
||||
},
|
||||
'Hidden.Setting': {
|
||||
id: 'Hidden.Setting',
|
||||
name: 'Hidden',
|
||||
type: 'hidden',
|
||||
defaultValue: false
|
||||
},
|
||||
'Vue.Hidden': {
|
||||
id: 'Vue.Hidden',
|
||||
name: 'Vue Hidden',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
hideInVueNodes: true
|
||||
}
|
||||
}
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
settingsById
|
||||
} as ReturnType<typeof useSettingStore>)
|
||||
vueFlags.shouldRenderVueNodes.value = true
|
||||
|
||||
const { settingCategories } = runSettingUI()
|
||||
|
||||
expect(settingCategories.value.map((category) => category.label)).toEqual([
|
||||
'Other'
|
||||
])
|
||||
expect(
|
||||
settingCategories.value[0].children?.map((node) => node.key)
|
||||
).toEqual(['root/Floating'])
|
||||
})
|
||||
|
||||
it('adds gated cloud, desktop, workspace, and secrets panels', () => {
|
||||
auth.isLoggedIn.value = true
|
||||
billing.isActiveSubscription.value = true
|
||||
dist.isCloud = true
|
||||
dist.isDesktop = true
|
||||
featureFlags.teamWorkspacesEnabled = true
|
||||
featureFlags.userSecretsEnabled = true
|
||||
Object.assign(window, { __CONFIG__: { subscription_required: true } })
|
||||
|
||||
const { findCategoryByKey, findPanelByKey, navGroups, panels } =
|
||||
runSettingUI()
|
||||
|
||||
expect(panels.value.map((panel) => panel.node.key)).toEqual([
|
||||
'about',
|
||||
'credits',
|
||||
'user',
|
||||
'workspace',
|
||||
'keybinding',
|
||||
'extension',
|
||||
'server-config',
|
||||
'subscription',
|
||||
'secrets'
|
||||
])
|
||||
expect(navGroups.value.map((group) => group.title)).toEqual([
|
||||
'Workspace',
|
||||
'General'
|
||||
])
|
||||
expect(findCategoryByKey('secrets')?.key).toBe('secrets')
|
||||
expect(findCategoryByKey('missing')).toBeNull()
|
||||
expect(findPanelByKey('subscription')?.node.key).toBe('subscription')
|
||||
expect(findPanelByKey('missing')).toBeNull()
|
||||
})
|
||||
|
||||
it('builds the legacy account menu from auth and subscription gates', () => {
|
||||
auth.isLoggedIn.value = true
|
||||
billing.isActiveSubscription.value = true
|
||||
dist.isCloud = true
|
||||
featureFlags.userSecretsEnabled = true
|
||||
Object.assign(window, { __CONFIG__: { subscription_required: true } })
|
||||
|
||||
const { navGroups, panels } = runSettingUI()
|
||||
|
||||
expect(panels.value.map((panel) => panel.node.key)).toEqual([
|
||||
'about',
|
||||
'credits',
|
||||
'user',
|
||||
'keybinding',
|
||||
'extension',
|
||||
'subscription',
|
||||
'secrets'
|
||||
])
|
||||
expect(navGroups.value[0]).toEqual({
|
||||
title: 'Account',
|
||||
items: [
|
||||
{
|
||||
id: 'user',
|
||||
label: 'User',
|
||||
icon: 'icon-[lucide--user]'
|
||||
},
|
||||
{
|
||||
id: 'subscription',
|
||||
label: 'PlanCredits',
|
||||
icon: 'icon-[lucide--credit-card]'
|
||||
},
|
||||
{
|
||||
id: 'secrets',
|
||||
label: 'Secrets',
|
||||
icon: 'icon-[lucide--key-round]'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('includes credits in legacy account settings when login is not subscription-gated', () => {
|
||||
auth.isLoggedIn.value = true
|
||||
dist.isCloud = true
|
||||
|
||||
const { navGroups } = runSettingUI()
|
||||
|
||||
expect(navGroups.value[0].items.map((item) => item.id)).toEqual([
|
||||
'user',
|
||||
'credits'
|
||||
])
|
||||
})
|
||||
|
||||
it('builds workspace menus without optional children when gates are closed', () => {
|
||||
dist.isCloud = true
|
||||
featureFlags.teamWorkspacesEnabled = true
|
||||
|
||||
const { navGroups, panels } = runSettingUI()
|
||||
|
||||
expect(panels.value.map((panel) => panel.node.key)).toEqual([
|
||||
'about',
|
||||
'credits',
|
||||
'user',
|
||||
'keybinding',
|
||||
'extension'
|
||||
])
|
||||
expect(navGroups.value.map((group) => group.title)).toEqual([
|
||||
'Workspace',
|
||||
'General'
|
||||
])
|
||||
expect(navGroups.value[0].items).toEqual([])
|
||||
})
|
||||
|
||||
it('uses label and fallback icons for custom categories', () => {
|
||||
settingsById = {
|
||||
'Acme.Tools.Toggle': {
|
||||
id: 'Acme.Tools.Toggle',
|
||||
name: 'Toggle',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
category: ['Acme Tools', 'Toggles']
|
||||
},
|
||||
PlanSetting: {
|
||||
id: 'PlanSetting',
|
||||
name: 'Plan Setting',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
category: ['PlanCredits', 'Credits']
|
||||
}
|
||||
}
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
settingsById
|
||||
} as ReturnType<typeof useSettingStore>)
|
||||
|
||||
const { navGroups } = runSettingUI()
|
||||
const settingsItems = navGroups.value[1].items
|
||||
|
||||
expect(settingsItems).toEqual([
|
||||
{
|
||||
id: 'root/Acme Tools',
|
||||
label: 'Acme Tools',
|
||||
icon: 'icon-[lucide--plug]'
|
||||
},
|
||||
{
|
||||
id: 'root/PlanCredits',
|
||||
label: 'PlanCredits',
|
||||
icon: 'icon-[lucide--credit-card]'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
describe('legacy billing in the workspace layout', () => {
|
||||
const navKeys = (groups: { items: { id: string }[] }[]) =>
|
||||
groups.flatMap((group) => group.items.map((item) => item.id))
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(env.state, {
|
||||
isCloud: true,
|
||||
isLoggedIn: true,
|
||||
teamWorkspacesEnabled: true,
|
||||
isActiveSubscription: true
|
||||
})
|
||||
window.__CONFIG__ = {
|
||||
subscription_required: true
|
||||
} as typeof window.__CONFIG__
|
||||
auth.isLoggedIn.value = true
|
||||
billing.isActiveSubscription.value = true
|
||||
dist.isCloud = true
|
||||
featureFlags.teamWorkspacesEnabled = true
|
||||
Object.assign(window, { __CONFIG__: { subscription_required: true } })
|
||||
})
|
||||
|
||||
it('exposes the legacy plan panel when billing is legacy', () => {
|
||||
env.state.billingType = 'legacy'
|
||||
const { defaultCategory, navGroups } = useSettingUI('subscription')
|
||||
billing.type.value = 'legacy'
|
||||
const { defaultCategory, navGroups } = runSettingUI('subscription')
|
||||
|
||||
expect(defaultCategory.value.key).toBe('subscription')
|
||||
expect(navKeys(navGroups.value)).toContain('subscription')
|
||||
@@ -206,8 +389,8 @@ describe('useSettingUI', () => {
|
||||
})
|
||||
|
||||
it('hides the legacy plan panel when billing is workspace', () => {
|
||||
env.state.billingType = 'workspace'
|
||||
const { navGroups } = useSettingUI()
|
||||
billing.type.value = 'workspace'
|
||||
const { navGroups } = runSettingUI()
|
||||
|
||||
expect(navKeys(navGroups.value)).not.toContain('subscription')
|
||||
expect(navKeys(navGroups.value)).toContain('workspace')
|
||||
@@ -215,7 +398,7 @@ describe('useSettingUI', () => {
|
||||
|
||||
it('never renders the plan panel in more than one tab', () => {
|
||||
const countSubscription = () => {
|
||||
const { navGroups } = useSettingUI()
|
||||
const { navGroups } = runSettingUI()
|
||||
return navKeys(navGroups.value).filter((id) => id === 'subscription')
|
||||
.length
|
||||
}
|
||||
@@ -223,11 +406,9 @@ describe('useSettingUI', () => {
|
||||
for (const teamWorkspacesEnabled of [true, false]) {
|
||||
for (const billingType of ['legacy', 'workspace'] as const) {
|
||||
for (const isLoggedIn of [true, false]) {
|
||||
Object.assign(env.state, {
|
||||
teamWorkspacesEnabled,
|
||||
billingType,
|
||||
isLoggedIn
|
||||
})
|
||||
featureFlags.teamWorkspacesEnabled = teamWorkspacesEnabled
|
||||
billing.type.value = billingType
|
||||
auth.isLoggedIn.value = isLoggedIn
|
||||
expect(countSubscription()).toBeLessThanOrEqual(1)
|
||||
}
|
||||
}
|
||||
|
||||
204
src/platform/settings/constants/coreSettings.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
import type { Keybinding } from '@/platform/keybindings/types'
|
||||
|
||||
const mockSettingStore = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
setMany: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false,
|
||||
isDesktop: false,
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
vi.mock('@/locales/localeConfig', () => ({
|
||||
getDefaultLocale: () => 'en',
|
||||
SUPPORTED_LOCALE_OPTIONS: [{ value: 'en', text: 'English' }]
|
||||
}))
|
||||
|
||||
function setting<T = unknown>(id: string): SettingParams<T> {
|
||||
const result = CORE_SETTINGS.find((item) => item.id === id)
|
||||
if (!result) throw new Error(`Missing setting ${id}`)
|
||||
return result as SettingParams<T>
|
||||
}
|
||||
|
||||
describe('CORE_SETTINGS', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.className = ''
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('uses compact sidebar size below the wide breakpoint', () => {
|
||||
vi.stubGlobal('innerWidth', 1200)
|
||||
|
||||
const defaultValue = setting('Comfy.Sidebar.Size').defaultValue
|
||||
|
||||
expect(typeof defaultValue).toBe('function')
|
||||
expect((defaultValue as () => string)()).toBe('small')
|
||||
})
|
||||
|
||||
it('uses normal sidebar size above the wide breakpoint', () => {
|
||||
vi.stubGlobal('innerWidth', 1600)
|
||||
|
||||
const defaultValue = setting('Comfy.Sidebar.Size').defaultValue
|
||||
|
||||
expect((defaultValue as () => string)()).toBe('normal')
|
||||
})
|
||||
|
||||
it('updates dependent canvas settings when navigation mode changes', async () => {
|
||||
const navigation = setting<string>('Comfy.Canvas.NavigationMode')
|
||||
|
||||
await navigation.onChange?.('standard', 'legacy')
|
||||
expect(mockSettingStore.setMany).toHaveBeenLastCalledWith({
|
||||
'Comfy.Canvas.LeftMouseClickBehavior': 'select',
|
||||
'Comfy.Canvas.MouseWheelScroll': 'panning'
|
||||
})
|
||||
|
||||
await navigation.onChange?.('legacy', 'standard')
|
||||
expect(mockSettingStore.setMany).toHaveBeenLastCalledWith({
|
||||
'Comfy.Canvas.LeftMouseClickBehavior': 'panning',
|
||||
'Comfy.Canvas.MouseWheelScroll': 'zoom'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not update dependent canvas settings on initial navigation setup', async () => {
|
||||
await setting<string>('Comfy.Canvas.NavigationMode').onChange?.('standard')
|
||||
|
||||
expect(mockSettingStore.setMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps preset navigation mode when left-click behavior still matches it', async () => {
|
||||
mockSettingStore.get.mockReturnValue('standard')
|
||||
|
||||
await setting<string>('Comfy.Canvas.LeftMouseClickBehavior').onChange?.(
|
||||
'select'
|
||||
)
|
||||
|
||||
expect(mockSettingStore.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks navigation mode custom when left-click behavior diverges from the preset', async () => {
|
||||
mockSettingStore.get.mockReturnValue('standard')
|
||||
|
||||
await setting<string>('Comfy.Canvas.LeftMouseClickBehavior').onChange?.(
|
||||
'panning'
|
||||
)
|
||||
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.NavigationMode',
|
||||
'custom'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not rewrite custom navigation mode from left-click behavior', async () => {
|
||||
mockSettingStore.get.mockReturnValue('custom')
|
||||
|
||||
await setting<string>('Comfy.Canvas.LeftMouseClickBehavior').onChange?.(
|
||||
'select'
|
||||
)
|
||||
|
||||
expect(mockSettingStore.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps preset navigation mode when wheel behavior still matches it', async () => {
|
||||
mockSettingStore.get.mockReturnValue('legacy')
|
||||
|
||||
await setting<string>('Comfy.Canvas.MouseWheelScroll').onChange?.('zoom')
|
||||
|
||||
expect(mockSettingStore.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks navigation mode custom when wheel behavior diverges from the preset', async () => {
|
||||
mockSettingStore.get.mockReturnValue('legacy')
|
||||
|
||||
await setting<string>('Comfy.Canvas.MouseWheelScroll').onChange?.('panning')
|
||||
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.NavigationMode',
|
||||
'custom'
|
||||
)
|
||||
})
|
||||
|
||||
it('toggles the dev-mode API save button when present', () => {
|
||||
const button = document.createElement('button')
|
||||
button.id = 'comfy-dev-save-api-button'
|
||||
document.body.append(button)
|
||||
|
||||
const devMode = setting<boolean>('Comfy.DevMode')
|
||||
devMode.onChange?.(true)
|
||||
expect(button.style.display).toBe('flex')
|
||||
|
||||
devMode.onChange?.(false)
|
||||
expect(button.style.display).toBe('none')
|
||||
})
|
||||
|
||||
it('ignores the dev-mode button handler when the element is absent', () => {
|
||||
expect(() =>
|
||||
setting<boolean>('Comfy.DevMode').onChange?.(true)
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('toggles the disabled animations body class', () => {
|
||||
const animations = setting<boolean>('Comfy.Appearance.DisableAnimations')
|
||||
|
||||
animations.onChange?.(true)
|
||||
expect(document.body.classList.contains('disable-animations')).toBe(true)
|
||||
|
||||
animations.onChange?.(false)
|
||||
expect(document.body.classList.contains('disable-animations')).toBe(false)
|
||||
})
|
||||
|
||||
it('migrates deprecated menu and workflow tab values', () => {
|
||||
expect(
|
||||
setting<string>('Comfy.UseNewMenu').migrateDeprecatedValue?.('Floating')
|
||||
).toBe('Top')
|
||||
expect(
|
||||
setting<string>('Comfy.UseNewMenu').migrateDeprecatedValue?.('Bottom')
|
||||
).toBe('Top')
|
||||
expect(
|
||||
setting<string>('Comfy.UseNewMenu').migrateDeprecatedValue?.('Top')
|
||||
).toBe('Top')
|
||||
expect(
|
||||
setting<string>(
|
||||
'Comfy.Workflow.WorkflowTabsPosition'
|
||||
).migrateDeprecatedValue?.('Topbar (2nd-row)')
|
||||
).toBe('Topbar')
|
||||
})
|
||||
|
||||
it('migrates graph-canvas keybinding target selectors', () => {
|
||||
const bindings = [
|
||||
{
|
||||
combo: { key: 'a' },
|
||||
commandId: 'test.command',
|
||||
targetSelector: '#graph-canvas'
|
||||
},
|
||||
{
|
||||
combo: { key: 'b' },
|
||||
commandId: 'other.command',
|
||||
targetSelector: '#other'
|
||||
}
|
||||
]
|
||||
|
||||
const migrated =
|
||||
setting<Keybinding[]>(
|
||||
'Comfy.Keybinding.UnsetBindings'
|
||||
).migrateDeprecatedValue?.(bindings) ?? []
|
||||
|
||||
expect(migrated[0].targetElementId).toBe('graph-canvas-container')
|
||||
expect(migrated[1].targetElementId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1207,18 +1207,6 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
type: 'hidden',
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
id: 'Comfy.VueNodes.AutoScaleLayout',
|
||||
category: ['Comfy', 'Nodes 2.0', 'AutoScaleLayout'],
|
||||
name: 'Auto-scale layout (Nodes 2.0)',
|
||||
tooltip:
|
||||
'Automatically scale node positions when switching to Nodes 2.0 rendering to prevent overlap',
|
||||
type: 'boolean',
|
||||
sortOrder: 50,
|
||||
experimental: true,
|
||||
defaultValue: true,
|
||||
versionAdded: '1.30.3'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Assets.UseAssetAPI',
|
||||
name: 'Use Asset API for model library',
|
||||
|
||||
@@ -78,4 +78,43 @@ describe('TelemetryRegistry', () => {
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('dispatches subscription cancellation telemetry to every registered provider', () => {
|
||||
const a: TelemetryProvider = { trackSubscriptionCancellation: vi.fn() }
|
||||
const b: TelemetryProvider = { trackSubscriptionCancellation: vi.fn() }
|
||||
const registry = new TelemetryRegistry()
|
||||
registry.registerProvider(a)
|
||||
registry.registerProvider(b)
|
||||
|
||||
const payload = {
|
||||
source: 'cancel_plan_menu' as const,
|
||||
current_tier: 'standard',
|
||||
cycle: 'monthly' as const,
|
||||
end_date: '2026-08-01T00:00:00.000Z'
|
||||
}
|
||||
registry.trackSubscriptionCancellation('flow_opened', payload)
|
||||
|
||||
expect(a.trackSubscriptionCancellation).toHaveBeenCalledExactlyOnceWith(
|
||||
'flow_opened',
|
||||
payload
|
||||
)
|
||||
expect(b.trackSubscriptionCancellation).toHaveBeenCalledExactlyOnceWith(
|
||||
'flow_opened',
|
||||
payload
|
||||
)
|
||||
})
|
||||
|
||||
it('dispatches resubscribe click telemetry to every registered provider', () => {
|
||||
const a: TelemetryProvider = { trackResubscribeClicked: vi.fn() }
|
||||
const b: TelemetryProvider = { trackResubscribeClicked: vi.fn() }
|
||||
const registry = new TelemetryRegistry()
|
||||
registry.registerProvider(a)
|
||||
registry.registerProvider(b)
|
||||
|
||||
const payload = { source: 'settings_billing_panel' as const }
|
||||
registry.trackResubscribeClicked(payload)
|
||||
|
||||
expect(a.trackResubscribeClicked).toHaveBeenCalledExactlyOnceWith(payload)
|
||||
expect(b.trackResubscribeClicked).toHaveBeenCalledExactlyOnceWith(payload)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,10 +19,12 @@ import type {
|
||||
SearchQueryMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
ResubscribeClickMetadata,
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
SharedWorkflowRunMetadata,
|
||||
ShellLayoutMetadata,
|
||||
SubscriptionCancellationMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
@@ -100,6 +102,19 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackMonthlySubscriptionCancelled?.())
|
||||
}
|
||||
|
||||
trackSubscriptionCancellation(
|
||||
event: 'flow_opened' | 'confirmed' | 'abandoned' | 'failed',
|
||||
metadata?: SubscriptionCancellationMetadata
|
||||
): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackSubscriptionCancellation?.(event, metadata)
|
||||
)
|
||||
}
|
||||
|
||||
trackResubscribeClicked(metadata: ResubscribeClickMetadata): void {
|
||||
this.dispatch((provider) => provider.trackResubscribeClicked?.(metadata))
|
||||
}
|
||||
|
||||
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackAddApiCreditButtonClicked?.(metadata)
|
||||
|
||||
@@ -27,6 +27,7 @@ export async function initTelemetry(): Promise<void> {
|
||||
{ ImpactTelemetryProvider },
|
||||
{ PostHogTelemetryProvider },
|
||||
{ ClickHouseTelemetryProvider },
|
||||
{ SyftTelemetryProvider },
|
||||
{ CustomerIoTelemetryProvider }
|
||||
] = await Promise.all([
|
||||
import('./TelemetryRegistry'),
|
||||
@@ -35,6 +36,7 @@ export async function initTelemetry(): Promise<void> {
|
||||
import('./providers/cloud/ImpactTelemetryProvider'),
|
||||
import('./providers/cloud/PostHogTelemetryProvider'),
|
||||
import('./providers/cloud/ClickHouseTelemetryProvider'),
|
||||
import('./providers/cloud/SyftTelemetryProvider'),
|
||||
import('./providers/cloud/CustomerIoTelemetryProvider')
|
||||
])
|
||||
|
||||
@@ -44,6 +46,7 @@ export async function initTelemetry(): Promise<void> {
|
||||
registry.registerProvider(new ImpactTelemetryProvider())
|
||||
registry.registerProvider(new PostHogTelemetryProvider())
|
||||
registry.registerProvider(new ClickHouseTelemetryProvider())
|
||||
registry.registerProvider(new SyftTelemetryProvider())
|
||||
registry.registerProvider(new CustomerIoTelemetryProvider())
|
||||
|
||||
setTelemetryRegistry(registry)
|
||||
|
||||