Compare commits

...

19 Commits

Author SHA1 Message Date
Comfy Org PR Bot
5f69e80c8f [backport cloud/1.46] fix: skip templates modal when opening a template from the URL (#13031)
Backport of #12835 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: Maanil Verma <vermaMaanil97@gmail.com>
2026-06-19 16:21:22 -07:00
Comfy Org PR Bot
2d68eb0355 [backport cloud/1.46] fix groups dragging children with control held (#13033)
Backport of #12867 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-06-19 16:20:53 -07:00
Comfy Org PR Bot
5581e188f0 [backport cloud/1.46] fix: remove unused export from ExportFormatOption interface (#12979)
Backport of #12973 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-06-19 14:52:39 -07:00
Comfy Org PR Bot
0c8936e1d3 [backport cloud/1.46] feat(workspace): switcher popover left of profile menu + DES-246 copy (FE-769) (#12997)
Backport of #12763 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-19 14:52:08 -07:00
Comfy Org PR Bot
3f71d546d6 [backport cloud/1.46] feat(billing): role-aware run-lock for cancelled/inactive team plans (FE-978) (#13006)
Backport of #12786 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-19 14:51:55 -07:00
Comfy Org PR Bot
38e77164e3 [backport cloud/1.46] fix(billing): refresh workspace billing status after completed top-up (FE-932) (#13007)
Backport of #12787 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-19 14:51:43 -07:00
Comfy Org PR Bot
93e0821a31 [backport cloud/1.46] refactor(billing): unify cancel-status polling into billingOperationStore (B8 / FE-970) (#13008)
Backport of #12788 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-19 14:51:30 -07:00
Comfy Org PR Bot
61caac9500 [backport cloud/1.46] fix(settings): widen the Settings dialog to 1280 (#13009)
Backport of #12849 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-19 14:51:17 -07:00
Comfy Org PR Bot
7337656b2a [backport cloud/1.46] fix: re-disable ultralytics asset-picker registration (#13023)
Backport of #13020 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
2026-06-19 14:50:46 -07:00
Christian Byrne
a3bbcfbe57 [backport cloud/1.46] Update default workflow (#12969)
Backport of #12804 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-18 16:46:50 -07:00
Comfy Org PR Bot
4d6cd552f4 [backport cloud/1.46] fix(cloud): stop bouncing working users to /cloud/survey mid-session (FE-739) (#12965)
Backport of #12621 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-19 08:36:05 +09:00
Comfy Org PR Bot
e2f39317c4 [backport cloud/1.46] Fix 'insert as node' in sidebar tab (#12963)
Backport of #12900 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-06-18 14:06:50 -07:00
Comfy Org PR Bot
e73136f039 [backport cloud/1.46] test: harden assets media-type filter spec against VirtualGrid flake (#12938)
Backport of #12897 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-06-18 13:45:17 -07:00
Comfy Org PR Bot
6e6ed8653f [backport cloud/1.46] feat: implement customer.io SDK & telemetry provider (#12920)
Backport of #12878 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-06-17 14:00:24 -07:00
Comfy Org PR Bot
384b29d72d [backport cloud/1.46] fix: encode large copy payload metadata in chunks (#12913)
Backport of #12847 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-17 17:02:37 +09:00
Comfy Org PR Bot
3a4f2d1440 [backport cloud/1.46] Fix undated failed runs in job history grouping (#12906)
Backport of #12879 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-17 16:03:52 +09:00
Comfy Org PR Bot
16169def51 [backport cloud/1.46] fix: bind replacement node widgets to reused id (#12909)
Backport of #12872 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-17 16:03:35 +09:00
Comfy Org PR Bot
af771a45d0 [backport cloud/1.46] feat: Load3DAdvanced uploads to input/3d (#12874)
Backport of #12851 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-06-16 08:21:37 -04:00
Comfy Org PR Bot
cb4c3b833b [backport cloud/1.46] Simplify missing model error presentation (#12853)
Backport of #12793 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-15 22:16:58 +09:00
91 changed files with 5200 additions and 2477 deletions

View File

@@ -29,3 +29,5 @@ runs:
if: ${{ inputs.include_build_step == 'true' }}
shell: bash
run: pnpm build
env:
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'

View File

@@ -109,3 +109,27 @@ jobs:
exit 1
fi
echo '✅ No PostHog references found'
- name: Scan dist for Customer.io telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for Customer.io references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e 'CustomerIoTelemetryProvider' \
-e '@customerio/cdp-analytics-browser' \
-e 'customerio-gist-web' \
-e '(?i)cdp\.customer\.io' \
-e 'Comfy\.CustomerIo' \
dist; then
echo '❌ ERROR: Customer.io references found in dist assets!'
echo 'Customer.io 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 Customer.io references found'

View File

@@ -47,6 +47,8 @@ jobs:
- name: Build cloud frontend
run: pnpm build:cloud
env:
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'
- name: Upload cloud frontend
uses: actions/upload-artifact@v6

View File

@@ -0,0 +1,60 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [100, 100],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["cloud_importable_model.safetensors"]
},
{
"id": 2,
"type": "LoadImage",
"pos": [560, 100],
"size": [400, 314],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": null },
{ "name": "MASK", "type": "MASK", "links": null }
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["cloud_unknown_model.safetensors", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "cloud_importable_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -60,14 +60,16 @@ export const TestIds = {
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model',
missingModelExpand: 'missing-model-expand',
missingModelImport: 'missing-model-import',
missingModelImportableRows: 'missing-model-importable-rows',
missingModelLocate: 'missing-model-locate',
missingModelCopyName: 'missing-model-copy-name',
missingModelCopyUrl: 'missing-model-copy-url',
missingModelReferenceCount: 'missing-model-reference-count',
missingModelUnsupportedSection:
'missing-model-import-not-supported-section',
missingModelDownload: 'missing-model-download',
missingModelActions: 'missing-model-actions',
missingModelDownloadAll: 'missing-model-download-all',
missingModelRefresh: 'missing-model-refresh',
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingModelRefresh: 'missing-model-header-refresh',
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
swapNodeGroupCount: 'swap-node-group-count',

View File

@@ -0,0 +1,45 @@
import type { Page } from '@playwright/test'
function flagAttributeFor(testId: string) {
const encoded = Array.from(testId, (ch) =>
ch.charCodeAt(0).toString(16)
).join('')
return `data-flashed-${encoded}`
}
/**
* Flags the first time an element matching `[data-testid="<testId>"]` is
* present and rendered, sampled every frame via `requestAnimationFrame` from
* page load. Catches a dialog that mounts and unmounts within a few frames,
* which `toBeHidden()` (final state only) cannot.
*
* Must be called before navigation (e.g. before `comfyPage.setup()`).
*/
export async function trackElementFlash(
page: Page,
testId: string
): Promise<{ hasFlashed: () => Promise<boolean> }> {
const flagAttribute = flagAttributeFor(testId)
await page.addInitScript(
({ id, attribute }: { id: string; attribute: string }) => {
const sample = () => {
const el = document.querySelector(`[data-testid="${CSS.escape(id)}"]`)
if (el instanceof HTMLElement) {
const rect = el.getBoundingClientRect()
if (rect.width > 0 && rect.height > 0) {
document.documentElement.setAttribute(attribute, 'true')
}
}
requestAnimationFrame(sample)
}
requestAnimationFrame(sample)
},
{ id: testId, attribute: flagAttribute }
)
return {
hasFlashed: async () =>
(await page.locator('html').getAttribute(flagAttribute)) === 'true'
}
}

View File

@@ -0,0 +1,135 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
/**
* getSurveyCompletedStatus fails safe: a transient 401 on `/` must not bounce a
* working user to /cloud/survey, while a genuine 404 (survey never submitted)
* must still route a not-completed user there. Drives a raw `page` so the cloud
* app boots against fully mocked endpoints (`comfyPage` would reach the OSS
* devtools backend during setup).
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
function jsonRoute(body: unknown) {
return {
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
}
}
async function mockCloudBoot(page: Page) {
// `/api/features` is the remote-config source: production builds resolve
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
// dev-only). Enable the survey so the gate is actually live.
await page.route('**/api/features', (r) =>
r.fulfill(
jsonRoute({ onboarding_survey_enabled: true } satisfies RemoteConfig)
)
)
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// Cloud user status (getUserCloudStatus) — an active account so the gate
// proceeds to the survey check instead of bouncing back to login.
await page.route('**/api/user', (r) =>
r.fulfill(jsonRoute({ status: 'active' }))
)
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
}
// Genuine "not completed": the cloud backend returns 404 for a survey key that
// was never stored. This is the response that must still route to the survey.
async function mockSurveyNotCompleted(page: Page) {
await page.route('**/api/settings/onboarding_survey', (r) =>
r.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ code: 'NOT_FOUND', message: 'Setting not found' })
})
)
}
// Transient auth failure: a stale workspace token makes the authenticated
// survey check 401 — the hiccup that used to bounce working users.
async function mockSurveyTransient401(page: Page) {
await page.route('**/api/settings/onboarding_survey', (r) =>
r.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({
code: 'UNAUTHORIZED',
message: 'User authentication required'
})
})
)
}
async function bootCloud(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
// Pre-select the mock user to skip the user-select screen.
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
}
test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
test('a transient 401 on the survey check does not bounce a working user to the survey', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
await mockSurveyTransient401(page)
await bootCloud(page)
await page.goto(APP_URL)
// The full app boots — CloudSurveyView is a standalone onboarding view, so
// reaching the extension manager proves we landed on the working app and
// the transient 401 was treated as "completed", not a bounce.
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await expect(page).not.toHaveURL(/\/cloud\/survey/)
})
test('a not-completed (404) user landing on / is routed to the survey', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
await mockSurveyNotCompleted(page)
await bootCloud(page)
await page.goto(APP_URL)
await expect(page).toHaveURL(/\/cloud\/survey/, { timeout: 45_000 })
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -131,6 +131,14 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
'normal',
1
])
if (mode.vueNodesEnabled) {
await expect(
comfyPage.vueNodes
.getWidgetByName('KSampler', 'denoise')
.locator('input')
).toHaveValue(/^1(?:\.0+)?$/)
}
})
test('Success toast is shown after replacement', async ({

View File

@@ -1,16 +1,29 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { Asset } from '@comfyorg/ingest-types'
import type {
Asset,
AssetCreated,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import {
countAssetRequestsByTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
const WORKFLOW = 'missing/nested_subgraph_installed_model'
const IMPORT_SECTIONS_WORKFLOW = 'missing/cloud_missing_model_import_sections'
const OUTER_SUBGRAPH_NODE_ID = '205'
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
const CLOUD_IMPORTABLE_MODEL_NAME = 'cloud_importable_model.safetensors'
const CLOUD_UNKNOWN_MODEL_NAME = 'cloud_unknown_model.safetensors'
const CLOUD_IMPORTED_CANONICAL_MODEL_NAME =
'models/checkpoints/cloud_importable_model.safetensors'
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
id: 'test-lotus-depth-d-v1-1',
@@ -27,13 +40,62 @@ const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
}
}
const EXISTING_CLOUD_IMPORTABLE_MODEL: Asset & { hash?: string } = {
id: 'test-existing-cloud-importable-model',
name: 'asset-record-display-name.safetensors',
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000204',
size: 2_048,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2026-05-05T00:00:00Z',
updated_at: '2026-05-05T00:00:00Z',
last_access_time: '2026-05-05T00:00:00Z',
user_metadata: {
filename: CLOUD_IMPORTED_CANONICAL_MODEL_NAME
}
}
const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
function getRequestedIncludeTags(requestUrl: string): string[] {
return (
new URL(requestUrl).searchParams
.get('include_tags')
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}
function filterAssetsByRequest(
assets: ReadonlyArray<Asset>,
requestUrl: string
): Asset[] {
const includeTags = getRequestedIncludeTags(requestUrl)
return includeTags.length
? assets.filter((asset) =>
includeTags.every((tag) => asset.tags?.includes(tag))
)
: [...assets]
}
async function enableMissingModelImportFeatures(page: Page): Promise<void> {
await page.evaluate(() => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
model_upload_button_enabled: true,
private_models_enabled: true
}
})
}
test.describe(
'Errors tab - Cloud missing models',
{ tag: ['@cloud', '@vue-nodes'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await enableMissingModelImportFeatures(comfyPage.page)
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
@@ -88,5 +150,216 @@ test.describe(
await expect(errorsTab).toBeHidden()
})
test('separates importable cloud models from unsupported rows', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
const importableRows = missingModelsGroup.getByTestId(
TestIds.dialogs.missingModelImportableRows
)
const unsupportedSection = missingModelsGroup.getByTestId(
TestIds.dialogs.missingModelUnsupportedSection
)
await expect(
importableRows.getByRole('button', {
name: CLOUD_IMPORTABLE_MODEL_NAME,
exact: true
})
).toBeVisible()
await expect(
importableRows.getByTestId(TestIds.dialogs.missingModelImport)
).toBeVisible()
await expect(unsupportedSection).toBeVisible()
await expect(
unsupportedSection.getByText('Import Not Supported')
).toBeVisible()
await expect(
unsupportedSection.getByText(
/Nodes that reference the models below do not support imported models/
)
).toBeVisible()
await expect(
unsupportedSection.getByText(CLOUD_UNKNOWN_MODEL_NAME)
).toBeVisible()
await expect(
unsupportedSection.getByText('Unknown', { exact: true })
).toBeVisible()
await expect(
unsupportedSection.getByRole('button', {
name: 'Load Image',
exact: true
})
).toBeVisible()
await expect(
unsupportedSection.getByTestId(TestIds.dialogs.missingModelImport)
).toHaveCount(0)
})
test('opens cloud import with missing-model replacement context', async ({
comfyPage
}) => {
await comfyPage.modelLibrary.mockModelFolders([
{ name: 'checkpoints', folders: [] }
])
await comfyPage.page.route('**/assets/remote-metadata?**', (route) => {
const response: AssetMetadata = {
content_length: 1024,
final_url:
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors',
content_type: 'application/octet-stream',
filename: 'replacement.safetensors',
tags: ['loras']
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await missingModelsGroup
.getByTestId(TestIds.dialogs.missingModelImport)
.click()
const urlInput = comfyPage.page.locator(
'[data-attr="upload-model-step1-url-input"]'
)
await expect(urlInput).toBeVisible()
await urlInput.fill(
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors'
)
await comfyPage.page
.locator('[data-attr="upload-model-step1-continue-button"]')
.click()
const uploadDialog = comfyPage.page.getByRole('dialog', {
name: /Import a model/
})
await expect(
uploadDialog.getByText(
`This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:`
)
).toBeVisible()
await expect(uploadDialog.getByText('Load Checkpoint')).toBeVisible()
await expect(uploadDialog.getByText('- ckpt_name')).toBeVisible()
await expect(
uploadDialog.getByText(
/Locked to (Checkpoints|checkpoints) for this missing model/
)
).toBeVisible()
})
test('uses the synced asset filename when applying an already imported cloud model', async ({
comfyPage
}) => {
let isImportedAssetAvailable = false
const visibleAssets = () =>
isImportedAssetAvailable
? [LOTUS_DIFFUSION_MODEL, EXISTING_CLOUD_IMPORTABLE_MODEL]
: [LOTUS_DIFFUSION_MODEL]
await comfyPage.modelLibrary.mockModelFolders([
{ name: 'checkpoints', folders: [] }
])
await comfyPage.page.route(/\/api\/assets(?:\?.*)?$/, (route) => {
const assets = filterAssetsByRequest(
visibleAssets(),
route.request().url()
)
const response: ListAssetsResponse = {
assets,
total: assets.length,
has_more: false
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await comfyPage.page.route('**/assets/remote-metadata?**', (route) => {
const response: AssetMetadata = {
content_length: 2048,
final_url:
'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors',
content_type: 'application/octet-stream',
filename: CLOUD_IMPORTABLE_MODEL_NAME,
tags: ['checkpoints']
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await comfyPage.page.route('**/assets/download', (route) => {
isImportedAssetAvailable = true
const response: AssetCreated = {
...EXISTING_CLOUD_IMPORTABLE_MODEL,
created_new: false
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await missingModelsGroup
.getByTestId(TestIds.dialogs.missingModelImport)
.click()
const uploadDialog = comfyPage.page.getByRole('dialog', {
name: /Import a model/
})
const urlInput = uploadDialog.locator(
'[data-attr="upload-model-step1-url-input"]'
)
await urlInput.fill(
'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors'
)
await uploadDialog
.locator('[data-attr="upload-model-step1-continue-button"]')
.click()
await expect(
uploadDialog.getByText(
`This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:`
)
).toBeVisible()
await uploadDialog
.locator('[data-attr="upload-model-step2-confirm-button"]')
.click()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const node = window.app!.graph.getNodeById(1)
return node?.widgets?.find((widget) => widget.name === 'ckpt_name')
?.value
})
)
.toBe(CLOUD_IMPORTED_CANONICAL_MODEL_NAME)
})
}
)

View File

@@ -1,4 +1,5 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -11,6 +12,18 @@ import {
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
return group.getByRole('button', { name: modelName, exact: true })
}
async function expectReferenceBadge(group: Locator, count: number) {
await expect(
group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
).toHaveText(String(count))
}
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
@@ -34,15 +47,14 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
).toHaveText(/\S/)
})
test('Should display model name with referencing node count', async ({
comfyPage
}) => {
test('Should display model name and metadata', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const modelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(modelsGroup).toContainText(/fake_model\.safetensors\s*\(\d+\)/)
await expect(getModelLabel(modelsGroup)).toBeVisible()
await expect(modelsGroup.getByText('checkpoints')).toBeVisible()
})
test('Should expand model row to show referencing nodes', async ({
@@ -53,32 +65,33 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
'missing/missing_models_with_nodes'
)
const locateButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelLocate
const modelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(locateButton.first()).toBeHidden()
const expandButton = comfyPage.page.getByTestId(
const expandButton = modelsGroup.getByTestId(
TestIds.dialogs.missingModelExpand
)
await expect(expandButton.first()).toBeVisible()
await expectReferenceBadge(modelsGroup, 2)
await expandButton.first().click()
await expect(locateButton.first()).toBeVisible()
await expect(
modelsGroup.getByTestId(TestIds.dialogs.missingModelLocate)
).toHaveCount(2)
})
test('Should copy model name to clipboard', async ({ comfyPage }) => {
test('Should copy model URL to clipboard', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await interceptClipboardWrite(comfyPage.page)
const copyButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyName
)
const copyButton = comfyPage.page.getByRole('button', {
name: 'Copy URL'
})
await expect(copyButton.first()).toBeVisible()
await copyButton.first().dispatchEvent('click')
const copiedText = await getClipboardText(comfyPage.page)
expect(copiedText).toContain('fake_model.safetensors')
expect(copiedText).toContain('/api/devtools/')
})
test.describe('OSS-specific', { tag: '@oss' }, () => {
@@ -87,9 +100,9 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
)
const copyUrlButton = comfyPage.page.getByRole('button', {
name: 'Copy URL'
})
await expect(copyUrlButton.first()).toBeVisible()
})
@@ -102,6 +115,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
TestIds.dialogs.missingModelDownload
)
await expect(downloadButton.first()).toBeVisible()
await expect(downloadButton.first()).toHaveText('Download')
})
test('Should render Download all and Refresh actions for one downloadable model', async ({

View File

@@ -1,4 +1,5 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -8,6 +9,18 @@ import {
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
return group.getByRole('button', { name: modelName, exact: true })
}
async function expectReferenceBadge(group: Locator, count: number) {
await expect(
group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
).toHaveText(String(count))
}
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -130,9 +143,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
'missing/missing_models_from_node_properties'
)
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
)
const copyUrlButton = comfyPage.page.getByRole('button', {
name: 'Copy URL'
})
await expect(copyUrlButton.first()).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
@@ -156,9 +169,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toBeVisible()
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
@@ -168,9 +179,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
await comfyPage.canvas.click()
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(2\)/
)
await expectReferenceBadge(missingModelGroup, 2)
})
test('Pasting a bypassed node does not add a new error', async ({
@@ -252,14 +261,17 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(/\(2\)/)
await expectReferenceBadge(missingModelGroup, 2)
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
await node1.click('title')
await expect(missingModelGroup).toContainText(/\(1\)/)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await expect(
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
).toHaveCount(1)
await comfyPage.canvas.click()
await expect(missingModelGroup).toContainText(/\(2\)/)
await expectReferenceBadge(missingModelGroup, 2)
})
})
@@ -384,9 +396,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await comfyPage.page.evaluate((value) => {
const hostNode = window.app!.graph!.getNodeById(2)
@@ -439,9 +449,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
const promotedModelCombo = comfyPage.vueNodes
.getNodeByTitle('Subgraph with Promoted Missing Model')

View File

@@ -15,6 +15,10 @@ import { createMixedMediaJobs } from '@e2e/fixtures/helpers/AssetsHelper'
// fixtures — Playwright runs auto fixtures before the `comfyPage` fixture's
// internal `setup()`, so the page first-loads with mocks already in place.
// See cloud-asset-default.spec.ts for the same pattern.
//
// Use `waitForAssets()` not `waitForAssets(MIXED_JOBS.length)`: VirtualGrid can
// virtualize the 3D card out of the initial render (#11635). Filtering reads the
// full store, so the per-filter count assertions still cover the behavior.
const MIXED_JOBS = createMixedMediaJobs(['images', 'video', 'audio', '3D'])
@@ -113,7 +117,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
@@ -136,7 +140,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')
@@ -153,7 +157,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('video')
@@ -167,7 +171,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('audio')
@@ -179,7 +183,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
test('Selecting only "3D" hides non-3D assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('3d')
@@ -193,7 +197,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')
@@ -211,7 +215,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')

View File

@@ -1105,3 +1105,56 @@ test.describe('Assets sidebar - drag and drop', () => {
await expect.poll(() => fileComboWidget.getValue()).toBe('test.png [temp]')
})
})
test('Insert as node', { tag: '@vue-nodes' }, async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory([
createMockJob({
id: 'job1',
preview_output: {
filename: `1.png`,
type: 'temp',
nodeId: '1',
mediaType: 'images'
}
}),
createMockJob({
id: 'job2',
preview_output: {
filename: `2.png`,
type: 'output',
nodeId: '1',
mediaType: 'images'
}
}),
createMockJob({
id: 'job2',
preview_output: {
filename: `3.png`,
type: 'input',
nodeId: '1',
mediaType: 'images'
}
})
])
const { assetsTab } = comfyPage.menu
await assetsTab.open()
await assetsTab.waitForAssets()
await expect(assetsTab.assetCards).toHaveCount(3)
for (const [index, expectedName] of [
[0, '1.png [temp]'],
[1, '2.png [output]'],
[2, '3.png']
] as const) {
await comfyPage.nodeOps.clearGraph()
await assetsTab.assetCards.nth(index).scrollIntoViewIfNeeded()
await assetsTab.assetCards.nth(index).click({ button: 'right' })
await expect(comfyPage.contextMenu.primeVueMenu).toBeVisible()
await comfyPage.contextMenu.primeVueMenu.getByText('Insert as node').click()
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(1)
const nodes = await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
const fileWidget = await nodes[0].getWidget(0)
await expect.poll(() => fileWidget.getValue()).toBe(expectedName)
}
})

View File

@@ -5,6 +5,7 @@ import type { WorkflowTemplates } from '@/platform/workflow/templates/types/temp
import { getWav } from '@e2e/fixtures/components/AudioPreview'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { trackElementFlash } from '@e2e/fixtures/utils/flashDetector'
async function checkTemplateFileExists(
page: Page,
@@ -505,3 +506,32 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
expect(popup.url()).toEqual(tutorialUrl)
})
})
test.describe(
'Templates deeplink (new user)',
{ tag: ['@slow', '@workflow'] },
() => {
test('templates dialog never flashes when first-time user opens a template link', async ({
comfyPage
}) => {
const templatesFlash = await trackElementFlash(
comfyPage.page,
TestIds.templates.content
)
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
await comfyPage.setup({
clearStorage: true,
url: '/?template=default'
})
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBeGreaterThan(0)
expect(await templatesFlash.hasFlashed()).toBe(false)
await expect(comfyPage.templates.content).toBeHidden()
})
}
)

View File

@@ -177,6 +177,30 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
}).toPass({ timeout: 5000 })
})
test('does not drag contents when control is held', async ({ comfyPage }) => {
await comfyPage.keyboard.selectAll()
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
const groupCount = () => comfyPage.page.evaluate(() => graph!.groups.length)
await expect.poll(groupCount, 'create group').toBe(1)
await comfyPage.page.mouse.click(100, 100)
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const initialNodeBounds = await ksampler.boundingBox()
expect(initialNodeBounds).toBeTruthy()
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
await comfyPage.page.mouse.move(groupPos.x, groupPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.mouse.move(groupPos.x + 100, groupPos.y)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Control')
await expect
.poll(() => getGroupTitlePosition(comfyPage, 'Group'))
.not.toEqual(groupPos)
expect(await ksampler.boundingBox()).toEqual(initialNodeBounds)
})
test('should keep groups aligned after loading legacy Vue workflows', async ({
comfyPage
}) => {

View File

@@ -98,4 +98,43 @@ test.describe('Workspace switcher', { tag: '@cloud' }, () => {
expect(box).not.toBeNull()
expect(box!.height).toBeLessThan(SINGLE_LINE_MAX_HEIGHT_PX)
})
test('opens the switcher to the left of the profile menu without overlap', async ({
comfyPage
}) => {
const page = comfyPage.page
await comfyPage.toast.closeToasts()
await page.getByRole('button', { name: 'Current user' }).click()
await page.getByTestId('workspace-switcher-trigger').click()
const panel = page.getByTestId('workspace-switcher-panel')
await expect(panel).toBeVisible()
const profileMenu = page.locator('.current-user-popover')
const panelBox = await panel.boundingBox()
const profileBox = await profileMenu.boundingBox()
expect(panelBox).not.toBeNull()
expect(profileBox).not.toBeNull()
expect(panelBox!.x + panelBox!.width).toBeLessThanOrEqual(profileBox!.x)
})
test('opens the create-workspace dialog with DES-246 copy', async ({
comfyPage
}) => {
const page = comfyPage.page
await comfyPage.toast.closeToasts()
await page.getByRole('button', { name: 'Current user' }).click()
await page.getByTestId('workspace-switcher-trigger').click()
await page.getByText('Create a workspace').click()
await expect(
page.getByText(
'Workspaces keep your projects and files organized. Subscribe to a Team plan to invite members.'
)
).toBeVisible()
await expect(page.getByPlaceholder('Ex: Comfy Org')).toBeVisible()
})
})

5
global.d.ts vendored
View File

@@ -49,6 +49,11 @@ interface Window {
posthog_project_token?: string
posthog_api_host?: string
posthog_config?: Record<string, unknown>
customer_io?: {
write_key?: string
site_id?: string
user_id?: string
}
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number

View File

@@ -23,6 +23,7 @@
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",
"dev:test": "cross-env VITE_USE_LEGACY_DEFAULT_GRAPH=true vite --config vite.config.mts",
"dev": "vite --config vite.config.mts",
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
"format:check": "oxfmt --check",
@@ -66,6 +67,7 @@
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@customerio/cdp-analytics-browser": "catalog:",
"@formkit/auto-animate": "catalog:",
"@iconify/json": "catalog:",
"@primeuix/forms": "catalog:",

137
pnpm-lock.yaml generated
View File

@@ -21,6 +21,9 @@ catalogs:
'@comfyorg/comfyui-electron-types':
specifier: 0.6.2
version: 0.6.2
'@customerio/cdp-analytics-browser':
specifier: ^0.5.3
version: 0.5.3
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1
@@ -447,6 +450,9 @@ importers:
'@comfyorg/tailwind-utils':
specifier: workspace:*
version: link:packages/tailwind-utils
'@customerio/cdp-analytics-browser':
specifier: 'catalog:'
version: 0.5.3
'@formkit/auto-animate':
specifier: 'catalog:'
version: 0.9.0
@@ -1441,6 +1447,15 @@ packages:
peerDependencies:
postcss-selector-parser: ^7.0.0
'@customerio/cdp-analytics-browser@0.5.3':
resolution: {integrity: sha512-P4lBz+P2iCekq+DOETiAtSfdMyNVQd7OjXhocjffjPtyBJ0ADhpvYuNZAT9R+q2AFrDTx0m8cuyJAtEG+qiXPQ==}
'@customerio/cdp-analytics-core@0.5.3':
resolution: {integrity: sha512-mjR0dyzsX8UjMAh22bT5ByiIEYwtpnNhc9TlHTk2nGPhFnMctSsn9KuMXD9BmfSFcjdmTPg+iABOq68yyPBPHg==}
'@customerio/jist@0.1.8':
resolution: {integrity: sha512-MPiAm5rxu6+wQiEPwY+nV/5i7y67vJ0TvQpeQrOuATzWC45kgpu4YAJm+RlrpDOq35CK1C3utlPG/wI1F6ycXg==}
'@cyberalien/svg-utils@1.2.15':
resolution: {integrity: sha512-ZbKU6npzW5PNocdoLVJYfKzaP+c/RpT6JUkoaKrW1DOcw6lyXub8XtcNpI3xok6FnyNjS6ZbsrrtjTnS9yeZAQ==}
@@ -2382,6 +2397,14 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@lukeed/csprng@1.1.0':
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
'@lukeed/uuid@2.0.1':
resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==}
engines: {node: '>=8'}
'@mdx-js/react@3.1.1':
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
peerDependencies:
@@ -3275,6 +3298,18 @@ packages:
'@rushstack/ts-command-line@5.3.1':
resolution: {integrity: sha512-mid/JIZSJafwy3x9e4v0wVLuAqSSYYErEHV0HXPALYLSBN13YNkR5caOk0hf97lSRKrxhtvQjGaDKSEelR3sMg==}
'@segment/analytics.js-video-plugins@0.2.1':
resolution: {integrity: sha512-lZwCyEXT4aaHBLNK433okEKdxGAuyrVmop4BpQqQSJuRz0DglPZgd9B/XjiiWs1UyOankg2aNYMN3VcS8t4eSQ==}
'@segment/facade@3.4.10':
resolution: {integrity: sha512-xVQBbB/lNvk/u8+ey0kC/+g8pT3l0gCT8O2y9Z+StMMn3KAFAQ9w8xfgef67tJybktOKKU7pQGRPolRM1i1pdA==}
'@segment/isodate-traverse@1.1.1':
resolution: {integrity: sha512-+G6e1SgAUkcq0EDMi+SRLfT48TNlLPF3QnSgFGVs0V9F3o3fq/woQ2rHFlW20W0yy5NnCUH0QGU3Am2rZy/E3w==}
'@segment/isodate@1.0.3':
resolution: {integrity: sha512-BtanDuvJqnACFkeeYje7pWULVv8RgZaqKHWwGFnL/g/TH/CcZjkIVTfGDp/MAxmilYHUkrX70SqwnYSTNEaN7A==}
'@sentry-internal/browser-utils@10.32.1':
resolution: {integrity: sha512-sjLLep1es3rTkbtAdTtdpc/a6g7v7bK5YJiZJsUigoJ4NTiFeMI5uIDCxbH/tjJ1q23YE1LzVn7T96I+qBRjHA==}
engines: {node: '>=18'}
@@ -5037,6 +5072,9 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
customerio-gist-web@3.23.2:
resolution: {integrity: sha512-oCM7WNEx/3cmEG1qQCKWrMwOtU+h41TTKJICNEb7Wj/1jR6+RJsj3b+3N+5u9TxgvUMusmLFvnVvqshU017eHA==}
cva@1.0.0-beta.4:
resolution: {integrity: sha512-F/JS9hScapq4DBVQXcK85l9U91M6ePeXoBMSp7vypzShoefUBxjQTo3g3935PUHgQd+IW77DjbPRIxugy4/GCQ==}
peerDependencies:
@@ -6255,6 +6293,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
js-cookie@3.0.1:
resolution: {integrity: sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==}
engines: {node: '>=12'}
js-cookie@3.0.7:
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
engines: {node: '>=20'}
@@ -6887,6 +6929,9 @@ packages:
resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==}
engines: {node: '>= 10'}
new-date@1.0.3:
resolution: {integrity: sha512-0fsVvQPbo2I18DT2zVHpezmeeNYV2JaJSrseiHLc17GNOxJzUdx5mvSigPu8LtIfZSij5i1wXnXFspEs2CD6hA==}
nlcst-to-string@4.0.0:
resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==}
@@ -6936,6 +6981,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
obj-case@0.2.1:
resolution: {integrity: sha512-PquYBBTy+Y6Ob/O2574XHhDtHJlV1cJHMCgW+rDRc9J5hhmRelJB3k5dTK/3cVmFVtzvAKuENeuLpoyTzMzkOg==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -7776,6 +7824,9 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
spark-md5@3.0.2:
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
@@ -8175,6 +8226,12 @@ packages:
unescape-js@1.1.4:
resolution: {integrity: sha512-42SD8NOQEhdYntEiUQdYq/1V/YHwr1HLwlHuTJB5InVVdOSbgI6xu8jK5q65yIzuFCfczzyDF/7hbGzVbyCw0g==}
unfetch@3.1.2:
resolution: {integrity: sha512-L0qrK7ZeAudGiKYw6nzFjnJ2D5WHblUBwmHIqtPS6oKUd+Hcpk7/hKsSmcHsTlpd1TbTNsiRBUKRq3bHLNIqIw==}
unfetch@4.2.0:
resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -8360,6 +8417,10 @@ packages:
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
uuid@14.0.0:
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
hasBin: true
valibot@1.2.0:
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
peerDependencies:
@@ -8640,8 +8701,8 @@ packages:
vue-component-type-helpers@3.3.2:
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
vue-component-type-helpers@3.3.4:
resolution: {integrity: sha512-joip1uZTaQR0nD23N400gIdJ7xY+WiiiMA/BCKz842gvGBknqDQAzklUvDEhqFvvrhQY8S2ZANBMu4X70VMFGw==}
vue-component-type-helpers@3.3.5:
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -9490,6 +9551,30 @@ snapshots:
dependencies:
postcss-selector-parser: 7.1.1
'@customerio/cdp-analytics-browser@0.5.3':
dependencies:
'@customerio/cdp-analytics-core': 0.5.3
'@lukeed/uuid': 2.0.1
'@segment/analytics.js-video-plugins': 0.2.1
'@segment/facade': 3.4.10
customerio-gist-web: 3.23.2
dset: 3.1.4
js-cookie: 3.0.1
node-fetch: 2.7.0
spark-md5: 3.0.2
tslib: 2.8.1
unfetch: 4.2.0
transitivePeerDependencies:
- encoding
'@customerio/cdp-analytics-core@0.5.3':
dependencies:
'@lukeed/uuid': 2.0.1
dset: 3.1.4
tslib: 2.8.1
'@customerio/jist@0.1.8': {}
'@cyberalien/svg-utils@1.2.15':
dependencies:
'@iconify/types': 2.0.0
@@ -10450,6 +10535,12 @@ snapshots:
- ws
- zod
'@lukeed/csprng@1.1.0': {}
'@lukeed/uuid@2.0.1':
dependencies:
'@lukeed/csprng': 1.1.0
'@mdx-js/react@3.1.1(@types/react@19.1.9)(react@19.2.4)':
dependencies:
'@types/mdx': 2.0.13
@@ -11079,6 +11170,23 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
'@segment/analytics.js-video-plugins@0.2.1':
dependencies:
unfetch: 3.1.2
'@segment/facade@3.4.10':
dependencies:
'@segment/isodate-traverse': 1.1.1
inherits: 2.0.4
new-date: 1.0.3
obj-case: 0.2.1
'@segment/isodate-traverse@1.1.1':
dependencies:
'@segment/isodate': 1.0.3
'@segment/isodate@1.0.3': {}
'@sentry-internal/browser-utils@10.32.1':
dependencies:
'@sentry/core': 10.32.1
@@ -11323,7 +11431,7 @@ snapshots:
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.34(typescript@5.9.3)
vue-component-type-helpers: 3.3.4
vue-component-type-helpers: 3.3.5
'@swc/helpers@0.5.21':
dependencies:
@@ -13103,6 +13211,11 @@ snapshots:
csstype@3.2.3: {}
customerio-gist-web@3.23.2:
dependencies:
'@customerio/jist': 0.1.8
uuid: 14.0.0
cva@1.0.0-beta.4(typescript@5.9.3):
dependencies:
clsx: 2.1.1
@@ -14475,6 +14588,8 @@ snapshots:
js-cookie: 3.0.7
nopt: 7.2.1
js-cookie@3.0.1: {}
js-cookie@3.0.7: {}
js-stringify@1.0.2: {}
@@ -15281,6 +15396,10 @@ snapshots:
neotraverse@0.6.18: {}
new-date@1.0.3:
dependencies:
'@segment/isodate': 1.0.3
nlcst-to-string@4.0.0:
dependencies:
'@types/nlcst': 2.0.3
@@ -15323,6 +15442,8 @@ snapshots:
pathe: 2.0.3
tinyexec: 1.0.4
obj-case@0.2.1: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -16440,6 +16561,8 @@ snapshots:
space-separated-tokens@2.0.2: {}
spark-md5@3.0.2: {}
speakingurl@14.0.1: {}
sprintf-js@1.0.3: {}
@@ -16854,6 +16977,10 @@ snapshots:
dependencies:
string.fromcodepoint: 0.2.1
unfetch@3.1.2: {}
unfetch@4.2.0: {}
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3
@@ -17044,6 +17171,8 @@ snapshots:
uuid@11.1.1: {}
uuid@14.0.0: {}
valibot@1.2.0(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
@@ -17469,7 +17598,7 @@ snapshots:
vue-component-type-helpers@3.3.2: {}
vue-component-type-helpers@3.3.4: {}
vue-component-type-helpers@3.3.5: {}
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
dependencies:

View File

@@ -15,6 +15,7 @@ catalog:
'@astrojs/sitemap': ^3.7.3
'@astrojs/vue': ^6.0.1
'@comfyorg/comfyui-electron-types': 0.6.2
'@customerio/cdp-analytics-browser': ^0.5.3
'@eslint/js': ^10.0.1
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178

View File

@@ -0,0 +1,69 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue'
const mockIsActiveSubscription = ref(true)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: mockIsActiveSubscription
})
}))
vi.mock('@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue', () => ({
default: {
name: 'ComfyQueueButton',
template: '<div data-testid="queue-button" />'
}
}))
vi.mock('@/platform/cloud/subscription/components/SubscribeToRun.vue', () => ({
default: {
name: 'SubscribeToRun',
template: '<div data-testid="subscribe-to-run-button" />'
}
}))
function renderWrapper() {
return render(CloudRunButtonWrapper)
}
describe('CloudRunButtonWrapper', () => {
beforeEach(() => {
mockIsActiveSubscription.value = true
})
it('renders the runnable queue button when the subscription is active', () => {
renderWrapper()
expect(screen.getByTestId('queue-button')).toBeInTheDocument()
expect(
screen.queryByTestId('subscribe-to-run-button')
).not.toBeInTheDocument()
})
it('locks the run button when the subscription is inactive', () => {
mockIsActiveSubscription.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
expect(screen.queryByTestId('queue-button')).not.toBeInTheDocument()
})
it('unlocks the run button once the subscription becomes active again', async () => {
mockIsActiveSubscription.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
mockIsActiveSubscription.value = true
await nextTick()
expect(screen.getByTestId('queue-button')).toBeInTheDocument()
expect(
screen.queryByTestId('subscribe-to-run-button')
).not.toBeInTheDocument()
})
})

View File

@@ -1,7 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor } from '@testing-library/vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
@@ -44,23 +45,28 @@ const mockSubscription = vi.hoisted(() => ({
value: null as { endDate: string | null } | null
}))
const mockCancelSubscription = vi.hoisted(() => vi.fn())
const mockFetchStatus = vi.hoisted(() => vi.fn())
const mockCloseDialog = vi.hoisted(() => vi.fn())
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
cancelSubscription: vi.fn(),
fetchStatus: vi.fn(),
cancelSubscription: mockCancelSubscription,
fetchStatus: mockFetchStatus,
subscription: mockSubscription
}))
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: vi.fn(() => ({
closeDialog: vi.fn()
closeDialog: mockCloseDialog
}))
}))
vi.mock('primevue/usetoast', () => ({
useToast: vi.fn(() => ({
add: vi.fn()
add: mockToastAdd
}))
}))
@@ -86,6 +92,54 @@ function renderComponent(props: { cancelAt?: string } = {}) {
}
describe('CancelSubscriptionDialogContent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('cancel flow', () => {
it('shows an error toast and keeps the dialog open when cancellation fails', async () => {
mockSubscription.value = null
mockCancelSubscription.mockRejectedValueOnce(
new Error('Subscription cancellation timed out')
)
renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /^cancel subscription$/i })
)
await waitFor(() =>
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: 'Subscription cancellation timed out'
})
)
)
expect(mockCloseDialog).not.toHaveBeenCalled()
})
it('closes the dialog and shows a success toast when cancellation succeeds', async () => {
mockSubscription.value = null
mockCancelSubscription.mockResolvedValueOnce(undefined)
renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /^cancel subscription$/i })
)
await waitFor(() =>
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'cancel-subscription'
})
)
expect(mockFetchStatus).toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
})
describe('formattedEndDate fallbacks', () => {
it('uses the localized fallback when no cancel timestamp is available', () => {
mockSubscription.value = { endDate: null }

View File

@@ -539,7 +539,7 @@ describe('TabErrors.vue', () => {
).toBeInTheDocument()
})
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
it('renders missing model Refresh in the header and Download all in the card when models are downloadable', () => {
const missingModel = {
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
@@ -557,11 +557,8 @@ describe('TabErrors.vue', () => {
}
})
expect(
screen.queryByTestId('missing-model-header-refresh')
).not.toBeInTheDocument()
expect(screen.getByTestId('missing-model-header-refresh')).toBeVisible()
expect(screen.getByTestId('missing-model-actions')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
})
})

View File

@@ -94,9 +94,10 @@
showMissingModelHeaderRefresh
"
data-testid="missing-model-header-refresh"
variant="secondary"
size="sm"
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
variant="muted-textonly"
size="icon"
class="mr-2 shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingModels.refresh')"
:aria-busy="missingModelStore.isRefreshingMissingModels"
:aria-disabled="missingModelStore.isRefreshingMissingModels"
@click.stop="handleMissingModelRefresh"
@@ -112,7 +113,6 @@
aria-hidden="true"
class="icon-[lucide--refresh-cw] size-4 shrink-0"
/>
{{ t('rightSidePanel.missingModels.refresh') }}
</Button>
<span
v-if="
@@ -246,7 +246,6 @@
<MissingModelCard
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-model="handleLocateAssetNode"
/>
@@ -301,11 +300,9 @@ import { cn } from '@comfyorg/tailwind-utils'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
@@ -319,7 +316,6 @@ import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCar
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
@@ -347,7 +343,6 @@ const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { openGitHubIssues, contactSupport } = useErrorActions()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const missingModelStore = useMissingModelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
@@ -371,12 +366,6 @@ function getGroupSize(group: ErrorGroup) {
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
}
const showNodeIdBadge = computed(
() =>
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
NodeBadgeMode.None
)
function isExecutionItemListGroup(group: ErrorGroup) {
return (
group.type === 'execution' &&
@@ -463,20 +452,13 @@ const {
swapNodeGroups
} = useErrorGroups(searchQuery)
const missingModelDownloadableModels = computed(() => {
if (isCloud) return []
return getDownloadableModels(missingModelGroups.value)
})
const showMissingModelHeaderRefresh = computed(
() =>
!isCloud &&
missingModelGroups.value.length > 0 &&
missingModelDownloadableModels.value.length === 0
() => !isCloud && missingModelGroups.value.length > 0
)
function handleMissingModelRefresh() {
if (missingModelStore.isRefreshingMissingModels) return
void missingModelStore.refreshMissingModels()
}

View File

@@ -2,6 +2,7 @@ import { render } from '@testing-library/vue'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { nextTick, reactive, ref } from 'vue'
import type { Ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobState } from '@/types/queue'
@@ -19,32 +20,24 @@ type TestTask = {
workflowId?: string
}
const translations: Record<string, string> = {
'queue.jobList.undated': 'Undated',
'g.emDash': '--',
'g.untitled': 'Untitled'
}
let localeRef: Ref<string>
let tMock: ReturnType<typeof vi.fn>
const ensureLocaleMocks = () => {
if (!localeRef) {
localeRef = ref('en-US') as Ref<string>
}
if (!tMock) {
tMock = vi.fn((key: string) => translations[key] ?? key)
}
return { localeRef, tMock }
}
vi.mock('vue-i18n', () => ({
useI18n: () => {
ensureLocaleMocks()
return {
t: tMock,
locale: localeRef
const createTestI18n = () =>
createI18n({
legacy: false,
locale: 'en-US',
messages: {
'en-US': {
queue: {
jobList: {
undated: 'Undated'
}
},
g: {
emDash: '--',
untitled: 'Untitled'
}
}
}
}
}))
})
vi.mock('@/i18n', () => ({
st: vi.fn((key: string, fallback?: string) => `i18n(${key})-${fallback}`)
@@ -184,13 +177,20 @@ const createTask = (
const mountUseJobList = () => {
let composable: ReturnType<typeof useJobList>
const result = render({
template: '<div />',
setup() {
composable = useJobList()
return {}
const result = render(
{
template: '<div />',
setup() {
composable = useJobList()
return {}
}
},
{
global: {
plugins: [createTestI18n()]
}
}
})
)
return { ...result, composable: composable! }
}
@@ -215,10 +215,6 @@ const resetStores = () => {
totalPercent.value = 0
currentNodePercent.value = 0
ensureLocaleMocks()
localeRef.value = 'en-US'
tMock.mockClear()
if (isJobInitializingMock) {
vi.mocked(isJobInitializingMock).mockReset()
vi.mocked(isJobInitializingMock).mockReturnValue(false)
@@ -561,6 +557,35 @@ describe('useJobList', () => {
)
})
it('groups terminal jobs without an execution end timestamp by create time', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))
queueStoreMock.historyTasks = [
createTask({
jobId: 'failed-before-execution',
job: { priority: 1 },
mockState: 'failed',
createTime: Date.now()
}),
createTask({
jobId: 'completed-without-end-time',
job: { priority: 1 },
mockState: 'completed',
createTime: Date.now() - 1_000
})
]
const instance = initComposable()
await flush()
const groups = instance.groupedJobItems.value
expect(groups.map((g) => g.label)).toEqual(['Today'])
expect(groups[0].items.map((item) => item.id)).toEqual([
'failed-before-execution',
'completed-without-end-time'
])
})
it('groups job items by date label and sorts by total generation time when requested', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))

View File

@@ -341,7 +341,7 @@ export function useJobList() {
for (const { task, state } of searchableTaskEntries.value) {
let ts: number | undefined
if (state === 'completed' || state === 'failed') {
ts = task.executionEndTimestamp
ts = task.executionEndTimestamp ?? task.createTime
} else {
ts = task.createTime
}

View File

@@ -1,105 +1,94 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCopy } from './useCopy'
/**
* Encodes a UTF-8 string to base64 (same logic as useCopy.ts)
*/
function encodeClipboardData(data: string): string {
return btoa(
String.fromCharCode(...Array.from(new TextEncoder().encode(data)))
const copyMocks = vi.hoisted(() => ({
copyHandler: undefined as ((event: ClipboardEvent) => unknown) | undefined,
canvas: {
selectedItems: new Set<object>([{}]),
copyToClipboard: vi.fn()
}
}))
vi.mock('@vueuse/core', () => ({
useEventListener: vi.fn(
(
_target: EventTarget,
event: string,
handler: (event: ClipboardEvent) => unknown
) => {
if (event === 'copy') copyMocks.copyHandler = handler
return vi.fn()
}
)
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: copyMocks.canvas
})
}))
vi.mock('@/workbench/eventHelpers', () => ({
shouldIgnoreCopyPaste: vi.fn(() => false)
}))
const multiChunkPayloadLength = 0x8000 * 6 + 123
function copySerializedData(serializedData: string): DataTransfer {
copyMocks.canvas.copyToClipboard.mockReturnValue(serializedData)
useCopy()
const dataTransfer = new DataTransfer()
const event = new ClipboardEvent('copy', {
clipboardData: dataTransfer
})
const copyHandler = copyMocks.copyHandler
expect(copyHandler).toBeDefined()
if (!copyHandler) throw new Error('Expected copy handler to be registered')
expect(() => copyHandler(event)).not.toThrow()
return dataTransfer
}
/**
* Decodes base64 to UTF-8 string (same logic as usePaste.ts)
*/
function decodeClipboardData(base64: string): string {
const binaryString = atob(base64)
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0))
function readSerializedClipboardMetadata(dataTransfer: DataTransfer): string {
const match = dataTransfer
.getData('text/html')
.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
expect(match).toBeDefined()
if (!match) throw new Error('Expected clipboard metadata to be written')
const binaryString = atob(match)
const bytes = Uint8Array.from(binaryString, (char) => char.charCodeAt(0))
return new TextDecoder().decode(bytes)
}
describe('Clipboard UTF-8 base64 encoding/decoding', () => {
it('should handle ASCII-only strings', () => {
const original = '{"nodes":[{"id":1,"type":"LoadImage"}]}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
describe('useCopy', () => {
beforeEach(() => {
copyMocks.copyHandler = undefined
copyMocks.canvas.copyToClipboard.mockReset()
})
it('should handle Chinese characters in localized_name', () => {
const original =
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"图像"}]}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle Japanese characters', () => {
const original = '{"localized_name":"画像を読み込む"}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle Korean characters', () => {
const original = '{"localized_name":"이미지 불러오기"}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle mixed ASCII and Unicode characters', () => {
const original =
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"加载图像","label":"Load Image 图片"}]}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle emoji characters', () => {
const original = '{"title":"Test Node 🎨🖼️"}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle empty string', () => {
const original = ''
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle complex node data with multiple Unicode fields', () => {
const original = JSON.stringify({
it('should write large serialized node data to clipboard metadata', () => {
const serializedData = JSON.stringify({
nodes: [
{
id: 1,
type: 'LoadImage',
localized_name: '图像',
inputs: [{ localized_name: '图片', name: 'image' }],
outputs: [{ localized_name: '输出', name: 'output' }]
type: 'Subgraph',
title: 'Large Subgraph',
localized_name: '이미지 그룹 图像 🎨',
payload: 'x'.repeat(multiChunkPayloadLength)
}
],
groups: [{ title: '预处理组 🔧' }],
links: []
reroutes: [],
links: [],
subgraphs: []
})
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
expect(JSON.parse(decoded)).toEqual(JSON.parse(original))
})
it('should produce valid base64 output', () => {
const original = '{"localized_name":"中文测试"}'
const encoded = encodeClipboardData(original)
// Base64 should only contain valid characters
expect(encoded).toMatch(/^[A-Za-z0-9+/=]+$/)
})
const dataTransfer = copySerializedData(serializedData)
it('should fail with plain btoa for non-Latin1 characters', () => {
const original = '{"localized_name":"图像"}'
// This demonstrates why we need TextEncoder - plain btoa fails
expect(() => btoa(original)).toThrow()
expect(readSerializedClipboardMetadata(dataTransfer)).toBe(serializedData)
})
})

View File

@@ -7,6 +7,29 @@ const clipboardHTMLWrapper = [
'<meta charset="utf-8"><div><span data-metadata="',
'"></span></div><span style="white-space:pre-wrap;">Text</span>'
]
const clipboardByteChunkSize = 0x8000
function bytesToBinaryString(bytes: Uint8Array): string {
const chunks: string[] = []
for (
let offset = 0;
offset < bytes.length;
offset += clipboardByteChunkSize
) {
chunks.push(
String.fromCharCode(
...bytes.subarray(offset, offset + clipboardByteChunkSize)
)
)
}
return chunks.join('')
}
function encodeClipboardData(data: string): string {
return btoa(bytesToBinaryString(new TextEncoder().encode(data)))
}
/**
* Adds a handler on copy that serializes selected nodes to JSON
@@ -23,17 +46,16 @@ export const useCopy = () => {
const canvas = canvasStore.canvas
if (canvas?.selectedItems) {
const serializedData = canvas.copyToClipboard()
// Use TextEncoder to handle Unicode characters properly
const base64Data = btoa(
String.fromCharCode(
...Array.from(new TextEncoder().encode(serializedData))
try {
const base64Data = encodeClipboardData(serializedData)
// clearData doesn't remove images from clipboard
e.clipboardData?.setData(
'text/html',
clipboardHTMLWrapper.join(base64Data)
)
)
// clearData doesn't remove images from clipboard
e.clipboardData?.setData(
'text/html',
clipboardHTMLWrapper.join(base64Data)
)
} catch (error) {
console.error(error)
}
e.preventDefault()
e.stopImmediatePropagation()
return false

View File

@@ -27,7 +27,7 @@ export const LOAD3D_NONE_MODEL = 'none'
export const DIRECT_EXPORT_FORMATS = new Set(['ply', 'spz', 'splat', 'ksplat'])
export interface ExportFormatOption {
interface ExportFormatOption {
label: string
value: string
}

View File

@@ -119,6 +119,23 @@ describe('load3dLazy', () => {
expect(spec.upload_subfolder).toBe('3d')
})
it('injects mesh_upload spec flags into the model_file widget for Load3DAdvanced nodes', async () => {
const { hook } = await loadLazyExtensionFresh()
const nodeData = makeNodeDef('Load3DAdvanced', {
input: {
required: { model_file: ['STRING', {}] }
}
} as Partial<ComfyNodeDef>)
await hook({} as typeof LGraphNode, nodeData)
const spec = (
nodeData.input!.required!.model_file as [string, Record<string, unknown>]
)[1]
expect(spec.mesh_upload).toBe(true)
expect(spec.upload_subfolder).toBe('3d')
})
it('does not throw when a Load3D node has no model_file widget spec', async () => {
const { hook } = await loadLazyExtensionFresh()
const nodeData = makeNodeDef('Load3D', {

View File

@@ -61,18 +61,12 @@ useExtensionService().registerExtension({
if (isLoad3dNode(nodeData.name)) {
// Inject mesh_upload spec flags so WidgetSelect.vue can detect
// Load3D's model_file as a mesh upload widget without hardcoding.
if (nodeData.name === 'Load3D') {
if (nodeData.name === 'Load3D' || nodeData.name === 'Load3DAdvanced') {
const modelFile = nodeData.input?.required?.model_file
if (modelFile?.[1]) {
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = '3d'
}
} else if (nodeData.name === 'Load3DAdvanced') {
const modelFile = nodeData.input?.required?.model_file
if (modelFile?.[1]) {
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = ''
}
}
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,

View File

@@ -8931,71 +8931,20 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
/**
* Collect all nodes that are children of groups in the selection
*/
private collectNodesInGroups(items: Set<Positionable>): Set<LGraphNode> {
const nodesInGroups = new Set<LGraphNode>()
for (const item of items) {
if (item instanceof LGraphGroup) {
for (const child of item._children) {
if (child instanceof LGraphNode) {
nodesInGroups.add(child)
}
}
}
}
return nodesInGroups
}
/**
* Move group children (both nodes and non-nodes)
*/
private moveGroupChildren(
group: LGraphGroup,
deltaX: number,
deltaY: number,
nodesToMove: Array<{ node: LGraphNode; newPos: { x: number; y: number } }>
): void {
for (const child of group._children) {
if (child instanceof LGraphNode) {
const node = child as LGraphNode
nodesToMove.push({
node,
newPos: this.calculateNewPosition(node, deltaX, deltaY)
})
} else if (!(child instanceof LGraphGroup)) {
// Non-node, non-group children (reroutes, etc.)
// Skip groups here - they're already in allItems and will be
// processed in the main loop of moveChildNodesInGroupVueMode
child.move(deltaX, deltaY, true)
}
}
}
moveChildNodesInGroupVueMode(
allItems: Set<Positionable>,
deltaX: number,
deltaY: number
) {
const nodesInMovingGroups = this.collectNodesInGroups(allItems)
const nodesToMove: NewNodePosition[] = []
// First, collect all the moves we need to make
for (const item of allItems) {
const isNode = item instanceof LGraphNode
if (isNode) {
const node = item as LGraphNode
if (nodesInMovingGroups.has(node)) {
continue
}
if (item instanceof LGraphNode) {
nodesToMove.push({
node,
newPos: this.calculateNewPosition(node, deltaX, deltaY)
node: item,
newPos: this.calculateNewPosition(item, deltaX, deltaY)
})
} else if (item instanceof LGraphGroup) {
item.move(deltaX, deltaY, true)
this.moveGroupChildren(item, deltaX, deltaY, nodesToMove)
} else {
// Other items (reroutes, etc.)
item.move(deltaX, deltaY, true)

View File

@@ -2409,7 +2409,9 @@
"topupProcessing": "Processing payment — adding credits...",
"topupSuccess": "Credits added successfully",
"topupFailed": "Top-up failed",
"topupTimeout": "Top-up verification timed out"
"topupTimeout": "Top-up verification timed out",
"cancelFailed": "Failed to cancel subscription",
"cancelTimeout": "Subscription cancellation timed out"
},
"subscription": {
"plansForWorkspace": "Plans for {workspace}",
@@ -2509,6 +2511,13 @@
"pollingFailed": "Subscription activation failed",
"pollingTimeout": "Timed out waiting for subscription. Please refresh and try again."
},
"inactive": {
"memberTitle": "This workspace's subscription is inactive",
"memberDescription": "Ask your workspace owner to reactivate the workspace's subscription to run workflows.",
"memberCta": "Ok, got it",
"memberRunTooltip": "Contact your workspace owner to resubscribe",
"runLabel": "Run"
},
"subscribeToRun": "Subscribe",
"subscribeToRunFull": "Subscribe to Run",
"subscribeForMore": "Upgrade",
@@ -2701,9 +2710,9 @@
},
"createWorkspaceDialog": {
"title": "Create a new workspace",
"message": "Workspaces create a new credit pool that can be shared among members. You'll become the owner after creating this.",
"nameLabel": "Workspace name*",
"namePlaceholder": "Enter workspace name",
"message": "Workspaces keep your projects and files organized. Subscribe to a Team plan to invite members.",
"nameLabel": "Workspace name",
"namePlaceholder": "Ex: Comfy Org",
"create": "Create"
},
"toast": {
@@ -2749,7 +2758,7 @@
"personal": "Personal",
"roleOwner": "Owner",
"roleMember": "Member",
"createWorkspace": "Create new workspace",
"createWorkspace": "Create a workspace",
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one.",
"failedToSwitch": "Failed to switch workspace"
},
@@ -3091,6 +3100,13 @@
"loadingModels": "Loading {type}...",
"maxFileSize": "Max file size: {size}",
"maxFileSizeValue": "1 GB",
"missingModelImportTypeLocked": "Locked to {type} for this missing model",
"missingModelImportTypeMismatchAlreadyImported": "This file is already imported as {actual}.",
"missingModelImportTypeMismatchNextAction": "Try importing a different {required} model that this node can use.",
"missingModelImportTypeMismatchRequired": "This node requires {required}, so this import cannot resolve the missing model.",
"missingModelImportTypeMismatchTitle": "This model cannot resolve the missing model.",
"missingModelImportUnknownType": "another model type",
"missingModelImportWillReplace": "This import will replace {model} in:",
"modelAssociatedWithLink": "The model associated with the link you provided:",
"modelName": "Model Name",
"modelNamePlaceholder": "Enter a name for this model",
@@ -3643,32 +3659,17 @@
"expand": "Expand"
},
"missingModels": {
"urlPlaceholder": "Paste Model URL (Civitai or Hugging Face)",
"or": "OR",
"useFromLibrary": "Use from Library",
"usingFromLibrary": "Using from Library",
"unsupportedUrl": "Only Civitai and Hugging Face URLs are supported.",
"metadataFetchFailed": "Failed to retrieve metadata. Please check the link and try again.",
"import": "Import",
"importing": "Importing...",
"imported": "Imported",
"importFailed": "Import failed",
"typeMismatch": "This model seems to be a \"{detectedType}\". Are you sure?",
"importAnyway": "Import Anyway",
"alreadyExistsInCategory": "This model already exists in \"{category}\"",
"customNodeDownloadDisabled": "Cloud environment does not support model imports for custom nodes in this section. Please use standard loader nodes or substitute with a model from the library below.",
"customNodeDownloadDisabled": "Nodes that reference the models below do not support imported models. Open the node to choose a supported built-in model, or replace it with a standard node that supports imported models.",
"importNotSupported": "Import Not Supported",
"copyModelName": "Copy model name",
"copyUrl": "Copy URL",
"confirmSelection": "Confirm selection",
"locateNode": "Locate node on canvas",
"cancelSelection": "Cancel selection",
"clearUrl": "Clear URL",
"expandNodes": "Show referencing nodes",
"collapseNodes": "Hide referencing nodes",
"unknownCategory": "Unknown",
"missingModelsTitle": "Missing Models",
"assetLoadTimeout": "Model detection timed out. Try reloading the workflow.",
"downloadAll": "Download all",
"refresh": "Refresh",
"refreshing": "Refreshing missing models.",

View File

@@ -0,0 +1,80 @@
import { render, screen } from '@testing-library/vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { UploadModelDialogContext } from '@/platform/assets/composables/useUploadModelWizard'
import UploadModelConfirmation from './UploadModelConfirmation.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false,
escapeParameter: true
})
const SingleSelectStub = {
name: 'SingleSelect',
props: {
disabled: Boolean,
modelValue: String
},
template:
'<button type="button" :disabled="disabled">{{ modelValue }}</button>'
}
describe('UploadModelConfirmation', () => {
it('shows missing-model replacement context and locks the model type', () => {
const uploadContext: UploadModelDialogContext = {
kind: 'missing-model-resolution',
missingModelName: 'segm/person_yolov8m-seg.pt',
requiredModelType: 'Ultralytics/bbox',
replacementTargets: [
{
nodeId: '1',
nodeLabel: 'Checkpoint Loader',
widgetName: 'ckpt_name'
}
]
}
render(UploadModelConfirmation, {
props: {
modelValue: 'Ultralytics/bbox',
metadata: {
content_length: 100,
final_url: 'https://civitai.com/models/123',
filename: 'replacement.safetensors'
},
uploadContext,
'onUpdate:modelValue': () => {}
},
global: {
plugins: [i18n],
stubs: {
SingleSelect: SingleSelectStub
}
}
})
expect(screen.getByText('segm/person_yolov8m-seg.pt')).toBeInTheDocument()
expect(screen.getByText('Checkpoint Loader')).toBeInTheDocument()
expect(screen.getByText('- ckpt_name')).toBeInTheDocument()
const modelTypeSelect = screen.getByRole('button', {
name: 'Ultralytics/bbox'
})
expect(modelTypeSelect).toBeDisabled()
expect(
screen.getByText((_content, element) => {
return (
element?.textContent ===
'Locked to Ultralytics/bbox for this missing model'
)
})
).toBeInTheDocument()
})
})

View File

@@ -22,16 +22,50 @@
</div>
</div>
<div
v-if="isMissingModelResolution"
class="flex flex-col gap-2 rounded-lg bg-secondary-background px-4 py-3"
>
<i18n-t
keypath="assetBrowser.missingModelImportWillReplace"
tag="p"
class="m-0 text-base-foreground"
>
<template #model>
<span>{{ missingModelName }}</span>
</template>
</i18n-t>
<ul class="m-0 list-none space-y-1 p-0">
<li
v-for="target in replacementTargets"
:key="`${target.nodeId}:${target.widgetName}`"
class="flex min-w-0 items-center gap-2"
>
<span class="min-w-0 truncate text-muted-foreground">
{{ target.nodeLabel }}
</span>
<span class="shrink-0 text-muted-foreground">
- {{ target.widgetName }}
</span>
</li>
</ul>
</div>
<!-- Model Type Selection -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<label>
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
</label>
<i class="icon-[lucide--circle-question-mark] text-muted-foreground" />
<span class="text-muted-foreground">
{{ $t('assetBrowser.notSureLeaveAsIs') }}
</span>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<label>
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
</label>
<i
aria-hidden="true"
class="icon-[lucide--circle-question-mark] text-muted-foreground"
/>
<span v-if="!isMissingModelResolution" class="text-muted-foreground">
{{ $t('assetBrowser.notSureLeaveAsIs') }}
</span>
</div>
</div>
<SingleSelect
v-model="modelValue"
@@ -41,23 +75,37 @@
: $t('assetBrowser.modelTypeSelectorPlaceholder')
"
:options="modelTypes"
:disabled="isLoading"
:disabled="isLoading || isMissingModelResolution"
:content-style="selectContentStyle"
data-attr="upload-model-step2-type-selector"
/>
<i18n-t
v-if="isMissingModelResolution"
keypath="assetBrowser.missingModelImportTypeLocked"
tag="span"
class="text-muted-foreground"
>
<template #type>
<span>{{ selectedModelTypeLabel }}</span>
</template>
</i18n-t>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type { UploadModelDialogContext } from '@/platform/assets/composables/useUploadModelWizard'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
defineProps<{
const { uploadContext } = defineProps<{
metadata?: AssetMetadata
previewImage?: string
uploadContext?: UploadModelDialogContext
}>()
const modelValue = defineModel<string | undefined>()
@@ -65,4 +113,27 @@ const modelValue = defineModel<string | undefined>()
const { modelTypes, isLoading } = useModelTypes()
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
const isMissingModelResolution = computed(
() => uploadContext?.kind === 'missing-model-resolution'
)
const missingModelName = computed(() =>
uploadContext?.kind === 'missing-model-resolution'
? uploadContext.missingModelName
: ''
)
const replacementTargets = computed(() =>
uploadContext?.kind === 'missing-model-resolution'
? uploadContext.replacementTargets
: []
)
const selectedModelTypeLabel = computed(() => {
const value =
uploadContext?.kind === 'missing-model-resolution'
? uploadContext.requiredModelType
: modelValue.value
return (
modelTypes.value.find((option) => option.value === value)?.name ?? value
)
})
</script>

View File

@@ -17,6 +17,7 @@
v-model="selectedModelType"
:metadata="wizardData.metadata"
:preview-image="wizardData.previewImage"
:upload-context="uploadContext"
/>
<!-- Step 3: Upload Progress -->
@@ -24,6 +25,7 @@
v-else-if="currentStep === 3 && uploadStatus != null"
:result="uploadStatus"
:error="uploadError"
:type-mismatch="uploadTypeMismatch"
:metadata="wizardData.metadata"
:model-type="selectedModelType"
:preview-image="wizardData.previewImage"
@@ -39,6 +41,7 @@
:can-fetch-metadata="canFetchMetadata"
:can-upload-model="canUploadModel"
:upload-status="uploadStatus"
:can-import-another="!isMissingModelResolution"
@back="goToPreviousStep"
@fetch-metadata="handleFetchMetadata"
@upload="handleUploadModel"
@@ -49,29 +52,47 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.vue'
import UploadModelFooter from '@/platform/assets/components/UploadModelFooter.vue'
import UploadModelProgress from '@/platform/assets/components/UploadModelProgress.vue'
import UploadModelUrlInput from '@/platform/assets/components/UploadModelUrlInput.vue'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type {
UploadModelDialogContext,
UploadModelSuccess
} from '@/platform/assets/composables/useUploadModelWizard'
import { useUploadModelWizard } from '@/platform/assets/composables/useUploadModelWizard'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const { modelTypes, fetchModelTypes } = useModelTypes()
const emit = defineEmits<{
'upload-success': []
const { uploadContext } = defineProps<{
uploadContext?: UploadModelDialogContext
}>()
const emit = defineEmits<{
'upload-success': [result: UploadModelSuccess]
}>()
const isMissingModelResolution = computed(
() => uploadContext?.kind === 'missing-model-resolution'
)
const requiredModelType = computed(() =>
uploadContext?.kind === 'missing-model-resolution'
? uploadContext.requiredModelType
: undefined
)
const {
currentStep,
isFetchingMetadata,
isUploading,
uploadStatus,
uploadError,
uploadTypeMismatch,
wizardData,
selectedModelType,
canFetchMetadata,
@@ -80,16 +101,18 @@ const {
uploadModel,
goToPreviousStep,
resetWizard
} = useUploadModelWizard(modelTypes)
} = useUploadModelWizard(modelTypes, {
requiredModelType: requiredModelType.value
})
async function handleFetchMetadata() {
await fetchMetadata()
}
async function handleUploadModel() {
const success = await uploadModel()
if (success) {
emit('upload-success')
const result = await uploadModel()
if (result) {
emit('upload-success', result)
}
}

View File

@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import UploadModelFooter from './UploadModelFooter.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false
})
function renderFooter(
props: Partial<InstanceType<typeof UploadModelFooter>['$props']> = {}
) {
render(UploadModelFooter, {
props: {
currentStep: 3,
isFetchingMetadata: false,
isUploading: false,
canFetchMetadata: true,
canUploadModel: true,
uploadStatus: 'success',
...props
},
global: {
plugins: [i18n],
stubs: {
VideoHelpDialog: true
}
}
})
}
describe('UploadModelFooter', () => {
it('allows importing another model by default', () => {
renderFooter()
expect(screen.getByRole('button', { name: 'Import Another' })).toBeEnabled()
})
it('disables importing another model when the upload resolves a missing model', () => {
renderFooter({ canImportAnother: false })
expect(
screen.getByRole('button', { name: 'Import Another' })
).toBeDisabled()
})
it('shows recovery actions for upload errors', () => {
renderFooter({ uploadStatus: 'error' })
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
})
})

View File

@@ -73,6 +73,7 @@
variant="muted-textonly"
size="lg"
data-attr="upload-model-step3-import-another-button"
:disabled="!canImportAnother"
@click="emit('importAnother')"
>
{{ $t('assetBrowser.importAnother') }}
@@ -90,6 +91,24 @@
}}
</Button>
</template>
<template v-else-if="currentStep === 3 && uploadStatus === 'error'">
<Button
variant="muted-textonly"
size="lg"
data-attr="upload-model-step3-back-button"
@click="emit('back')"
>
{{ $t('g.back') }}
</Button>
<Button
variant="secondary"
size="lg"
data-attr="upload-model-step3-close-button"
@click="emit('close')"
>
{{ $t('g.close') }}
</Button>
</template>
<VideoHelpDialog
v-model="showCivitaiHelp"
video-url="https://media.comfy.org/compressed_768/civitai_howto.webm"
@@ -113,13 +132,14 @@ import VideoHelpDialog from '@/platform/assets/components/VideoHelpDialog.vue'
const showCivitaiHelp = ref(false)
const showHuggingFaceHelp = ref(false)
defineProps<{
const { canImportAnother = true } = defineProps<{
currentStep: number
isFetchingMetadata: boolean
isUploading: boolean
canFetchMetadata: boolean
canUploadModel: boolean
uploadStatus?: 'processing' | 'success' | 'error'
canImportAnother?: boolean
}>()
const emit = defineEmits<{

View File

@@ -0,0 +1,74 @@
import { render, screen } from '@testing-library/vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import UploadModelProgress from './UploadModelProgress.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false,
escapeParameter: true
})
describe('UploadModelProgress', () => {
it('renders missing-model type mismatch labels', () => {
render(UploadModelProgress, {
props: {
result: 'error',
typeMismatch: {
importedModelType: 'loras',
importedModelTypeLabel: 'LoRA/Custom',
requiredModelType: 'Ultralytics/bbox',
requiredModelTypeLabel: 'Ultralytics/bbox'
}
},
global: {
plugins: [i18n]
}
})
expect(
screen.getByText('This model cannot resolve the missing model.')
).toBeInTheDocument()
expect(screen.getByText('LoRA/Custom')).toBeInTheDocument()
expect(screen.getAllByText('Ultralytics/bbox').length).toBeGreaterThan(0)
expect(
screen.getByText((_content, element) => {
return (
element?.textContent ===
'Try importing a different Ultralytics/bbox model that this node can use.'
)
})
).toBeInTheDocument()
})
it('uses fallback copy when the imported model type label is unknown', () => {
render(UploadModelProgress, {
props: {
result: 'error',
typeMismatch: {
requiredModelType: 'checkpoints',
requiredModelTypeLabel: 'Checkpoint'
}
},
global: {
plugins: [i18n]
}
})
expect(screen.getByText('another model type')).toBeInTheDocument()
expect(
screen.getByText((_content, element) => {
return (
element?.textContent ===
'This file is already imported as another model type.'
)
})
).toBeInTheDocument()
})
})

View File

@@ -1,5 +1,12 @@
<template>
<div class="flex flex-1 flex-col gap-6 text-sm text-muted-foreground">
<div
:class="
cn(
'flex flex-1 flex-col gap-6 text-sm text-muted-foreground',
isTypeMismatchError && 'min-h-full justify-center'
)
"
>
<!-- Processing State (202 async download in progress) -->
<div v-if="result === 'processing'" class="flex flex-col gap-2">
<p class="m-0 font-bold">
@@ -67,8 +74,51 @@
v-else-if="result === 'error'"
class="flex flex-1 flex-col items-center justify-center gap-6"
>
<i class="icon-[lucide--x-circle] text-6xl text-error" />
<div class="text-center">
<i
aria-hidden="true"
class="text-error"
:class="
typeMismatch
? 'icon-[lucide--circle-alert] size-12'
: 'icon-[lucide--x-circle] size-16'
"
/>
<div
v-if="typeMismatch"
class="flex max-w-2xl flex-col gap-3 text-center"
>
<p class="m-0 text-sm font-bold">
{{ $t('assetBrowser.missingModelImportTypeMismatchTitle') }}
</p>
<i18n-t
keypath="assetBrowser.missingModelImportTypeMismatchAlreadyImported"
tag="p"
class="m-0 text-sm text-muted"
>
<template #actual>
<span>{{ actualModelTypeLabel }}</span>
</template>
</i18n-t>
<i18n-t
keypath="assetBrowser.missingModelImportTypeMismatchRequired"
tag="p"
class="m-0 text-sm text-muted"
>
<template #required>
<span>{{ typeMismatch.requiredModelTypeLabel }}</span>
</template>
</i18n-t>
<i18n-t
keypath="assetBrowser.missingModelImportTypeMismatchNextAction"
tag="p"
class="m-0 text-sm text-base-foreground"
>
<template #required>
<span>{{ typeMismatch.requiredModelTypeLabel }}</span>
</template>
</i18n-t>
</div>
<div v-else class="text-center">
<p class="m-0 text-sm font-bold">
{{ $t('assetBrowser.uploadFailed') }}
</p>
@@ -81,13 +131,26 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import type { UploadModelTypeMismatch } from '@/platform/assets/composables/useUploadModelWizard'
defineProps<{
const { typeMismatch } = defineProps<{
result: 'processing' | 'success' | 'error'
error?: string
metadata?: AssetMetadata
modelType?: string
previewImage?: string
typeMismatch?: UploadModelTypeMismatch | null
}>()
const { t } = useI18n()
const isTypeMismatchError = computed(() => typeMismatch != null)
const actualModelTypeLabel = computed(
() =>
typeMismatch?.importedModelTypeLabel ??
t('assetBrowser.missingModelImportUnknownType')
)
</script>

View File

@@ -313,9 +313,7 @@ export function useMediaAssetActions() {
subfolder: metadata?.subfolder || '',
type: isResultItemType(assetType) ? assetType : undefined
},
{
rootFolder: isResultItemType(assetType) ? assetType : undefined
}
{ rootFolder: 'input' }
)
const widget = node.widgets?.find((w) => w.name === widgetName)

View File

@@ -3,17 +3,28 @@ import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vue'
import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue'
import type {
UploadModelDialogContext,
UploadModelSuccess
} from '@/platform/assets/composables/useUploadModelWizard'
import UploadModelUpgradeModal from '@/platform/assets/components/UploadModelUpgradeModal.vue'
import UploadModelUpgradeModalHeader from '@/platform/assets/components/UploadModelUpgradeModalHeader.vue'
import { useDialogStore } from '@/stores/dialogStore'
type UploadModelContextResolver = () => UploadModelDialogContext | undefined
export function useModelUpload(
onUploadSuccess?: () => Promise<unknown> | void
onUploadSuccess?: (result: UploadModelSuccess) => Promise<unknown> | void,
uploadContext?: UploadModelDialogContext | UploadModelContextResolver
) {
const dialogStore = useDialogStore()
const { flags } = useFeatureFlags()
const isUploadButtonEnabled = computed(() => flags.modelUploadButtonEnabled)
function resolveUploadContext() {
return typeof uploadContext === 'function' ? uploadContext() : uploadContext
}
function showUploadDialog() {
if (!flags.privateModelsEnabled) {
dialogStore.showDialog({
@@ -33,8 +44,9 @@ export function useModelUpload(
headerComponent: UploadModelDialogHeader,
component: UploadModelDialog,
props: {
onUploadSuccess: async () => {
await onUploadSuccess?.()
uploadContext: resolveUploadContext(),
onUploadSuccess: async (result: UploadModelSuccess) => {
await onUploadSuccess?.(result)
}
},
dialogComponentProps: {

View File

@@ -1,14 +1,18 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, ref } from 'vue'
import type { App } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { AsyncUploadResponse } from '@/platform/assets/schemas/assetSchema'
import { useUploadModelWizard } from './useUploadModelWizard'
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetMetadata: vi.fn(),
uploadAssetAsync: vi.fn(),
uploadAssetPreviewImage: vi.fn()
}
@@ -45,18 +49,52 @@ vi.mock('@/i18n', () => ({
d: (date: Date) => date.toISOString()
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key })
}))
describe('useUploadModelWizard', () => {
const modelTypes = ref([{ name: 'Checkpoint', value: 'checkpoints' }])
const mountedApps: App<Element>[] = []
function setupWithI18n<T>(factory: () => T): T {
let result: T | undefined
const host = document.createElement('div')
const app = createApp({
setup() {
result = factory()
return () => null
}
})
app.use(
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
)
app.mount(host)
mountedApps.push(app)
if (result === undefined) {
throw new Error('Composable setup did not run')
}
return result
}
function setupUploadModelWizard(
...args: Parameters<typeof useUploadModelWizard>
): ReturnType<typeof useUploadModelWizard> {
return setupWithI18n(() => useUploadModelWizard(...args))
}
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
for (const app of mountedApps.splice(0)) {
app.unmount()
}
})
it('updates uploadStatus to success when async download completes', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
@@ -71,11 +109,18 @@ describe('useUploadModelWizard', () => {
}
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
const wizard = useUploadModelWizard(modelTypes)
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
const result = await wizard.uploadModel()
expect(result).toEqual({
filename: 'model',
modelType: 'checkpoints',
taskId: 'task-123',
status: 'processing'
})
expect(wizard.uploadStatus.value).toBe('processing')
@@ -118,7 +163,7 @@ describe('useUploadModelWizard', () => {
}
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
const wizard = useUploadModelWizard(modelTypes)
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/99999'
wizard.selectedModelType.value = 'checkpoints'
@@ -169,7 +214,7 @@ describe('useUploadModelWizard', () => {
}
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
const wizard = useUploadModelWizard(modelTypes)
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.red/models/12345'
wizard.selectedModelType.value = 'checkpoints'
@@ -178,4 +223,160 @@ describe('useUploadModelWizard', () => {
expect(assetService.uploadAssetAsync).toHaveBeenCalled()
expect(wizard.uploadStatus.value).toBe('processing')
})
it('keeps a required model type when metadata suggests another type', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.getAssetMetadata).mockResolvedValue({
content_length: 100,
final_url: 'https://civitai.com/models/12345',
filename: 'lora.safetensors',
tags: ['loras']
})
const wizard = setupUploadModelWizard(
ref([
{ name: 'Checkpoint', value: 'checkpoints' },
{ name: 'LoRA', value: 'loras' }
]),
{ requiredModelType: 'checkpoints' }
)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
await wizard.fetchMetadata()
expect(wizard.selectedModelType.value).toBe('checkpoints')
})
it('uploads with the required model type even if selection changes', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-1',
name: 'model.safetensors',
tags: ['models', 'checkpoints']
}
})
const wizard = setupUploadModelWizard(modelTypes, {
requiredModelType: 'checkpoints'
})
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'loras'
const result = await wizard.uploadModel()
expect(assetService.uploadAssetAsync).toHaveBeenCalledWith(
expect.objectContaining({
tags: ['models', 'checkpoints'],
user_metadata: expect.objectContaining({
model_type: 'checkpoints'
})
})
)
expect(result?.modelType).toBe('checkpoints')
})
it('returns the synced asset filename for sync imports', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-canonical',
name: 'asset-record-display-name.safetensors',
tags: ['models', 'checkpoints'],
user_metadata: {
filename: 'models/checkpoints/canonical-model.safetensors'
}
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.wizardData.value.metadata = {
content_length: 100,
final_url:
'https://civitai.com/api/download/models/canonical-model.safetensors',
filename: 'metadata-model.safetensors',
tags: ['checkpoints']
}
wizard.selectedModelType.value = 'checkpoints'
const result = await wizard.uploadModel()
expect(result).toEqual({
filename: 'models/checkpoints/canonical-model.safetensors',
modelType: 'checkpoints',
status: 'success'
})
})
it('blocks a missing-model import when an existing asset has the wrong model type', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-lora',
name: 'model.safetensors',
tags: ['models', 'loras']
}
})
const wizard = setupUploadModelWizard(
ref([
{ name: 'Checkpoint', value: 'checkpoints' },
{ name: 'LoRA', value: 'loras' }
]),
{ requiredModelType: 'checkpoints' }
)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
const result = await wizard.uploadModel()
expect(result).toBeNull()
expect(wizard.uploadStatus.value).toBe('error')
expect(wizard.uploadTypeMismatch.value).toEqual({
importedModelType: 'loras',
importedModelTypeLabel: 'LoRA',
requiredModelType: 'checkpoints',
requiredModelTypeLabel: 'Checkpoint'
})
})
it('does not block sync imports as mismatches without a required model type', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-lora',
name: 'model.safetensors',
tags: ['models', 'loras']
}
})
const wizard = setupUploadModelWizard(
ref([
{ name: 'Checkpoint', value: 'checkpoints' },
{ name: 'LoRA', value: 'loras' }
])
)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
const result = await wizard.uploadModel()
expect(result).toEqual(
expect.objectContaining({
modelType: 'checkpoints',
status: 'success'
})
)
expect(wizard.uploadStatus.value).toBe('success')
expect(wizard.uploadTypeMismatch.value).toBeNull()
})
})

View File

@@ -5,9 +5,13 @@ import { useI18n } from 'vue-i18n'
import { st } from '@/i18n'
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import type {
AssetItem,
AssetMetadata
} from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import type { ImportSource } from '@/platform/assets/types/importSource'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useAssetsStore } from '@/stores/assetsStore'
@@ -26,16 +30,54 @@ interface ModelTypeOption {
value: string
}
export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const MODEL_ROOT_TAG = 'models'
export interface UploadModelSuccess {
filename: string
modelType?: string
taskId?: string
status: 'processing' | 'success'
}
export interface UploadModelTypeMismatch {
importedModelType?: string
importedModelTypeLabel?: string
requiredModelType: string
requiredModelTypeLabel: string
}
interface MissingModelUploadContext {
kind: 'missing-model-resolution'
missingModelName: string
requiredModelType: string
replacementTargets: Array<{
nodeId: string
nodeLabel: string
widgetName: string
}>
}
export type UploadModelDialogContext = MissingModelUploadContext
interface UploadModelWizardOptions {
requiredModelType?: string
}
export function useUploadModelWizard(
modelTypes: Ref<ModelTypeOption[]>,
options: UploadModelWizardOptions = {}
) {
const { t } = useI18n()
const assetsStore = useAssetsStore()
const assetDownloadStore = useAssetDownloadStore()
const modelToNodeStore = useModelToNodeStore()
const requiredModelType = options.requiredModelType
const currentStep = ref(1)
const isFetchingMetadata = ref(false)
const isUploading = ref(false)
const uploadStatus = ref<'processing' | 'success' | 'error'>()
const uploadError = ref('')
const uploadTypeMismatch = ref<UploadModelTypeMismatch | null>(null)
let stopAsyncWatch: (() => void) | undefined
const wizardData = ref<WizardData>({
@@ -44,7 +86,10 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
tags: []
})
const selectedModelType = ref<string>()
const selectedModelType = ref<string | undefined>(requiredModelType)
const resolvedModelType = computed(
() => requiredModelType ?? selectedModelType.value
)
const importSources: ImportSource[] = [
civitaiImportSource,
@@ -65,16 +110,29 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
() => wizardData.value.url,
() => {
uploadError.value = ''
uploadTypeMismatch.value = null
}
)
if (requiredModelType) {
watch(
selectedModelType,
(value) => {
if (value !== requiredModelType) {
selectedModelType.value = requiredModelType
}
},
{ immediate: true }
)
}
// Validation - only enable Continue when URL matches a supported source
const canFetchMetadata = computed(() => {
return detectedSource.value !== null
})
const canUploadModel = computed(() => {
return !!selectedModelType.value
return !!resolvedModelType.value
})
async function fetchMetadata() {
@@ -128,7 +186,9 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
wizardData.value.previewImage = metadata.preview_image
// Pre-fill model type from metadata tags if available
if (metadata.tags && metadata.tags.length > 0) {
if (requiredModelType) {
selectedModelType.value = requiredModelType
} else if (metadata.tags && metadata.tags.length > 0) {
wizardData.value.tags = metadata.tags
// Try to detect model type from tags
const typeTag = metadata.tags.find((tag) =>
@@ -183,10 +243,10 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
}
async function refreshModelCaches() {
if (!selectedModelType.value) return
if (!resolvedModelType.value) return
const providers = modelToNodeStore.getAllNodeProviders(
selectedModelType.value
resolvedModelType.value
)
const results = await Promise.allSettled(
providers.map((provider) =>
@@ -203,24 +263,61 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
})
}
async function uploadModel(): Promise<boolean> {
if (isUploading.value) return false
function getModelTypeLabel(modelType: string): string {
return (
modelTypes.value.find((type) => type.value === modelType)?.name ??
modelType
)
}
function getImportedModelType(asset: AssetItem): string | undefined {
const knownType = asset.tags.find(
(tag) =>
tag !== MODEL_ROOT_TAG &&
modelTypes.value.some((type) => type.value === tag)
)
return knownType ?? asset.tags.find((tag) => tag !== MODEL_ROOT_TAG)
}
function blockMismatchedImportedModel(
asset: AssetItem,
requiredType: string
): boolean {
if (asset.tags.includes(requiredType)) return false
const importedType = getImportedModelType(asset)
uploadStatus.value = 'error'
uploadError.value = ''
uploadTypeMismatch.value = {
importedModelType: importedType,
importedModelTypeLabel: importedType
? getModelTypeLabel(importedType)
: undefined,
requiredModelType: requiredType,
requiredModelTypeLabel: getModelTypeLabel(requiredType)
}
return true
}
async function uploadModel(): Promise<UploadModelSuccess | null> {
if (isUploading.value) return null
if (!canUploadModel.value) {
return false
return null
}
const source = detectedSource.value
if (!source) {
uploadError.value = t('assetBrowser.noValidSourceDetected')
return false
return null
}
isUploading.value = true
uploadTypeMismatch.value = null
let uploadSuccess: UploadModelSuccess | null = null
try {
const tags = selectedModelType.value
? ['models', selectedModelType.value]
: ['models']
const modelType = resolvedModelType.value
const tags = modelType ? ['models', modelType] : ['models']
const filename =
wizardData.value.metadata?.filename ||
wizardData.value.metadata?.name ||
@@ -230,7 +327,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const userMetadata = {
source: source.type,
source_url: wizardData.value.url,
model_type: selectedModelType.value
model_type: modelType
}
const result = await assetService.uploadAssetAsync({
@@ -241,14 +338,20 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
})
if (result.type === 'async' && result.task.status !== 'completed') {
if (selectedModelType.value) {
if (modelType) {
assetDownloadStore.trackDownload(
result.task.task_id,
selectedModelType.value,
modelType,
filename
)
}
uploadStatus.value = 'processing'
uploadSuccess = {
filename,
modelType,
taskId: result.task.task_id,
status: 'processing'
}
stopAsyncWatch?.()
let resolved = false
@@ -288,8 +391,24 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
stopAsyncWatch = stop
}
} else {
if (
requiredModelType &&
result.type === 'sync' &&
modelType &&
blockMismatchedImportedModel(result.asset, modelType)
) {
currentStep.value = 3
return null
}
uploadStatus.value = 'success'
await refreshModelCaches()
uploadSuccess = {
filename:
result.type === 'sync' ? getAssetFilename(result.asset) : filename,
modelType,
status: 'success'
}
}
currentStep.value = 3
} catch (error) {
@@ -301,7 +420,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
} finally {
isUploading.value = false
}
return uploadStatus.value !== 'error'
return uploadSuccess
}
function goToPreviousStep() {
@@ -318,12 +437,13 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
isUploading.value = false
uploadStatus.value = undefined
uploadError.value = ''
uploadTypeMismatch.value = null
wizardData.value = {
url: '',
name: '',
tags: []
}
selectedModelType.value = undefined
selectedModelType.value = requiredModelType
}
return {
@@ -333,6 +453,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
isUploading,
uploadStatus,
uploadError,
uploadTypeMismatch,
wizardData,
selectedModelType,

View File

@@ -223,8 +223,18 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
['film', 'FILM VFI', 'ckpt_name'],
// ---- Ultralytics YOLO detectors (ComfyUI-Impact-Pack) ----
['ultralytics/bbox', 'UltralyticsDetectorProvider', 'model_name'],
['ultralytics/segm', 'UltralyticsDetectorProvider', 'model_name'],
// Intentionally NOT mapped to the asset-picker. The cloud asset-ingestion
// metadata for nested model folders (`ultralytics/bbox`, `ultralytics/segm`)
// still has the two known half-bugs described in #12075:
// 1. Tag lookup mismatch (cloud stores combined tags, picker queries split).
// 2. Submitted value mismatch (picker returns basenames, ingest expects
// subdirectory-prefixed `bbox/<file>` / `segm/<file>`).
// PR #12151 re-added the bbox/segm entries before either half was fixed,
// reintroducing the FaceDetailer breakage. Until BE-689 lands the cloud-side
// fixes, leave these disabled so the node falls back to the static combo
// populated from `/api/object_info`.
// ['ultralytics/bbox', 'UltralyticsDetectorProvider', 'model_name'],
// ['ultralytics/segm', 'UltralyticsDetectorProvider', 'model_name'],
// ---- Mel-Band RoFormer audio separation (ComfyUI-MelBandRoFormer) ----
['diffusion_models', 'MelBandRoFormerModelLoader', 'model_name'],

View File

@@ -20,5 +20,8 @@ export function getAssetType(
asset: AssetItem,
defaultType: 'input' | 'output' = 'output'
): string {
return asset.tags?.[0] || defaultType
const urlType = new URLSearchParams(
(asset.preview_url ?? '').split('?')[1] ?? ''
).get('type')
return urlType || asset.tags?.[0] || defaultType
}

View File

@@ -0,0 +1,99 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { getSurveyCompletedStatus } from './auth'
const fetchApi = vi.fn()
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: (...args: unknown[]) => fetchApi(...args)
}
}))
vi.mock('@sentry/vue', () => ({
addBreadcrumb: vi.fn(),
captureException: vi.fn()
}))
function mockResponse({
ok,
status,
body
}: {
ok: boolean
status: number
body?: unknown
}): Response {
return fromPartial<Response>({
ok,
status,
statusText: '',
json: async () => body
})
}
describe('getSurveyCompletedStatus', () => {
beforeEach(() => {
fetchApi.mockReset()
})
test('200 with non-empty value → true', async () => {
fetchApi.mockResolvedValueOnce(
mockResponse({ ok: true, status: 200, body: { value: { q1: 'a' } } })
)
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
})
test('200 with empty value → false (the only "not completed" signal)', async () => {
fetchApi.mockResolvedValueOnce(
mockResponse({ ok: true, status: 200, body: { value: {} } })
)
await expect(getSurveyCompletedStatus()).resolves.toBe(false)
})
test('200 with null value → false', async () => {
fetchApi.mockResolvedValueOnce(
mockResponse({ ok: true, status: 200, body: { value: null } })
)
await expect(getSurveyCompletedStatus()).resolves.toBe(false)
})
test('200 with missing value key → false', async () => {
fetchApi.mockResolvedValueOnce(
mockResponse({ ok: true, status: 200, body: {} })
)
await expect(getSurveyCompletedStatus()).resolves.toBe(false)
})
test('404 → false (key never stored = genuinely not completed)', async () => {
fetchApi.mockResolvedValueOnce(mockResponse({ ok: false, status: 404 }))
await expect(getSurveyCompletedStatus()).resolves.toBe(false)
})
test('500 → true (do not bounce on transient backend error)', async () => {
fetchApi.mockResolvedValueOnce(mockResponse({ ok: false, status: 500 }))
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
})
// 401/403/5xx stay under the "ambiguous => treat as completed" fail-safe;
// 404 is the one non-ok we disambiguate, since it's the real not-completed
// signal. The dedicated auth layer handles re-authentication on the next API
// call; this function deliberately does not try to recover auth failures
// itself. Locking with tests so the policy can't drift back to a "throw on
// auth error" branch.
test('401 → true (auth layer handles re-auth on next call)', async () => {
fetchApi.mockResolvedValueOnce(mockResponse({ ok: false, status: 401 }))
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
})
test('403 → true (auth layer handles re-auth on next call)', async () => {
fetchApi.mockResolvedValueOnce(mockResponse({ ok: false, status: 403 }))
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
})
test('network rejection → true (do not bounce on network error)', async () => {
fetchApi.mockRejectedValueOnce(new TypeError('Network request failed'))
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
})
})

View File

@@ -95,24 +95,30 @@ export async function getSurveyCompletedStatus(): Promise<boolean> {
'Content-Type': 'application/json'
}
})
// 404 = the survey key was never stored = genuinely not completed. Only
// reachable after a successful authenticated read (a stale token returns
// 401, never 404), so it can't be a transient-auth false signal.
if (response.status === 404) {
return false
}
if (!response.ok) {
// Not an error case - survey not completed is a valid state
// Other non-ok (401/403/5xx): treat as completed so a transient failure
// never bounces a working user to /cloud/survey.
Sentry.addBreadcrumb({
category: 'auth',
message: 'Survey status check returned non-ok response',
level: 'info',
level: 'warning',
data: {
status: response.status,
endpoint: `/settings/${ONBOARDING_SURVEY_KEY}`
}
})
return false
return true
}
const data = await response.json()
// Check if data exists and is not empty
return !isEmpty(data.value)
} catch (error) {
// Network error - still capture it as it's not thrown from above
// Network/parse failure: same fail-safe policy as a non-ok response.
Sentry.captureException(error, {
tags: {
api_endpoint: '/settings/{key}',
@@ -124,7 +130,7 @@ export async function getSurveyCompletedStatus(): Promise<boolean> {
},
level: 'warning'
})
return false
return true
}
}

View File

@@ -0,0 +1,114 @@
import type * as VueUseCore from '@vueuse/core'
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 SubscribeToRun from './SubscribeToRun.vue'
const mockShowSubscriptionDialog = vi.fn()
const mockCanManageSubscription = ref(true)
const mockIsMdOrLarger = ref(true)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
showSubscriptionDialog: mockShowSubscriptionDialog
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: computed(() => ({
canManageSubscription: mockCanManageSubscription.value
}))
})
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal<typeof VueUseCore>()
return {
...actual,
useBreakpoints: () => ({
greaterOrEqual: () => mockIsMdOrLarger
})
}
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subscription: {
subscribeToRun: 'Subscribe',
subscribeToRunFull: 'Subscribe to Run',
inactive: {
runLabel: 'Run',
memberRunTooltip: 'Contact your workspace owner to resubscribe'
}
}
}
}
})
function renderButton() {
const user = userEvent.setup()
const result = render(SubscribeToRun, {
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
return { ...result, user }
}
describe('SubscribeToRun', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanManageSubscription.value = true
mockIsMdOrLarger.value = true
})
it('shows the subscribe label for owners who can manage the subscription', () => {
renderButton()
expect(screen.getByTestId('subscribe-to-run-button')).toHaveTextContent(
'Subscribe to Run'
)
})
it('shows a neutral run label for members who cannot subscribe', () => {
mockCanManageSubscription.value = false
renderButton()
const button = screen.getByTestId('subscribe-to-run-button')
expect(button).toHaveTextContent('Run')
expect(button).not.toHaveTextContent('Subscribe')
})
it('opens the subscription dialog for owners on click', async () => {
const { user } = renderButton()
await user.click(screen.getByTestId('subscribe-to-run-button'))
expect(mockShowSubscriptionDialog).toHaveBeenCalledOnce()
})
it('routes members to the same role-aware dialog on click', async () => {
mockCanManageSubscription.value = false
const { user } = renderButton()
await user.click(screen.getByTestId('subscribe-to-run-button'))
expect(mockShowSubscriptionDialog).toHaveBeenCalledOnce()
})
})

View File

@@ -1,7 +1,7 @@
<template>
<Button
v-tooltip.bottom="{
value: $t('subscription.subscribeToRunFull'),
value: buttonTooltip,
showDelay: 600
}"
class="subscribe-to-run-button whitespace-nowrap"
@@ -24,20 +24,31 @@ import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
const { t } = useI18n()
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMdOrLarger = breakpoints.greaterOrEqual('md')
const buttonLabel = computed(() =>
isMdOrLarger.value
? t('subscription.subscribeToRunFull')
: t('subscription.subscribeToRun')
)
const { permissions } = useWorkspaceUI()
const { showSubscriptionDialog } = useBillingContext()
const handleSubscribeToRun = () => {
const canResubscribe = computed(() => permissions.value.canManageSubscription)
const buttonLabel = computed(() => {
if (!canResubscribe.value) return t('subscription.inactive.runLabel')
return isMdOrLarger.value
? t('subscription.subscribeToRunFull')
: t('subscription.subscribeToRun')
})
const buttonTooltip = computed(() =>
canResubscribe.value
? t('subscription.subscribeToRunFull')
: t('subscription.inactive.memberRunTooltip')
)
function handleSubscribeToRun() {
if (isCloud) {
useTelemetry()?.trackRunButton({ subscribe_to_run: true })
}

View File

@@ -4,6 +4,7 @@ import { useDialogStore } from '@/stores/dialogStore'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
const DIALOG_KEY = 'subscription-required'
@@ -20,6 +21,7 @@ export const useSubscriptionDialog = () => {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const { permissions } = useWorkspaceUI()
const { isFreeTier } = useSubscription()
function hide() {
@@ -30,6 +32,33 @@ export const useSubscriptionDialog = () => {
function showPricingTable(options?: { reason?: SubscriptionDialogReason }) {
if (!isCloud) return
// Members can't manage the workspace subscription, so a blocked run shows a
// small read-only "ask your owner to reactivate" modal instead of the
// pricing table. Out-of-credits still routes everyone to the credits flow.
if (
flags.teamWorkspacesEnabled &&
!workspaceStore.isInPersonalWorkspace &&
!permissions.value.canManageSubscription &&
options?.reason !== 'out_of_credits'
) {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/SubscriptionInactiveMemberDialog.vue')
),
props: { onClose: hide },
dialogComponentProps: {
style: 'width: min(360px, 95vw);',
pt: {
root: { class: 'bg-transparent' },
content: { class: '!p-0 bg-transparent border-none shadow-none' }
}
}
})
return
}
const useWorkspaceVariant =
flags.teamWorkspacesEnabled && !workspaceStore.isInPersonalWorkspace

View File

@@ -1,24 +1,37 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type {
MissingModelGroup,
MissingModelViewModel
} from '@/platform/missingModel/types'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
vi.mock('./MissingModelRow.vue', () => ({
default: {
name: 'MissingModelRow',
template:
'<div class="model-row" :data-show-node-id-badge="showNodeIdBadge" :data-is-asset-supported="isAssetSupported" :data-directory="directory"><button class="locate-trigger" @click="$emit(\'locate-model\', model?.representative?.nodeId)">Locate</button></div>',
props: ['model', 'directory', 'showNodeIdBadge', 'isAssetSupported'],
template: `
<div
data-testid="model-row"
class="model-row"
:data-model-name="model.name"
:data-is-asset-supported="isAssetSupported"
:data-directory="directory"
:data-can-cloud-import="canCloudImport"
>
<button
class="locate-trigger"
@click="$emit('locate-model', model?.representative?.nodeId)"
>
Locate
</button>
</div>
`,
props: ['model', 'directory', 'isAssetSupported', 'canCloudImport'],
emits: ['locate-model']
}
}))
@@ -35,21 +48,7 @@ import MissingModelCard from './MissingModelCard.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
missingModels: {
importNotSupported: 'Import Not Supported',
customNodeDownloadDisabled:
'Cloud environment does not support model imports for custom nodes.',
unknownCategory: 'Unknown Category',
downloadAll: 'Download all',
refresh: 'Refresh',
refreshing: 'Refreshing missing models.'
}
}
}
},
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false
})
@@ -106,7 +105,6 @@ function makeGroup(
function mountCard(
props: Partial<{
missingModelGroups: MissingModelGroup[]
showNodeIdBadge: boolean
}> = {},
onLocateModel?: (nodeId: string) => void
) {
@@ -114,7 +112,6 @@ function mountCard(
return render(MissingModelCard, {
props: {
missingModelGroups: [makeGroup()],
showNodeIdBadge: false,
...props,
...(onLocateModel ? { onLocateModel } : {})
},
@@ -124,62 +121,115 @@ function mountCard(
})
}
function getRows() {
return screen.queryAllByTestId('model-row')
}
function getRowsIn(testId: string) {
return within(screen.getByTestId(testId)).getAllByTestId('model-row')
}
describe('MissingModelCard', () => {
beforeEach(() => {
mockIsCloud.value = true
})
describe('Rendering & Props', () => {
it('renders directory name in category header', () => {
const { container } = mountCard({
it('passes the model directory to rows', () => {
mockIsCloud.value = false
mountCard({
missingModelGroups: [makeGroup({ directory: 'loras' })]
})
expect(container.textContent).toContain('loras')
})
it('renders translated unknown category when directory is null', () => {
const { container } = mountCard({
missingModelGroups: [makeGroup({ directory: null })]
})
expect(container.textContent).toContain('Unknown Category')
})
it('renders model count in category header', () => {
const { container } = mountCard({
missingModelGroups: [
makeGroup({ modelNames: ['a.safetensors', 'b.safetensors'] })
]
})
expect(container.textContent).toContain('(2)')
expect(getRows()[0].getAttribute('data-directory')).toBe('loras')
})
it('renders correct number of MissingModelRow components', () => {
const { container } = mountCard({
mountCard({
missingModelGroups: [
makeGroup({
modelNames: ['a.safetensors', 'b.safetensors', 'c.safetensors']
})
]
})
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelectorAll('.model-row')).toHaveLength(3)
expect(getRows()).toHaveLength(3)
})
it('renders multiple groups', () => {
const { container } = mountCard({
it('flattens multiple groups into rows', () => {
mockIsCloud.value = false
mountCard({
missingModelGroups: [
makeGroup({ directory: 'checkpoints' }),
makeGroup({ directory: 'loras' })
]
})
expect(container.textContent).toContain('checkpoints')
expect(container.textContent).toContain('loras')
expect(getRows()).toHaveLength(2)
})
it('sorts importable rows by model type order in cloud', () => {
mountCard({
missingModelGroups: [
makeGroup({ directory: null, modelNames: ['unknown.safetensors'] }),
makeGroup({ directory: 'loras', modelNames: ['lora.safetensors'] }),
makeGroup({
directory: 'checkpoints',
modelNames: ['checkpoint.safetensors']
})
]
})
expect(
getRowsIn('missing-model-importable-rows').map((row) =>
row.getAttribute('data-model-name')
)
).toEqual(['checkpoint.safetensors', 'lora.safetensors'])
})
it('moves cloud rows without import context into the unsupported section', () => {
mountCard({
missingModelGroups: [
makeGroup({
directory: 'checkpoints',
modelNames: ['importable.safetensors']
}),
makeGroup({
directory: null,
modelNames: ['unknown.safetensors']
}),
makeGroup({
directory: 'loras',
isAssetSupported: false,
modelNames: ['custom-node-model.safetensors']
})
]
})
expect(
getRowsIn('missing-model-importable-rows').map((row) =>
row.getAttribute('data-model-name')
)
).toEqual(['importable.safetensors'])
const unsupportedSection = screen.getByTestId(
'missing-model-import-not-supported-section'
)
expect(
within(unsupportedSection)
.getAllByTestId('model-row')
.map((row) => row.getAttribute('data-model-name'))
).toEqual(['custom-node-model.safetensors', 'unknown.safetensors'])
expect(
within(unsupportedSection).getByText('Import Not Supported')
).toBeInTheDocument()
expect(
within(unsupportedSection).getByText(
/Nodes that reference the models below do not support imported models/
)
).toBeInTheDocument()
})
it('renders zero rows when missingModelGroups is empty', () => {
const { container } = mountCard({ missingModelGroups: [] })
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelectorAll('.model-row')).toHaveLength(0)
mountCard({ missingModelGroups: [] })
expect(getRows()).toHaveLength(0)
})
it('hides bulk actions in cloud', () => {
@@ -191,43 +241,6 @@ describe('MissingModelCard', () => {
screen.queryByTestId('missing-model-actions')
).not.toBeInTheDocument()
})
it('passes props correctly to MissingModelRow children', () => {
const { container } = mountCard({ showNodeIdBadge: true })
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const row = container.querySelector('.model-row')
expect(row).not.toBeNull()
expect(row!.getAttribute('data-show-node-id-badge')).toBe('true')
expect(row!.getAttribute('data-is-asset-supported')).toBe('true')
expect(row!.getAttribute('data-directory')).toBe('checkpoints')
})
})
describe('Asset Unsupported Group', () => {
it('shows "Import Not Supported" header for unsupported groups', () => {
const { container } = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: false })]
})
expect(container.textContent).toContain('Import Not Supported')
})
it('shows info notice for unsupported groups', () => {
const { container } = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: false })]
})
expect(container.textContent).toContain(
'Cloud environment does not support model imports'
)
})
it('hides info notice for supported groups', () => {
const { container } = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: true })]
})
expect(container.textContent).not.toContain(
'Cloud environment does not support model imports'
)
})
})
describe('Event Handling', () => {
@@ -251,79 +264,43 @@ describe('MissingModelCard (OSS)', () => {
})
it('shows directory name instead of "Import Not Supported" for unsupported groups', () => {
const { container } = mountCard({
mountCard({
missingModelGroups: [
makeGroup({ directory: 'checkpoints', isAssetSupported: false })
]
})
expect(container.textContent).toContain('checkpoints')
expect(container.textContent).not.toContain('Import Not Supported')
expect(getRows()[0].getAttribute('data-directory')).toBe('checkpoints')
})
it('hides info notice for unsupported groups', () => {
const { container } = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: false })]
})
expect(container.textContent).not.toContain(
'Cloud environment does not support model imports'
)
})
it('renders unknown category for null directory in OSS', () => {
const { container } = mountCard({
it('passes null directory for unknown category rows in OSS', () => {
mountCard({
missingModelGroups: [
makeGroup({ directory: null, isAssetSupported: false })
]
})
expect(container.textContent).toContain('Unknown Category')
expect(container.textContent).not.toContain('Import Not Supported')
expect(getRows()[0].hasAttribute('data-directory')).toBe(false)
})
it('shows bulk actions when one model is downloadable', () => {
it('shows Download all at the bottom when one model is downloadable', () => {
mountCard({
missingModelGroups: [makeGroup({ withDownloadUrls: true })]
})
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
const actions = screen.getByTestId('missing-model-actions')
expect(actions).toBeVisible()
expect(
within(actions).getByRole('button', { name: /Download all/ })
).toBeVisible()
})
it('hides bulk actions when no model is downloadable', () => {
it('hides Download all when no model is downloadable', () => {
mountCard()
expect(
screen.queryByRole('button', { name: /Download all/ })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Refresh' })
screen.queryByTestId('missing-model-actions')
).not.toBeInTheDocument()
})
it('refreshes missing models from the action bar', async () => {
mountCard({
missingModelGroups: [makeGroup({ withDownloadUrls: true })]
})
const store = useMissingModelStore()
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }))
expect(store.refreshMissingModels).toHaveBeenCalled()
})
it('keeps the Refresh button focusable and announces refresh progress', async () => {
mountCard({
missingModelGroups: [makeGroup({ withDownloadUrls: true })]
})
const store = useMissingModelStore()
store.isRefreshingMissingModels = true
await nextTick()
const refreshButton = screen.getByRole('button', { name: 'Refresh' })
expect(refreshButton).toHaveAttribute('aria-disabled', 'true')
expect(refreshButton).toHaveAttribute('aria-busy', 'true')
expect(screen.getByRole('status')).toHaveTextContent(
'Refreshing missing models.'
)
})
})

View File

@@ -1,9 +1,49 @@
<template>
<div class="px-4 pb-2">
<div
v-if="importableModelRows.length > 0"
data-testid="missing-model-importable-rows"
class="flex flex-col gap-1 overflow-hidden py-2"
>
<MissingModelRow
v-for="row in importableModelRows"
:key="row.key"
:model="row.model"
:directory="row.directory"
:is-asset-supported="row.isAssetSupported"
:can-cloud-import="true"
@locate-model="emit('locateModel', $event)"
/>
</div>
<div
v-if="unsupportedModelRows.length > 0"
data-testid="missing-model-import-not-supported-section"
class="flex flex-col gap-1 border-t border-interface-stroke pt-3"
>
<div class="mb-1">
<p class="m-0 text-sm font-semibold text-warning-background">
{{ t('rightSidePanel.missingModels.importNotSupported') }}
</p>
<p class="m-0 mt-1 text-xs/relaxed text-muted-foreground">
{{ t('rightSidePanel.missingModels.customNodeDownloadDisabled') }}
</p>
</div>
<MissingModelRow
v-for="row in unsupportedModelRows"
:key="row.key"
:model="row.model"
:directory="row.directory"
:is-asset-supported="row.isAssetSupported"
:can-cloud-import="false"
@locate-model="emit('locateModel', $event)"
/>
</div>
<div
v-if="downloadableModels.length > 0"
data-testid="missing-model-actions"
class="flex items-center gap-2 border-b border-interface-stroke py-2"
class="flex items-center pt-2"
>
<Button
data-testid="missing-model-download-all"
@@ -15,100 +55,6 @@
<i aria-hidden="true" class="icon-[lucide--download] size-4 shrink-0" />
<span class="truncate">{{ downloadAllLabel }}</span>
</Button>
<!-- Keep this focusable while refreshing so the live status remains discoverable. -->
<Button
data-testid="missing-model-refresh"
variant="secondary"
size="sm"
class="h-8 w-28 shrink-0 rounded-lg text-sm"
:aria-busy="missingModelStore.isRefreshingMissingModels"
:aria-disabled="missingModelStore.isRefreshingMissingModels"
@click="handleRefreshClick"
>
<DotSpinner
v-if="missingModelStore.isRefreshingMissingModels"
aria-hidden="true"
duration="1s"
:size="12"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--refresh-cw] size-4 shrink-0"
/>
{{ t('rightSidePanel.missingModels.refresh') }}
</Button>
<span role="status" aria-live="polite" class="sr-only">
{{
missingModelStore.isRefreshingMissingModels
? t('rightSidePanel.missingModels.refreshing')
: ''
}}
</span>
</div>
<!-- Category groups (by directory) -->
<div
v-for="group in missingModelGroups"
:key="`${group.isAssetSupported ? 'supported' : 'unsupported'}::${group.directory ?? '__unknown__'}`"
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
>
<!-- Category header -->
<div class="flex h-8 w-full items-center">
<p
class="min-w-0 flex-1 truncate text-sm font-medium"
:class="
(isCloud && !group.isAssetSupported) || group.directory === null
? 'text-warning-background'
: 'text-destructive-background-hover'
"
>
<span v-if="isCloud && !group.isAssetSupported">
{{ t('rightSidePanel.missingModels.importNotSupported') }}
({{ group.models.length }})
</span>
<span v-else>
<i
v-if="group.directory === null"
aria-hidden="true"
class="mr-1 icon-[lucide--triangle-alert] size-3.5 align-text-bottom"
/>
{{
group.directory ??
t('rightSidePanel.missingModels.unknownCategory')
}}
({{ group.models.length }})
</span>
</p>
</div>
<!-- Asset unsupported group notice -->
<div
v-if="isCloud && !group.isAssetSupported"
data-testid="missing-model-import-unsupported"
class="flex items-start gap-1.5 px-0.5 py-1 pl-2"
>
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--info] size-3.5 shrink-0 text-muted-foreground"
/>
<span class="text-xs/tight text-muted-foreground">
{{ t('rightSidePanel.missingModels.customNodeDownloadDisabled') }}
</span>
</div>
<!-- Model rows -->
<div class="flex flex-col gap-1 overflow-hidden pl-2">
<MissingModelRow
v-for="model in group.models"
:key="model.name"
:model="model"
:directory="group.directory"
:show-node-id-badge="showNodeIdBadge"
:is-asset-supported="group.isAssetSupported"
@locate-model="emit('locateModel', $event)"
/>
</div>
</div>
</div>
</template>
@@ -120,15 +66,28 @@ import type { MissingModelGroup } from '@/platform/missingModel/types'
import { isCloud } from '@/platform/distribution/types'
import MissingModelRow from '@/platform/missingModel/components/MissingModelRow.vue'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { downloadModel } from '@/platform/missingModel/missingModelDownload'
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { formatSize } from '@/utils/formatUtil'
const { missingModelGroups, showNodeIdBadge } = defineProps<{
interface MissingModelRowEntry {
key: string
model: MissingModelGroup['models'][number]
directory: string | null
isAssetSupported: boolean
}
const MODEL_TYPE_SORT_ORDER = [
'checkpoints',
'loras',
'vae',
'text_encoders',
'diffusion_models'
] as const
const { missingModelGroups } = defineProps<{
missingModelGroups: MissingModelGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
@@ -138,6 +97,27 @@ const emit = defineEmits<{
const { t } = useI18n()
const missingModelStore = useMissingModelStore()
const sortedModelRows = computed(() =>
missingModelGroups
.flatMap((group) =>
group.models.map((model, index) => ({
key: getModelRowKey(group, model, index),
model,
directory: group.directory,
isAssetSupported: group.isAssetSupported
}))
)
.sort((a, b) => compareModelRows(a, b))
)
const importableModelRows = computed(() =>
sortedModelRows.value.filter((row) => !isCloud || canCloudImport(row))
)
const unsupportedModelRows = computed(() =>
isCloud ? sortedModelRows.value.filter((row) => !canCloudImport(row)) : []
)
const downloadableModels = computed(() => {
if (isCloud) return []
@@ -159,7 +139,37 @@ function downloadAllModels() {
}
}
function handleRefreshClick() {
void missingModelStore.refreshMissingModels()
function getModelRowKey(
group: MissingModelGroup,
model: MissingModelGroup['models'][number],
index: number
) {
const supportKey = group.isAssetSupported ? 'supported' : 'unsupported'
return [
supportKey,
group.directory ?? '__unknown__',
model.name,
String(index)
].join('::')
}
function compareModelRows(a: MissingModelRowEntry, b: MissingModelRowEntry) {
return (
getModelTypeSortIndex(a.directory) - getModelTypeSortIndex(b.directory) ||
(a.directory ?? '').localeCompare(b.directory ?? '') ||
a.model.name.localeCompare(b.model.name)
)
}
function getModelTypeSortIndex(directory: string | null) {
if (directory === null) return Number.MAX_SAFE_INTEGER
const index = MODEL_TYPE_SORT_ORDER.indexOf(
directory as (typeof MODEL_TYPE_SORT_ORDER)[number]
)
return index === -1 ? MODEL_TYPE_SORT_ORDER.length : index
}
function canCloudImport(row: MissingModelRowEntry) {
return row.isAssetSupported && row.directory !== null
}
</script>

View File

@@ -1,113 +0,0 @@
<template>
<div class="flex flex-col gap-2">
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
<span class="text-xs font-bold text-muted-foreground">
{{ t('rightSidePanel.missingModels.or') }}
</span>
</div>
<Select
:model-value="modelValue"
:disabled="options.length === 0"
@update:model-value="handleSelect"
>
<SelectTrigger
size="md"
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
>
<SelectValue
:placeholder="t('rightSidePanel.missingModels.useFromLibrary')"
/>
</SelectTrigger>
<SelectContent>
<template v-if="options.length > 4" #prepend>
<div class="px-1 pb-1.5">
<div
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
>
<i
aria-hidden="true"
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
/>
<input
v-model="filterQuery"
type="text"
:aria-label="t('g.searchPlaceholder', { subject: '' })"
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
:placeholder="t('g.searchPlaceholder', { subject: '' })"
@keydown.stop
/>
</div>
</div>
</template>
<SelectItem
v-for="option in filteredOptions"
:key="option.value"
:value="option.value"
class="text-xs"
>
{{ option.name }}
</SelectItem>
<div
v-if="filteredOptions.length === 0"
role="status"
class="px-3 py-2 text-xs text-muted-foreground"
>
{{ t('g.noResultsFound') }}
</div>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFuse } from '@vueuse/integrations/useFuse'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
const { options, showDivider = false } = defineProps<{
modelValue: string | undefined
options: { name: string; value: string }[]
showDivider?: boolean
}>()
const emit = defineEmits<{
select: [value: string]
}>()
const { t } = useI18n()
const filterQuery = ref('')
watch(
() => options.length,
(len) => {
if (len <= 4) filterQuery.value = ''
}
)
const { results: fuseResults } = useFuse(filterQuery, () => options, {
fuseOptions: {
keys: ['name'],
threshold: 0.4,
ignoreLocation: true
},
matchAllWhenSearchEmpty: true
})
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
function handleSelect(value: unknown) {
if (typeof value === 'string') {
filterQuery.value = ''
emit('select', value)
}
}
</script>

View File

@@ -0,0 +1,460 @@
import { createPinia, setActivePinia } from 'pinia'
import { render, screen, waitFor } 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 enMessages from '@/locales/en/main.json' with { type: 'json' }
import type {
UploadModelDialogContext,
UploadModelSuccess
} from '@/platform/assets/composables/useUploadModelWizard'
import type { MissingModelViewModel } from '@/platform/missingModel/types'
import type * as MissingModelDownload from '@/platform/missingModel/missingModelDownload'
import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
const mockIsCloud = vi.hoisted(() => ({ value: true }))
const mockShowUploadDialog = vi.hoisted(() => vi.fn())
const mockCopyToClipboard = vi.hoisted(() => vi.fn())
const mockDownloadModel = vi.hoisted(() => vi.fn())
const mockRootGraph = vi.hoisted<{
value: Record<string, never> | null
}>(() => ({ value: null }))
const mockGetNodeByExecutionId = vi.hoisted(() => vi.fn())
const mockApiListeners = vi.hoisted(
() => new Map<string, (event: CustomEvent) => void>()
)
type UploadModelContextResolver = () => UploadModelDialogContext | undefined
const mockUploadContext = vi.hoisted(() => ({
resolver: undefined as UploadModelContextResolver | undefined
}))
const mockUploadCallbacks = vi.hoisted(() => ({
onUploadSuccess: undefined as
| ((result: UploadModelSuccess) => Promise<unknown> | unknown)
| undefined
}))
vi.mock('@/scripts/app', () => ({
app: {
get rootGraph() {
return mockRootGraph.value
}
}
}))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(
(event: string, handler: (event: CustomEvent) => void) => {
mockApiListeners.set(event, handler)
}
),
apiURL: vi.fn((path: string) => path),
fetchApi: vi.fn()
}
}))
vi.mock('@/utils/graphTraversalUtil', async () => {
const actual = await vi.importActual<typeof GraphTraversalUtil>(
'@/utils/graphTraversalUtil'
)
return {
...actual,
getActiveGraphNodeIds: vi.fn(() => new Set()),
getNodeByExecutionId: mockGetNodeByExecutionId
}
})
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
vi.mock('@/platform/assets/composables/useModelUpload', () => ({
useModelUpload: (
onUploadSuccess?: (
result: UploadModelSuccess
) => Promise<unknown> | unknown,
uploadContext?: UploadModelDialogContext | UploadModelContextResolver
) => {
mockUploadCallbacks.onUploadSuccess = onUploadSuccess
mockUploadContext.resolver =
typeof uploadContext === 'function' ? uploadContext : () => uploadContext
return {
isUploadButtonEnabled: { value: true },
showUploadDialog: mockShowUploadDialog
}
}
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({
copyToClipboard: mockCopyToClipboard
})
}))
vi.mock('@/platform/missingModel/missingModelDownload', async () => {
const actual = await vi.importActual<typeof MissingModelDownload>(
'@/platform/missingModel/missingModelDownload'
)
return {
...actual,
downloadModel: mockDownloadModel,
fetchModelMetadata: vi.fn().mockResolvedValue({
fileSize: null,
gatedRepoUrl: null
})
}
})
import MissingModelRow from './MissingModelRow.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false
})
const TransitionCollapseStub = {
name: 'TransitionCollapse',
template: '<div><slot /></div>'
}
function makeModel(
refs: MissingModelViewModel['referencingNodes']
): MissingModelViewModel {
return {
name: 'model.safetensors',
representative: {
nodeId: refs[0]?.nodeId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
name: 'model.safetensors',
directory: 'checkpoints',
url: 'https://example.com/model.safetensors',
isMissing: true
},
referencingNodes: refs
}
}
function renderRow(
model: MissingModelViewModel,
onLocateModel = vi.fn(),
isAssetSupported = true,
directory: string | null = 'checkpoints',
canCloudImport = true
) {
const pinia = createPinia()
setActivePinia(pinia)
render(MissingModelRow, {
props: {
model,
directory,
isAssetSupported,
canCloudImport,
onLocateModel
},
global: {
plugins: [pinia, i18n],
stubs: {
TransitionCollapse: TransitionCollapseStub
}
}
})
return { onLocateModel }
}
describe('MissingModelRow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockRootGraph.value = null
mockApiListeners.clear()
mockGetNodeByExecutionId.mockReset()
mockUploadContext.resolver = undefined
mockUploadCallbacks.onUploadSuccess = undefined
})
it('opens the model import dialog from the cloud row', async () => {
const user = userEvent.setup()
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
await user.click(screen.getByRole('button', { name: 'Import' }))
expect(mockShowUploadDialog).toHaveBeenCalledTimes(1)
expect(mockUploadContext.resolver?.()).toEqual({
kind: 'missing-model-resolution',
missingModelName: 'model.safetensors',
requiredModelType: 'checkpoints',
replacementTargets: [
{
nodeId: '1',
nodeLabel: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name'
}
]
})
})
it('keeps unsupported cloud rows as reference-only rows', () => {
renderRow(
makeModel([{ nodeId: '1', widgetName: 'model_name' }]),
vi.fn(),
true,
null,
false
)
expect(screen.getByText('model.safetensors')).toBeInTheDocument()
expect(screen.getByText('Unknown')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'CheckpointLoaderSimple' })
).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Import' })).toBeNull()
})
it('shows row progress as soon as the model import starts', async () => {
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
const store = useMissingModelStore()
await mockUploadCallbacks.onUploadSuccess?.({
filename: 'downloaded-model.safetensors',
modelType: 'checkpoints',
taskId: 'task-1',
status: 'processing'
})
await nextTick()
expect(
store.importTaskIds['supported::checkpoints::model.safetensors']
).toBe('task-1')
expect(
screen.getByRole('progressbar', { name: 'Importing...' })
).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Importing...')
expect(screen.getByText('downloaded-model.safetensors')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Import' })).toBeNull()
})
it('applies the completed imported model to every referencing node', async () => {
const graph = {}
const firstWidget = {
name: 'ckpt_name',
value: 'old-first.safetensors',
callback: vi.fn()
}
const secondWidget = {
name: 'ckpt_name',
value: 'old-second.safetensors',
callback: vi.fn()
}
const firstSetDirtyCanvas = vi.fn()
const secondSetDirtyCanvas = vi.fn()
mockRootGraph.value = graph
mockGetNodeByExecutionId.mockImplementation((_graph, nodeId) => {
if (nodeId === '1') {
return {
widgets: [firstWidget],
graph: { setDirtyCanvas: firstSetDirtyCanvas }
}
}
if (nodeId === '2') {
return {
widgets: [secondWidget],
graph: { setDirtyCanvas: secondSetDirtyCanvas }
}
}
return null
})
renderRow(
makeModel([
{ nodeId: '1', widgetName: 'ckpt_name' },
{ nodeId: '2', widgetName: 'ckpt_name' }
])
)
await mockUploadCallbacks.onUploadSuccess?.({
filename: 'client-name.safetensors',
modelType: 'checkpoints',
taskId: 'task-1',
status: 'processing'
})
await nextTick()
const handler = mockApiListeners.get('asset_download')
expect(handler).toBeDefined()
handler!(
new CustomEvent('asset_download', {
detail: {
task_id: 'task-1',
asset_name: 'server-name.safetensors',
bytes_total: 100,
bytes_downloaded: 100,
progress: 1,
status: 'completed'
}
})
)
await waitFor(() => {
expect(firstWidget.value).toBe('server-name.safetensors')
expect(secondWidget.value).toBe('server-name.safetensors')
})
expect(firstWidget.callback).toHaveBeenCalledWith('server-name.safetensors')
expect(secondWidget.callback).toHaveBeenCalledWith(
'server-name.safetensors'
)
expect(firstSetDirtyCanvas).toHaveBeenCalledWith(true, true)
expect(secondSetDirtyCanvas).toHaveBeenCalledWith(true, true)
})
it('locates the parent row directly when a cloud model has one reference', async () => {
const user = userEvent.setup()
const { onLocateModel } = renderRow(
makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
)
await user.click(screen.getByRole('button', { name: 'model.safetensors' }))
expect(onLocateModel).toHaveBeenCalledWith('1')
})
it('moves locate actions to expanded child rows when a cloud model has multiple references', async () => {
const user = userEvent.setup()
const { onLocateModel } = renderRow(
makeModel([
{ nodeId: '1', widgetName: 'ckpt_name' },
{ nodeId: '2', widgetName: 'ckpt_name' }
])
)
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.queryAllByTestId('missing-model-locate')).toHaveLength(0)
await user.click(
screen.getByRole('button', { name: 'Show referencing nodes' })
)
const locateButtons = screen.getAllByTestId('missing-model-locate')
expect(locateButtons).toHaveLength(2)
await user.click(locateButtons[1])
expect(onLocateModel).toHaveBeenCalledWith('2')
})
it('locates the parent row directly when an OSS model has one reference', async () => {
mockIsCloud.value = false
const user = userEvent.setup()
const { onLocateModel } = renderRow(
makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
)
await user.click(screen.getByRole('button', { name: 'model.safetensors' }))
expect(onLocateModel).toHaveBeenCalledWith('1')
})
it('shows no resolution action in OSS rows without a download url', () => {
mockIsCloud.value = false
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(
screen.queryByTestId('missing-model-download')
).not.toBeInTheDocument()
expect(screen.queryByTestId('missing-model-import')).not.toBeInTheDocument()
})
it('shows model type metadata below the model name', () => {
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
expect(screen.getByText('checkpoints')).toBeInTheDocument()
})
it('shows downloadable model size beside the model type metadata', async () => {
mockIsCloud.value = false
const model = makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
model.representative.url =
'https://huggingface.co/comfy/test/resolve/main/model.safetensors'
renderRow(model, vi.fn(), false)
const store = useMissingModelStore()
store.fileSizes[model.representative.url] = 14 * 1024 ** 3
await nextTick()
expect(screen.getByText('checkpoints · 14 GB')).toBeInTheDocument()
expect(screen.getByTestId('missing-model-download')).toHaveTextContent(
'Download'
)
})
it('shows unknown category metadata for models without a directory', () => {
renderRow(
makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]),
vi.fn(),
true,
null
)
expect(screen.getByText('Unknown')).toBeInTheDocument()
})
it('moves locate actions to expanded child rows when an OSS model has multiple references', async () => {
mockIsCloud.value = false
const user = userEvent.setup()
const { onLocateModel } = renderRow(
makeModel([
{ nodeId: '1', widgetName: 'ckpt_name' },
{ nodeId: '2', widgetName: 'ckpt_name' }
])
)
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.queryAllByTestId('missing-model-locate')).toHaveLength(0)
await user.click(
screen.getByRole('button', { name: 'Show referencing nodes' })
)
const locateButtons = screen.getAllByTestId('missing-model-locate')
expect(locateButtons).toHaveLength(2)
await user.click(locateButtons[1])
expect(onLocateModel).toHaveBeenCalledWith('2')
})
it('shows the OSS download action in the row for downloadable models', async () => {
mockIsCloud.value = false
const user = userEvent.setup()
const model = makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
model.representative.url =
'https://huggingface.co/comfy/test/resolve/main/model.safetensors'
renderRow(model, vi.fn(), false)
await user.click(screen.getByTestId('missing-model-download'))
expect(mockDownloadModel).toHaveBeenCalledWith(
{
name: 'model.safetensors',
url: 'https://huggingface.co/comfy/test/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{}
)
})
})

View File

@@ -1,72 +1,11 @@
<template>
<div class="flex w-full flex-col pb-3">
<!-- Model header -->
<div class="flex h-8 w-full items-center gap-2">
<i
aria-hidden="true"
class="text-foreground icon-[lucide--file-check] size-4 shrink-0"
/>
<div class="flex min-w-0 flex-1 items-center">
<p
class="text-foreground min-w-0 truncate text-sm font-medium"
:title="model.name"
>
{{ model.name }} ({{ model.referencingNodes.length }})
</p>
<Button
data-testid="missing-model-copy-name"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 hover:bg-transparent"
:aria-label="t('rightSidePanel.missingModels.copyModelName')"
:title="t('rightSidePanel.missingModels.copyModelName')"
@click="copyToClipboard(model.name)"
>
<i
aria-hidden="true"
class="icon-[lucide--copy] size-3.5 text-muted-foreground"
/>
</Button>
</div>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div class="flex min-h-8 w-full items-center gap-1">
<Button
v-if="!isCloud && model.representative.url && !isAssetSupported"
data-testid="missing-model-copy-url"
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@click="copyToClipboard(toBrowsableUrl(model.representative.url!))"
>
{{ t('rightSidePanel.missingModels.copyUrl') }}
</Button>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.confirmSelection')"
:disabled="!canConfirm"
:class="
cn(
'size-8 shrink-0 rounded-lg transition-colors',
canConfirm ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
)
"
@click="handleLibrarySelect"
>
<i
aria-hidden="true"
class="icon-[lucide--check] size-4"
:class="canConfirm ? 'text-primary' : 'text-foreground'"
/>
</Button>
<Button
v-if="model.referencingNodes.length > 0"
v-if="hasMultipleReferences"
data-testid="missing-model-expand"
variant="textonly"
size="icon-sm"
size="unset"
:aria-label="
expanded
? t('rightSidePanel.missingModels.collapseNodes')
@@ -75,131 +14,193 @@
:aria-expanded="expanded"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-180'
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
@click="toggleModelExpand(modelKey)"
@click="handleToggleExpand"
>
<i
aria-hidden="true"
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
/>
</Button>
<span class="flex min-w-0 flex-1 flex-col gap-0">
<span class="block min-w-0 text-sm/tight">
<button
v-if="hasModelLabelControl"
ref="modelLabelControl"
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
:title="displayModelName"
@click="handleModelLabelClick"
>
{{ displayModelName }}
</button>
<span
v-else
class="font-normal wrap-break-word text-base-foreground"
:title="displayModelName"
>
{{ displayModelName }}
</span>
<span
v-if="hasMultipleReferences"
data-testid="missing-model-reference-count"
class="ml-2 inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected align-middle text-xs font-bold text-muted-foreground"
>
{{ model.referencingNodes.length }}
</span>
<Button
variant="textonly"
size="icon-sm"
class="ml-2 inline-flex size-7 shrink-0 align-middle text-muted-foreground hover:bg-transparent hover:text-base-foreground"
:aria-label="linkLabel"
:title="linkLabel"
@click="copyModelLink"
>
<i aria-hidden="true" class="icon-[lucide--link] size-4" />
</Button>
</span>
<span
v-if="modelMetadataLabel"
class="block text-2xs/tight"
:class="
isUnknownCategory
? 'text-warning-background'
: 'text-muted-foreground'
"
>
{{ modelMetadataLabel }}
</span>
</span>
<template v-if="isCloud && canCloudImport">
<Button
v-if="!isCloudImportDownloadActive"
data-testid="missing-model-import"
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@click="showUploadDialog"
>
{{ t('g.import') }}
</Button>
<div
v-else
ref="cloudProgress"
role="progressbar"
:aria-label="t('rightSidePanel.missingModels.importing')"
:aria-valuenow="cloudImportProgressPercent"
aria-valuemin="0"
aria-valuemax="100"
tabindex="-1"
class="flex h-8 w-16 shrink-0 items-center"
>
<span
class="block h-1.5 w-full overflow-hidden rounded-full bg-secondary-background-selected"
>
<span
class="block h-full rounded-full bg-primary-background transition-all duration-200 ease-linear"
:style="{ width: `${cloudImportProgressPercent}%` }"
/>
</span>
</div>
<span
v-if="isCloudImportDownloadActive"
role="status"
aria-live="polite"
class="sr-only"
>
{{ t('rightSidePanel.missingModels.importing') }}
</span>
</template>
<template v-else>
<Button
v-if="showDownloadAction"
data-testid="missing-model-download"
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
:aria-label="`${t('g.download')} ${model.name}`"
@click="handleDownload"
>
{{ t('g.download') }}
</Button>
</template>
<Button
v-if="!hasMultipleReferences && !isUnknownCategory && primaryReference"
data-testid="missing-model-locate"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="handleLocatePrimary"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
<!-- Referencing nodes -->
<TransitionCollapse>
<div
v-if="expanded"
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
<ul
v-if="showReferenceList"
:class="
cn(
'm-0 list-none space-y-0.5 p-0',
(hasMultipleReferences || isUnknownCategory) && 'pl-5'
)
"
>
<div
<li
v-for="ref in model.referencingNodes"
:key="`${String(ref.nodeId)}::${ref.widgetName}`"
class="flex h-7 items-center"
class="min-w-0"
>
<span
v-if="showNodeIdBadge"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ ref.nodeId }}
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getNodeDisplayLabel(ref.nodeId, model.representative.nodeType) }}
</p>
<Button
data-testid="missing-model-locate"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateModel', String(ref.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Status card -->
<TransitionCollapse>
<MissingModelStatusCard
v-if="selectedLibraryModel[modelKey]"
:model-name="selectedLibraryModel[modelKey]"
:is-download-active="isDownloadActive"
:download-status="downloadStatus"
:category-mismatch="importCategoryMismatch[modelKey]"
@cancel="cancelLibrarySelect(modelKey)"
/>
</TransitionCollapse>
<!-- Input area -->
<TransitionCollapse>
<div
v-if="!selectedLibraryModel[modelKey]"
class="mt-1 flex flex-col gap-1"
>
<div v-if="isAssetSupported" class="flex w-full flex-col py-1">
<MissingModelUrlInput
:model-key="modelKey"
:directory="directory"
:type-mismatch="typeMismatch"
/>
</div>
<div
v-else-if="!isCloud && downloadable"
class="flex w-full items-start py-1"
>
<Button
data-testid="missing-model-download"
variant="secondary"
size="md"
class="flex w-full flex-1"
:aria-label="`${t('g.download')} ${model.name}`"
@click="handleDownload"
>
<i
aria-hidden="true"
class="text-foreground mr-1 icon-[lucide--download] size-4 shrink-0"
/>
<span class="text-foreground min-w-0 truncate text-sm">
{{ downloadLabel }}
</span>
</Button>
</div>
<TransitionCollapse>
<MissingModelLibrarySelect
v-if="!urlInputs[modelKey]"
:model-value="getComboValue(model.representative)"
:options="comboOptions"
:show-divider="isAssetSupported || downloadable"
@select="handleComboSelect(modelKey, $event)"
/>
</TransitionCollapse>
</div>
<div class="flex min-h-6 min-w-0 items-center gap-2">
<button
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/tight font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
@click="emit('locateModel', String(ref.nodeId))"
>
{{
getNodeDisplayLabel(ref.nodeId, model.representative.nodeType)
}}
</button>
<Button
data-testid="missing-model-locate"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="ml-auto size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateModel', String(ref.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</ul>
</TransitionCollapse>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed, nextTick, onMounted, useTemplateRef, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import MissingModelStatusCard from '@/platform/missingModel/components/MissingModelStatusCard.vue'
import MissingModelUrlInput from '@/platform/missingModel/components/MissingModelUrlInput.vue'
import MissingModelLibrarySelect from '@/platform/missingModel/components/MissingModelLibrarySelect.vue'
import type { MissingModelViewModel } from '@/platform/missingModel/types'
import type { UploadModelDialogContext } from '@/platform/assets/composables/useUploadModelWizard'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
import {
useMissingModelInteractions,
getModelStateKey,
getNodeDisplayLabel,
getComboValue
getNodeDisplayLabel
} from '@/platform/missingModel/composables/useMissingModelInteractions'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
@@ -212,11 +213,16 @@ import {
} from '@/platform/missingModel/missingModelDownload'
import { formatSize } from '@/utils/formatUtil'
const { model, directory, isAssetSupported } = defineProps<{
const {
model,
directory,
isAssetSupported,
canCloudImport = true
} = defineProps<{
model: MissingModelViewModel
directory: string | null
showNodeIdBadge: boolean
isAssetSupported: boolean
canCloudImport?: boolean
}>()
const emit = defineEmits<{
@@ -231,21 +237,117 @@ const modelKey = computed(() =>
)
const downloadStatus = computed(() => getDownloadStatus(modelKey.value))
const comboOptions = computed(() => getComboOptions(model.representative))
const canConfirm = computed(() => isSelectionConfirmable(modelKey.value))
const expanded = computed(() => isModelExpanded(modelKey.value))
const typeMismatch = computed(() => getTypeMismatch(modelKey.value, directory))
const isUnknownCategory = computed(() => directory === null)
const isDownloadActive = computed(
() =>
downloadStatus.value?.status === 'running' ||
downloadStatus.value?.status === 'created'
)
const isCloudImportDownloadActive = computed(
() => isCloud && canCloudImport && isDownloadActive.value
)
const cloudImportProgressPercent = computed(() =>
Math.round((downloadStatus.value?.progress ?? 0) * 100)
)
const hasMultipleReferences = computed(() => model.referencingNodes.length > 1)
const primaryReference = computed(() => model.referencingNodes[0])
const hasModelLabelControl = computed(
() =>
hasMultipleReferences.value ||
(!isUnknownCategory.value && !!primaryReference.value)
)
const linkLabel = computed(() =>
model.representative.url
? t('rightSidePanel.missingModels.copyUrl')
: t('rightSidePanel.missingModels.copyModelName')
)
const store = useMissingModelStore()
const { selectedLibraryModel, importCategoryMismatch, urlInputs } =
storeToRefs(store)
const { selectedLibraryModel } = storeToRefs(store)
const cloudProgress = useTemplateRef<HTMLElement>('cloudProgress')
const modelLabelControl = useTemplateRef<HTMLButtonElement>('modelLabelControl')
const expanded = computed(
() =>
store.modelExpandState[modelKey.value] ??
(isUnknownCategory.value && hasMultipleReferences.value)
)
const showReferenceList = computed(
() =>
(isUnknownCategory.value && model.referencingNodes.length === 1) ||
(hasMultipleReferences.value && expanded.value)
)
const displayModelName = computed(() => {
if (!isCloudImportDownloadActive.value) return model.name
return (
downloadStatus.value?.assetName ??
selectedLibraryModel.value[modelKey.value] ??
model.name
)
})
const downloadable = computed(() => {
const rep = model.representative
return !!(
rep.url &&
rep.directory &&
isModelDownloadable({
name: rep.name,
url: rep.url,
directory: rep.directory
})
)
})
const showDownloadAction = computed(() => !isCloud && downloadable.value)
const downloadSizeLabel = computed(() => {
if (!showDownloadAction.value) return undefined
const url = model.representative.url
const size = url ? store.fileSizes[url] : undefined
return size ? formatSize(size) : undefined
})
const modelTypeLabel = computed(
() => directory ?? t('rightSidePanel.missingModels.unknownCategory')
)
const modelMetadataLabel = computed(() =>
[modelTypeLabel.value, downloadSizeLabel.value].filter(Boolean).join(' · ')
)
const missingModelUploadContext = computed<
UploadModelDialogContext | undefined
>(() => {
if (!canCloudImport || !directory) return undefined
return {
kind: 'missing-model-resolution',
missingModelName: model.name,
requiredModelType: directory,
replacementTargets: model.referencingNodes.map((ref) => ({
nodeId: String(ref.nodeId),
nodeLabel: getNodeDisplayLabel(ref.nodeId, model.representative.nodeType),
widgetName: ref.widgetName
}))
}
})
const { showUploadDialog } = useModelUpload(
(result) => {
handleUploadedModelImport(modelKey.value, result)
if (result.status === 'success') {
handleLibrarySelect()
}
},
() => missingModelUploadContext.value
)
onMounted(() => {
if (isCloud) return
const url = model.representative.url
if (url && !store.fileSizes[url]) {
fetchModelMetadata(url)
@@ -263,27 +365,6 @@ onMounted(() => {
}
})
const downloadable = computed(() => {
const rep = model.representative
return !!(
!isAssetSupported &&
rep.url &&
rep.directory &&
isModelDownloadable({
name: rep.name,
url: rep.url,
directory: rep.directory
})
)
})
const downloadLabel = computed(() => {
const base = t('g.download')
const url = model.representative.url
const size = url ? store.fileSizes[url] : undefined
return size ? `${base} (${formatSize(size)})` : base
})
function handleDownload() {
const rep = model.representative
if (rep.url && rep.directory) {
@@ -296,17 +377,53 @@ function handleDownload() {
}
}
const {
toggleModelExpand,
isModelExpanded,
getComboOptions,
handleComboSelect,
isSelectionConfirmable,
cancelLibrarySelect,
confirmLibrarySelect,
getTypeMismatch,
getDownloadStatus
} = useMissingModelInteractions()
function handleLocatePrimary() {
const ref = primaryReference.value
if (ref) emit('locateModel', String(ref.nodeId))
}
function copyModelLink() {
const url = model.representative.url
copyToClipboard(url ? toBrowsableUrl(url) : model.name)
}
const { confirmLibrarySelect, getDownloadStatus, handleUploadedModelImport } =
useMissingModelInteractions()
function handleToggleExpand() {
store.modelExpandState[modelKey.value] = !expanded.value
}
function handleModelLabelClick() {
if (hasMultipleReferences.value) {
handleToggleExpand()
return
}
handleLocatePrimary()
}
watch(
() => downloadStatus.value?.status,
(status) => {
if (!isCloud || status !== 'completed') return
const completedAssetName = downloadStatus.value?.assetName
if (completedAssetName) {
selectedLibraryModel.value[modelKey.value] = completedAssetName
}
handleLibrarySelect()
},
{ immediate: true }
)
watch(isCloudImportDownloadActive, async (isActive, wasActive) => {
await nextTick()
if (isActive) {
cloudProgress.value?.focus()
} else if (wasActive) {
modelLabelControl.value?.focus()
}
})
function handleLibrarySelect() {
confirmLibrarySelect(

View File

@@ -1,108 +0,0 @@
<template>
<div
aria-live="polite"
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
>
<!-- Progress bar fill -->
<div
v-if="isDownloadActive"
class="absolute inset-y-0 left-0 bg-primary/10 transition-all duration-200 ease-linear"
:style="{ width: (downloadStatus?.progress ?? 0) * 100 + '%' }"
/>
<div class="relative z-10 flex items-center gap-2">
<div class="flex size-8 shrink-0 items-center justify-center">
<i
v-if="categoryMismatch"
aria-hidden="true"
class="mt-0.5 icon-[lucide--triangle-alert] size-5 text-warning-background"
/>
<i
v-else-if="downloadStatus?.status === 'failed'"
aria-hidden="true"
class="icon-[lucide--circle-alert] size-5 text-destructive-background"
/>
<i
v-else-if="downloadStatus?.status === 'completed'"
aria-hidden="true"
class="icon-[lucide--check-circle] size-5 text-success-background"
/>
<i
v-else-if="isDownloadActive"
aria-hidden="true"
class="icon-[lucide--loader-circle] size-5 animate-spin text-muted-foreground"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--file-check] size-5 text-muted-foreground"
/>
</div>
<div class="flex min-w-0 flex-1 flex-col justify-center">
<span class="text-foreground truncate text-xs/tight font-medium">
{{ modelName }}
</span>
<span class="mt-0.5 text-xs/tight text-muted-foreground">
<template v-if="categoryMismatch">
{{
t('rightSidePanel.missingModels.alreadyExistsInCategory', {
category: categoryMismatch
})
}}
</template>
<template v-else-if="isDownloadActive">
{{ t('rightSidePanel.missingModels.importing') }}
{{ Math.round((downloadStatus?.progress ?? 0) * 100) }}%
</template>
<template v-else-if="downloadStatus?.status === 'completed'">
{{ t('rightSidePanel.missingModels.imported') }}
</template>
<template v-else-if="downloadStatus?.status === 'failed'">
{{
downloadStatus?.error ||
t('rightSidePanel.missingModels.importFailed')
}}
</template>
<template v-else>
{{ t('rightSidePanel.missingModels.usingFromLibrary') }}
</template>
</span>
</div>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.cancelSelection')"
class="relative z-10 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('cancel')"
>
<i aria-hidden="true" class="icon-[lucide--circle-x] size-4" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
const {
modelName,
isDownloadActive,
downloadStatus = null,
categoryMismatch = null
} = defineProps<{
modelName: string
isDownloadActive: boolean
downloadStatus?: AssetDownload | null
categoryMismatch?: string | null
}>()
const emit = defineEmits<{
cancel: []
}>()
const { t } = useI18n()
</script>

View File

@@ -1,184 +0,0 @@
import { render, screen, fireEvent } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
const mockPrivateModelsEnabled = vi.hoisted(() => ({ value: true }))
const mockShowUploadDialog = vi.hoisted(() => vi.fn())
const mockHandleUrlInput = vi.hoisted(() => vi.fn())
const mockHandleImport = vi.hoisted(() => vi.fn())
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get privateModelsEnabled() {
return mockPrivateModelsEnabled.value
}
}
})
}))
vi.mock('@/platform/assets/composables/useModelUpload', () => ({
useModelUpload: () => ({
isUploadButtonEnabled: { value: true },
showUploadDialog: mockShowUploadDialog
})
}))
vi.mock(
'@/platform/missingModel/composables/useMissingModelInteractions',
() => ({
useMissingModelInteractions: () => ({
handleUrlInput: mockHandleUrlInput,
handleImport: mockHandleImport
})
})
)
vi.mock('@/components/rightSidePanel/layout/TransitionCollapse.vue', () => ({
default: {
name: 'TransitionCollapse',
template: '<div><slot /></div>'
}
}))
import MissingModelUrlInput from './MissingModelUrlInput.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { loading: 'Loading' },
rightSidePanel: {
missingModels: {
urlPlaceholder: 'Paste model URL...',
clearUrl: 'Clear URL',
import: 'Import',
importAnyway: 'Import Anyway',
typeMismatch: 'Type mismatch: {detectedType}',
unsupportedUrl: 'Unsupported URL',
metadataFetchFailed: 'Failed to fetch metadata',
importFailed: 'Import failed'
}
}
}
},
missingWarn: false,
fallbackWarn: false
})
const MODEL_KEY = 'supported::checkpoints::model.safetensors'
function renderComponent(
props: Partial<{
modelKey: string
directory: string | null
typeMismatch: string | null
}> = {}
) {
return render(MissingModelUrlInput, {
props: {
modelKey: MODEL_KEY,
directory: 'checkpoints',
typeMismatch: null,
...props
},
global: {
plugins: [i18n]
}
})
}
describe('MissingModelUrlInput', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockPrivateModelsEnabled.value = true
mockShowUploadDialog.mockClear()
mockHandleUrlInput.mockClear()
mockHandleImport.mockClear()
})
describe('URL input is always editable', () => {
it('input is editable when privateModelsEnabled is true', () => {
mockPrivateModelsEnabled.value = true
renderComponent()
const input = screen.getByRole('textbox')
expect(input).not.toHaveAttribute('readonly')
})
it('input is editable when privateModelsEnabled is false (free tier)', () => {
mockPrivateModelsEnabled.value = false
renderComponent()
const input = screen.getByRole('textbox')
expect(input).not.toHaveAttribute('readonly')
})
it('input accepts user typing when privateModelsEnabled is false', async () => {
mockPrivateModelsEnabled.value = false
renderComponent()
const input = screen.getByRole('textbox') as HTMLInputElement
input.value = 'https://example.com/model.safetensors'
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.input(input)
expect(mockHandleUrlInput).toHaveBeenCalledWith(
MODEL_KEY,
'https://example.com/model.safetensors'
)
})
})
describe('Import button gates on subscription', () => {
it('calls handleImport when privateModelsEnabled is true', async () => {
mockPrivateModelsEnabled.value = true
const user = userEvent.setup()
const store = useMissingModelStore()
store.urlMetadata[MODEL_KEY] = {
filename: 'model.safetensors',
content_length: 1024,
final_url: 'https://example.com/model.safetensors'
}
renderComponent()
const importBtn = screen.getByRole('button', { name: /Import/ })
expect(importBtn).toBeInTheDocument()
await user.click(importBtn)
expect(mockHandleImport).toHaveBeenCalledWith(MODEL_KEY, 'checkpoints')
expect(mockShowUploadDialog).not.toHaveBeenCalled()
})
it('calls showUploadDialog when privateModelsEnabled is false (free tier)', async () => {
mockPrivateModelsEnabled.value = false
const user = userEvent.setup()
const store = useMissingModelStore()
store.urlMetadata[MODEL_KEY] = {
filename: 'model.safetensors',
content_length: 1024,
final_url: 'https://example.com/model.safetensors'
}
renderComponent()
const importBtn = screen.getByRole('button', { name: /Import/ })
expect(importBtn).toBeInTheDocument()
await user.click(importBtn)
expect(mockShowUploadDialog).toHaveBeenCalled()
expect(mockHandleImport).not.toHaveBeenCalled()
})
it('clear button works for free-tier users', async () => {
mockPrivateModelsEnabled.value = false
const user = userEvent.setup()
const store = useMissingModelStore()
store.urlInputs[MODEL_KEY] = 'https://example.com/model.safetensors'
renderComponent()
const clearBtn = screen.getByRole('button', { name: 'Clear URL' })
await user.click(clearBtn)
expect(mockHandleUrlInput).toHaveBeenCalledWith(MODEL_KEY, '')
})
})
})

View File

@@ -1,135 +0,0 @@
<template>
<div
class="flex h-8 items-center rounded-lg border border-transparent bg-secondary-background px-3 transition-colors focus-within:border-interface-stroke"
>
<label :for="`url-input-${modelKey}`" class="sr-only">
{{ t('rightSidePanel.missingModels.urlPlaceholder') }}
</label>
<input
:id="`url-input-${modelKey}`"
type="text"
:value="urlInputs[modelKey] ?? ''"
:placeholder="t('rightSidePanel.missingModels.urlPlaceholder')"
class="text-foreground w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
@input="
handleUrlInput(modelKey, ($event.target as HTMLInputElement).value)
"
/>
<Button
v-if="urlInputs[modelKey]"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.clearUrl')"
class="ml-1 shrink-0"
@click.stop="handleUrlInput(modelKey, '')"
>
<i aria-hidden="true" class="icon-[lucide--x] size-3.5" />
</Button>
</div>
<TransitionCollapse>
<div v-if="urlMetadata[modelKey]" class="flex flex-col gap-2">
<div class="flex items-center gap-2 px-0.5 pt-2.5">
<span class="text-foreground min-w-0 truncate text-xs font-bold">
{{ urlMetadata[modelKey]?.filename }}
</span>
<span
v-if="(urlMetadata[modelKey]?.content_length ?? 0) > 0"
class="shrink-0 rounded-sm bg-secondary-background-selected px-1.5 py-0.5 text-xs font-medium text-muted-foreground"
>
{{ formatSize(urlMetadata[modelKey]?.content_length ?? 0) }}
</span>
</div>
<div v-if="typeMismatch" class="flex items-start gap-1.5 px-0.5">
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--triangle-alert] size-3 shrink-0 text-warning-background"
/>
<span class="text-xs/tight text-warning-background">
{{
t('rightSidePanel.missingModels.typeMismatch', {
detectedType: typeMismatch
})
}}
</span>
</div>
<div class="pt-0.5">
<Button
variant="primary"
class="h-9 w-full justify-center gap-2 text-sm font-semibold"
:loading="urlImporting[modelKey]"
@click="handleImportClick"
>
<i aria-hidden="true" class="icon-[lucide--download] size-4" />
{{
typeMismatch
? t('rightSidePanel.missingModels.importAnyway')
: t('rightSidePanel.missingModels.import')
}}
</Button>
</div>
</div>
</TransitionCollapse>
<TransitionCollapse>
<div
v-if="urlFetching[modelKey]"
aria-live="polite"
class="flex items-center justify-center py-2"
>
<i
aria-hidden="true"
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
/>
<span class="sr-only">{{ t('g.loading') }}</span>
</div>
</TransitionCollapse>
<TransitionCollapse>
<div v-if="urlErrors[modelKey]" class="px-0.5" role="alert">
<span class="text-xs text-destructive-background-hover">
{{ urlErrors[modelKey] }}
</span>
</div>
</TransitionCollapse>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import { formatSize } from '@/utils/formatUtil'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingModelInteractions } from '@/platform/missingModel/composables/useMissingModelInteractions'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
const { modelKey, directory, typeMismatch } = defineProps<{
modelKey: string
directory: string | null
typeMismatch: string | null
}>()
const { t } = useI18n()
const { flags } = useFeatureFlags()
const canImportModels = computed(() => flags.privateModelsEnabled)
const { showUploadDialog } = useModelUpload()
const store = useMissingModelStore()
const { urlInputs, urlMetadata, urlFetching, urlErrors, urlImporting } =
storeToRefs(store)
const { handleUrlInput, handleImport } = useMissingModelInteractions()
function handleImportClick() {
if (canImportModels.value) {
handleImport(modelKey, directory)
} else {
showUploadDialog()
}
}
</script>

View File

@@ -1,38 +1,26 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import type { App } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { MissingModelCandidate } from '@/platform/missingModel/types'
const mockGetNodeByExecutionId = vi.fn()
const mockResolveNodeDisplayName = vi.fn()
const mockValidateSourceUrl = vi.fn()
const mockGetAssetMetadata = vi.fn()
const mockUploadAssetAsync = vi.fn()
const mockTrackDownload = vi.fn()
const mockInvalidateModelsForCategory = vi.fn()
const mockGetAssetDisplayName = vi.fn((a: { name: string }) => a.name)
const mockGetAssetFilename = vi.fn((a: { name: string }) => a.name)
const mockGetAssets = vi.fn()
const mockUpdateModelsForNodeType = vi.fn()
const mockGetAllNodeProviders = vi.fn()
const mockDownloadList = vi.fn(
(): Array<{ taskId: string; status: string }> => []
)
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: null
@@ -55,7 +43,6 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
getAssets: mockGetAssets,
updateModelsForNodeType: mockUpdateModelsForNodeType,
invalidateModelsForCategory: mockInvalidateModelsForCategory,
updateModelsForTag: vi.fn()
@@ -77,42 +64,9 @@ vi.mock('@/stores/modelToNodeStore', () => ({
})
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetMetadata: (...args: unknown[]) => mockGetAssetMetadata(...args),
uploadAssetAsync: (...args: unknown[]) => mockUploadAssetAsync(...args)
}
}))
vi.mock('@/platform/assets/utils/assetMetadataUtils', () => ({
getAssetDisplayName: (a: { name: string }) => mockGetAssetDisplayName(a),
getAssetFilename: (a: { name: string }) => mockGetAssetFilename(a)
}))
vi.mock('@/platform/assets/importSources/civitaiImportSource', () => ({
civitaiImportSource: {
type: 'civitai',
name: 'Civitai',
hostnames: ['civitai.com', 'civitai.red']
}
}))
vi.mock('@/platform/assets/importSources/huggingfaceImportSource', () => ({
huggingfaceImportSource: {
type: 'huggingface',
name: 'Hugging Face',
hostnames: ['huggingface.co']
}
}))
vi.mock('@/platform/assets/utils/importSourceUtil', () => ({
validateSourceUrl: (...args: unknown[]) => mockValidateSourceUrl(...args)
}))
import { app } from '@/scripts/app'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import {
getComboValue,
getModelStateKey,
getNodeDisplayLabel,
useMissingModelInteractions
@@ -133,17 +87,54 @@ function makeCandidate(
}
describe('useMissingModelInteractions', () => {
const mountedApps: App<Element>[] = []
function setupWithI18n<T>(factory: () => T): T {
let result: T | undefined
const host = document.createElement('div')
const app = createApp({
setup() {
result = factory()
return () => null
}
})
app.use(
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
)
app.mount(host)
mountedApps.push(app)
if (result === undefined) {
throw new Error('Composable setup did not run')
}
return result
}
function setupMissingModelInteractions(): ReturnType<
typeof useMissingModelInteractions
> {
return setupWithI18n(() => useMissingModelInteractions())
}
beforeEach(() => {
setActivePinia(createPinia())
vi.resetAllMocks()
mockGetAssetDisplayName.mockImplementation((a: { name: string }) => a.name)
mockGetAssetFilename.mockImplementation((a: { name: string }) => a.name)
mockDownloadList.mockImplementation(
(): Array<{ taskId: string; status: string }> => []
)
;(app as { rootGraph: unknown }).rootGraph = null
})
afterEach(() => {
for (const app of mountedApps.splice(0)) {
app.unmount()
}
})
describe('getModelStateKey', () => {
it('returns key with supported prefix when asset is supported', () => {
expect(getModelStateKey('model.safetensors', 'checkpoints', true)).toBe(
@@ -184,149 +175,28 @@ describe('useMissingModelInteractions', () => {
})
})
describe('getComboValue', () => {
it('returns undefined when node is not found', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue(null)
const result = getComboValue(makeCandidate())
expect(result).toBeUndefined()
})
it('returns undefined when widget is not found', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'other_widget', value: 'test' }]
})
const result = getComboValue(makeCandidate())
expect(result).toBeUndefined()
})
it('returns string value directly', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: 'v1-5.safetensors' }]
})
expect(getComboValue(makeCandidate())).toBe('v1-5.safetensors')
})
it('returns stringified number value', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: 42 }]
})
expect(getComboValue(makeCandidate())).toBe('42')
})
it('returns undefined for unexpected types', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: { complex: true } }]
})
expect(getComboValue(makeCandidate())).toBeUndefined()
})
it('returns undefined when nodeId is null', () => {
const result = getComboValue(makeCandidate({ nodeId: undefined }))
expect(result).toBeUndefined()
})
})
describe('toggleModelExpand / isModelExpanded', () => {
it('starts collapsed by default', () => {
const { isModelExpanded } = useMissingModelInteractions()
const { isModelExpanded } = setupMissingModelInteractions()
expect(isModelExpanded('key1')).toBe(false)
})
it('toggles to expanded', () => {
const { toggleModelExpand, isModelExpanded } =
useMissingModelInteractions()
setupMissingModelInteractions()
toggleModelExpand('key1')
expect(isModelExpanded('key1')).toBe(true)
})
it('toggles back to collapsed', () => {
const { toggleModelExpand, isModelExpanded } =
useMissingModelInteractions()
setupMissingModelInteractions()
toggleModelExpand('key1')
toggleModelExpand('key1')
expect(isModelExpanded('key1')).toBe(false)
})
})
describe('handleComboSelect', () => {
it('sets selectedLibraryModel in store', () => {
const store = useMissingModelStore()
const { handleComboSelect } = useMissingModelInteractions()
handleComboSelect('key1', 'model_v2.safetensors')
expect(store.selectedLibraryModel['key1']).toBe('model_v2.safetensors')
})
it('does not set value when undefined', () => {
const store = useMissingModelStore()
const { handleComboSelect } = useMissingModelInteractions()
handleComboSelect('key1', undefined)
expect(store.selectedLibraryModel['key1']).toBeUndefined()
})
})
describe('isSelectionConfirmable', () => {
it('returns false when no selection exists', () => {
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
it('returns false when download is running', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importTaskIds['key1'] = 'task-123'
mockDownloadList.mockReturnValue([
{ taskId: 'task-123', status: 'running' }
])
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
it('returns false when importCategoryMismatch exists', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importCategoryMismatch['key1'] = 'loras'
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
it('returns true when selection is ready with no active download', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
mockDownloadList.mockReturnValue([])
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(true)
})
})
describe('cancelLibrarySelect', () => {
it('clears selectedLibraryModel and importCategoryMismatch', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importCategoryMismatch['key1'] = 'loras'
const { cancelLibrarySelect } = useMissingModelInteractions()
cancelLibrarySelect('key1')
expect(store.selectedLibraryModel['key1']).toBeUndefined()
expect(store.importCategoryMismatch['key1']).toBeUndefined()
})
})
describe('confirmLibrarySelect', () => {
it('updates widget values on referencing nodes and removes missing model', () => {
const mockGraph = {}
@@ -347,6 +217,7 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new_model.safetensors'
store.importTaskIds['key1'] = 'task-123'
store.setMissingModels([
makeCandidate({ name: 'old_model.safetensors', nodeId: '10' }),
makeCandidate({ name: 'old_model.safetensors', nodeId: '20' })
@@ -354,7 +225,7 @@ describe('useMissingModelInteractions', () => {
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = useMissingModelInteractions()
const { confirmLibrarySelect } = setupMissingModelInteractions()
confirmLibrarySelect(
'key1',
'old_model.safetensors',
@@ -372,6 +243,7 @@ describe('useMissingModelInteractions', () => {
new Set(['10', '20'])
)
expect(store.selectedLibraryModel['key1']).toBeUndefined()
expect(store.importTaskIds['key1']).toBeUndefined()
})
it('does nothing when no selection exists', () => {
@@ -379,7 +251,7 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = useMissingModelInteractions()
const { confirmLibrarySelect } = setupMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], null)
expect(removeSpy).not.toHaveBeenCalled()
@@ -391,7 +263,7 @@ describe('useMissingModelInteractions', () => {
store.selectedLibraryModel['key1'] = 'new.safetensors'
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = useMissingModelInteractions()
const { confirmLibrarySelect } = setupMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], null)
expect(removeSpy).not.toHaveBeenCalled()
@@ -407,169 +279,16 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new.safetensors'
const { confirmLibrarySelect } = useMissingModelInteractions()
const { confirmLibrarySelect } = setupMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], 'checkpoints')
expect(mockGetAllNodeProviders).toHaveBeenCalledWith('checkpoints')
})
})
describe('handleUrlInput', () => {
it('clears previous state on new input', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = { name: 'old' } as never
store.urlErrors['key1'] = 'old error'
store.urlFetching['key1'] = true
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(store.urlInputs['key1']).toBe('https://civitai.com/models/123')
expect(store.urlMetadata['key1']).toBeUndefined()
expect(store.urlErrors['key1']).toBeUndefined()
expect(store.urlFetching['key1']).toBe(false)
})
it('does not set debounce timer for empty input', () => {
const store = useMissingModelStore()
const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', ' ')
expect(setTimerSpy).not.toHaveBeenCalled()
})
it('sets debounce timer for non-empty input', () => {
const store = useMissingModelStore()
const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(setTimerSpy).toHaveBeenCalledWith(
'key1',
expect.any(Function),
800
)
})
it('clears previous debounce timer', () => {
const store = useMissingModelStore()
const clearTimerSpy = vi.spyOn(store, 'clearDebounceTimer')
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(clearTimerSpy).toHaveBeenCalledWith('key1')
})
})
describe('getTypeMismatch', () => {
it('returns null when groupDirectory is null', () => {
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', null)).toBeNull()
})
it('returns null when no metadata exists', () => {
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
it('returns null when metadata has no tags', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = { name: 'model', tags: [] } as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
it('returns null when detected type matches directory', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = {
name: 'model',
tags: ['checkpoints']
} as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
it('returns detected type when it differs from directory', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = {
name: 'model',
tags: ['loras']
} as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBe('loras')
})
it('returns null when tags contain no recognized model type', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = {
name: 'model',
tags: ['other', 'random']
} as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
})
describe('getComboOptions', () => {
it('returns assets from assetsStore when the model is asset-supported', () => {
mockGetAssets.mockReturnValueOnce([
{ name: 'modelA.safetensors' },
{ name: 'modelB.safetensors' }
])
const { getComboOptions } = useMissingModelInteractions()
const options = getComboOptions(makeCandidate({ isAssetSupported: true }))
expect(mockGetAssets).toHaveBeenCalledWith('CheckpointLoaderSimple')
expect(options).toEqual([
{ name: 'modelA.safetensors', value: 'modelA.safetensors' },
{ name: 'modelB.safetensors', value: 'modelB.safetensors' }
])
})
it('returns widget options when the model is not asset-supported', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [
{
name: 'ckpt_name',
value: '',
options: { values: ['v1.safetensors', 'v2.safetensors'] }
}
]
})
const { getComboOptions } = useMissingModelInteractions()
const options = getComboOptions(makeCandidate())
expect(options).toEqual([
{ name: 'v1.safetensors', value: 'v1.safetensors' },
{ name: 'v2.safetensors', value: 'v2.safetensors' }
])
})
it('returns an empty array when the widget has no options.values', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: '' }]
})
const { getComboOptions } = useMissingModelInteractions()
expect(getComboOptions(makeCandidate())).toEqual([])
})
})
describe('getDownloadStatus', () => {
it('returns null when no taskId is tracked for the key', () => {
const { getDownloadStatus } = useMissingModelInteractions()
const { getDownloadStatus } = setupMissingModelInteractions()
expect(getDownloadStatus('key1')).toBeNull()
})
@@ -581,7 +300,7 @@ describe('useMissingModelInteractions', () => {
{ taskId: 'task-42', status: 'created' }
])
const { getDownloadStatus } = useMissingModelInteractions()
const { getDownloadStatus } = setupMissingModelInteractions()
expect(getDownloadStatus('key1')).toEqual({
taskId: 'task-42',
status: 'created'
@@ -589,29 +308,20 @@ describe('useMissingModelInteractions', () => {
})
})
describe('handleImport', () => {
const setupImportableState = (key: string) => {
describe('handleUploadedModelImport', () => {
it('tracks an async-pending result via importTaskIds and trackDownload', () => {
const store = useMissingModelStore()
store.urlInputs[key] = 'https://civitai.com/models/123'
store.urlMetadata[key] = {
filename: 'model.safetensors',
name: 'model'
} as never
mockValidateSourceUrl.mockReturnValue(true)
return store
}
it('tracks an async-pending result via importTaskIds and trackDownload', async () => {
const store = setupImportableState('key1')
mockUploadAssetAsync.mockResolvedValueOnce({
type: 'async',
task: { task_id: 'task-99', status: 'created' }
const { handleUploadedModelImport } = setupMissingModelInteractions()
handleUploadedModelImport('key1', {
status: 'processing',
taskId: 'task-99',
modelType: 'checkpoints',
filename: 'model.safetensors'
})
const { handleImport } = useMissingModelInteractions()
await handleImport('key1', 'checkpoints')
expect(store.importTaskIds['key1']).toBe('task-99')
expect(store.selectedLibraryModel['key1']).toBe('model.safetensors')
expect(mockTrackDownload).toHaveBeenCalledWith(
'task-99',
'checkpoints',
@@ -619,43 +329,17 @@ describe('useMissingModelInteractions', () => {
)
})
it('invalidates model caches when the async result is already completed', async () => {
setupImportableState('key1')
mockUploadAssetAsync.mockResolvedValueOnce({
type: 'async',
task: { task_id: 'task-100', status: 'completed' }
it('invalidates model caches when the result is already completed', () => {
const { handleUploadedModelImport } = setupMissingModelInteractions()
handleUploadedModelImport('key1', {
status: 'success',
modelType: 'checkpoints',
filename: 'model.safetensors'
})
const { handleImport } = useMissingModelInteractions()
await handleImport('key1', 'checkpoints')
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
'checkpoints'
)
})
it('records importCategoryMismatch when sync result tags differ from groupDirectory', async () => {
const store = setupImportableState('key1')
mockUploadAssetAsync.mockResolvedValueOnce({
type: 'sync',
asset: { tags: ['models', 'loras'] }
})
const { handleImport } = useMissingModelInteractions()
await handleImport('key1', 'checkpoints')
expect(store.importCategoryMismatch['key1']).toBe('loras')
})
it('writes the error message to urlErrors when the upload rejects', async () => {
const store = setupImportableState('key1')
mockUploadAssetAsync.mockRejectedValueOnce(new Error('Upload boom'))
const { handleImport } = useMissingModelInteractions()
await handleImport('key1', 'checkpoints')
expect(store.urlErrors['key1']).toBe('Upload boom')
expect(store.urlImporting['key1']).toBe(false)
})
})
})

View File

@@ -1,39 +1,13 @@
import { useI18n } from 'vue-i18n'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { st } from '@/i18n'
import { assetService } from '@/platform/assets/services/assetService'
import {
getAssetDisplayName,
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
import type { UploadModelSuccess } from '@/platform/assets/composables/useUploadModelWizard'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useAssetsStore } from '@/stores/assetsStore'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { app } from '@/scripts/app'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import type {
MissingModelCandidate,
MissingModelViewModel
} from '@/platform/missingModel/types'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
const importSources = [civitaiImportSource, huggingfaceImportSource]
const MODEL_TYPE_TAGS = [
'checkpoints',
'loras',
'vae',
'text_encoders',
'diffusion_models'
] as const
const URL_DEBOUNCE_MS = 800
import type { MissingModelViewModel } from '@/platform/missingModel/types'
export function getModelStateKey(
modelName: string,
@@ -58,42 +32,12 @@ export function getNodeDisplayLabel(
})
}
function getModelComboWidget(
model: MissingModelCandidate
): { node: LGraphNode; widget: IBaseWidget } | null {
if (model.nodeId == null) return null
const graph = app.rootGraph
if (!graph) return null
const node = getNodeByExecutionId(graph, String(model.nodeId))
if (!node) return null
const widget = node.widgets?.find((w) => w.name === model.widgetName)
if (!widget) return null
return { node, widget }
}
export function getComboValue(
model: MissingModelCandidate
): string | undefined {
const result = getModelComboWidget(model)
if (!result) return undefined
const val = result.widget.value
if (typeof val === 'string') return val
if (typeof val === 'number') return String(val)
return undefined
}
export function useMissingModelInteractions() {
const { t } = useI18n()
const store = useMissingModelStore()
const assetsStore = useAssetsStore()
const assetDownloadStore = useAssetDownloadStore()
const modelToNodeStore = useModelToNodeStore()
const _requestTokens: Record<string, symbol> = {}
function toggleModelExpand(key: string) {
store.modelExpandState[key] = !isModelExpanded(key)
}
@@ -102,49 +46,6 @@ export function useMissingModelInteractions() {
return store.modelExpandState[key] ?? false
}
function getComboOptions(
model: MissingModelCandidate
): { name: string; value: string }[] {
if (model.isAssetSupported && model.nodeType) {
const assets = assetsStore.getAssets(model.nodeType) ?? []
return assets.map((asset) => ({
name: getAssetDisplayName(asset),
value: getAssetFilename(asset)
}))
}
const result = getModelComboWidget(model)
if (!result) return []
const values = result.widget.options?.values
if (!Array.isArray(values)) return []
return values.map((v) => ({ name: String(v), value: String(v) }))
}
function handleComboSelect(key: string, value: string | undefined) {
if (value) {
store.selectedLibraryModel[key] = value
}
}
function isSelectionConfirmable(key: string): boolean {
if (!store.selectedLibraryModel[key]) return false
if (store.importCategoryMismatch[key]) return false
const status = getDownloadStatus(key)
if (
status &&
(status.status === 'running' || status.status === 'created')
) {
return false
}
return true
}
function cancelLibrarySelect(key: string) {
delete store.selectedLibraryModel[key]
delete store.importCategoryMismatch[key]
}
/** Apply selected model to referencing nodes, removing only that model from the error list. */
function confirmLibrarySelect(
key: string,
@@ -189,97 +90,11 @@ export function useMissingModelInteractions() {
}
delete store.selectedLibraryModel[key]
delete store.importTaskIds[key]
const nodeIdSet = new Set(referencingNodes.map((ref) => String(ref.nodeId)))
store.removeMissingModelByNameOnNodes(modelName, nodeIdSet)
}
function handleUrlInput(key: string, value: string) {
store.urlInputs[key] = value
delete store.urlMetadata[key]
delete store.urlErrors[key]
delete store.importCategoryMismatch[key]
store.urlFetching[key] = false
store.clearDebounceTimer(key)
const trimmed = value.trim()
if (!trimmed) return
store.setDebounceTimer(
key,
() => {
void fetchUrlMetadata(key, trimmed)
},
URL_DEBOUNCE_MS
)
}
async function fetchUrlMetadata(key: string, url: string) {
const source = importSources.find((s) => validateSourceUrl(url, s))
if (!source) {
store.urlErrors[key] = t('rightSidePanel.missingModels.unsupportedUrl')
return
}
const token = Symbol()
_requestTokens[key] = token
store.urlFetching[key] = true
delete store.urlErrors[key]
try {
const metadata = await assetService.getAssetMetadata(url)
if (_requestTokens[key] !== token) return
if (metadata.filename) {
try {
const decoded = decodeURIComponent(metadata.filename)
const basename = decoded.split(/[/\\]/).pop() ?? decoded
if (!basename.includes('..')) {
metadata.filename = basename
}
} catch {
/* keep original */
}
}
store.urlMetadata[key] = metadata
} catch (error) {
if (_requestTokens[key] !== token) return
store.urlErrors[key] =
error instanceof Error
? error.message
: t('rightSidePanel.missingModels.metadataFetchFailed')
} finally {
if (_requestTokens[key] === token) {
store.urlFetching[key] = false
}
}
}
function getTypeMismatch(
key: string,
groupDirectory: string | null
): string | null {
if (!groupDirectory) return null
const metadata = store.urlMetadata[key]
if (!metadata?.tags?.length) return null
const detectedType = metadata.tags.find((tag) =>
MODEL_TYPE_TAGS.includes(tag as (typeof MODEL_TYPE_TAGS)[number])
)
if (!detectedType) return null
if (detectedType !== groupDirectory) {
return detectedType
}
return null
}
function getDownloadStatus(key: string) {
const taskId = store.importTaskIds[key]
if (!taskId) return null
@@ -307,87 +122,21 @@ export function useMissingModelInteractions() {
}
}
function handleSyncResult(
key: string,
tags: string[],
modelType: string | undefined
) {
const existingCategory = tags.find((tag) =>
MODEL_TYPE_TAGS.includes(tag as (typeof MODEL_TYPE_TAGS)[number])
)
if (existingCategory && modelType && existingCategory !== modelType) {
store.importCategoryMismatch[key] = existingCategory
function handleUploadedModelImport(key: string, result: UploadModelSuccess) {
if (result.taskId) {
handleAsyncPending(key, result.taskId, result.modelType, result.filename)
} else if (result.status === 'success') {
handleAsyncCompleted(result.modelType)
}
}
async function handleImport(key: string, groupDirectory: string | null) {
const metadata = store.urlMetadata[key]
if (!metadata) return
const url = store.urlInputs[key]?.trim()
if (!url) return
const source = importSources.find((s) => validateSourceUrl(url, s))
if (!source) return
const token = Symbol()
_requestTokens[key] = token
store.urlImporting[key] = true
delete store.urlErrors[key]
delete store.importCategoryMismatch[key]
try {
const modelType = groupDirectory || undefined
const tags = modelType ? ['models', modelType] : ['models']
const filename = metadata.filename || metadata.name || 'model'
const result = await assetService.uploadAssetAsync({
source_url: url,
tags,
user_metadata: {
source: source.type,
source_url: url,
model_type: modelType
}
})
if (_requestTokens[key] !== token) return
if (result.type === 'async' && result.task.status !== 'completed') {
handleAsyncPending(key, result.task.task_id, modelType, filename)
} else if (result.type === 'async') {
handleAsyncCompleted(modelType)
} else if (result.type === 'sync') {
handleSyncResult(key, result.asset.tags ?? [], modelType)
}
store.selectedLibraryModel[key] = filename
} catch (error) {
if (_requestTokens[key] !== token) return
store.urlErrors[key] =
error instanceof Error
? error.message
: t('rightSidePanel.missingModels.importFailed')
} finally {
if (_requestTokens[key] === token) {
store.urlImporting[key] = false
}
}
store.selectedLibraryModel[key] = result.filename
}
return {
toggleModelExpand,
isModelExpanded,
getComboOptions,
handleComboSelect,
isSelectionConfirmable,
cancelLibrarySelect,
confirmLibrarySelect,
handleUrlInput,
getTypeMismatch,
getDownloadStatus,
handleImport
handleUploadedModelImport
}
}

View File

@@ -214,7 +214,7 @@ describe('missingModelStore', () => {
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
])
store.urlInputs['test-key'] = 'https://example.com'
store.modelExpandState['test-key'] = true
store.selectedLibraryModel['test-key'] = 'some-model'
expect(store.missingModelCandidates).not.toBeNull()
@@ -222,7 +222,7 @@ describe('missingModelStore', () => {
expect(store.missingModelCandidates).toBeNull()
expect(store.hasMissingModels).toBe(false)
expect(store.urlInputs).toEqual({})
expect(store.modelExpandState).toEqual({})
expect(store.selectedLibraryModel).toEqual({})
})
})
@@ -515,17 +515,19 @@ describe('missingModelStore', () => {
makeModelCandidate('shared.safetensors', { nodeId: '65:80:5' }),
makeModelCandidate('only-interior.safetensors', { nodeId: '65:70:64' })
])
store.urlInputs['shared.safetensors'] = 'https://example.com/shared'
store.urlInputs['only-interior.safetensors'] =
'https://example.com/interior'
store.selectedLibraryModel['shared.safetensors'] = 'shared-replacement'
store.selectedLibraryModel['only-interior.safetensors'] =
'interior-replacement'
store.removeMissingModelsByPrefix('65:70:')
// 'only-interior' fully removed → interaction state cleared.
// 'shared' still referenced by 65:80:5 → interaction state preserved.
expect(store.urlInputs['only-interior.safetensors']).toBeUndefined()
expect(store.urlInputs['shared.safetensors']).toBe(
'https://example.com/shared'
expect(
store.selectedLibraryModel['only-interior.safetensors']
).toBeUndefined()
expect(store.selectedLibraryModel['shared.safetensors']).toBe(
'shared-replacement'
)
})
})

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { computed, onScopeDispose, ref } from 'vue'
import { computed, ref } from 'vue'
import { t } from '@/i18n'
// eslint-disable-next-line import-x/no-restricted-paths
@@ -7,7 +7,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
@@ -77,26 +76,16 @@ export const useMissingModelStore = defineStore('missingModel', () => {
)
})
// Persists across component re-mounts so that download progress,
// URL inputs, etc. survive tab switches within the right-side panel.
// Persists across component re-mounts so that download progress
// survives tab switches within the right-side panel.
const modelExpandState = ref<Record<string, boolean>>({})
const selectedLibraryModel = ref<Record<string, string>>({})
const importCategoryMismatch = ref<Record<string, string>>({})
const importTaskIds = ref<Record<string, string>>({})
const urlInputs = ref<Record<string, string>>({})
const urlMetadata = ref<Record<string, AssetMetadata | null>>({})
const urlFetching = ref<Record<string, boolean>>({})
const urlErrors = ref<Record<string, string>>({})
const urlImporting = ref<Record<string, boolean>>({})
const folderPaths = ref<Record<string, string[]>>({})
const fileSizes = ref<Record<string, number>>({})
const _urlDebounceTimers: Record<string, ReturnType<typeof setTimeout>> = {}
let _verificationAbortController: AbortController | null = null
onScopeDispose(cancelDebounceTimers)
function createVerificationAbortController(): AbortController {
_verificationAbortController?.abort()
_verificationAbortController = new AbortController()
@@ -134,13 +123,7 @@ export const useMissingModelStore = defineStore('missingModel', () => {
function clearInteractionStateForName(name: string) {
delete modelExpandState.value[name]
delete selectedLibraryModel.value[name]
delete importCategoryMismatch.value[name]
delete importTaskIds.value[name]
delete urlInputs.value[name]
delete urlMetadata.value[name]
delete urlFetching.value[name]
delete urlErrors.value[name]
delete urlImporting.value[name]
}
function removeMissingModelsByNodeId(nodeId: string) {
@@ -222,31 +205,6 @@ export const useMissingModelStore = defineStore('missingModel', () => {
return activeMissingModelGraphIds.value.has(String(node.id))
}
function cancelDebounceTimers() {
for (const key of Object.keys(_urlDebounceTimers)) {
clearTimeout(_urlDebounceTimers[key])
delete _urlDebounceTimers[key]
}
}
function setDebounceTimer(
key: string,
callback: () => void,
delayMs: number
) {
if (_urlDebounceTimers[key]) {
clearTimeout(_urlDebounceTimers[key])
}
_urlDebounceTimers[key] = setTimeout(callback, delayMs)
}
function clearDebounceTimer(key: string) {
if (_urlDebounceTimers[key]) {
clearTimeout(_urlDebounceTimers[key])
delete _urlDebounceTimers[key]
}
}
function setFolderPaths(paths: Record<string, string[]>) {
folderPaths.value = paths
}
@@ -259,16 +217,9 @@ export const useMissingModelStore = defineStore('missingModel', () => {
_verificationAbortController?.abort()
_verificationAbortController = null
missingModelCandidates.value = null
cancelDebounceTimers()
modelExpandState.value = {}
selectedLibraryModel.value = {}
importCategoryMismatch.value = {}
importTaskIds.value = {}
urlInputs.value = {}
urlMetadata.value = {}
urlFetching.value = {}
urlErrors.value = {}
urlImporting.value = {}
folderPaths.value = {}
fileSizes.value = {}
}
@@ -323,19 +274,10 @@ export const useMissingModelStore = defineStore('missingModel', () => {
modelExpandState,
selectedLibraryModel,
importTaskIds,
importCategoryMismatch,
urlInputs,
urlMetadata,
urlFetching,
urlErrors,
urlImporting,
folderPaths,
fileSizes,
setFolderPaths,
setFileSize,
setDebounceTimer,
clearDebounceTimer
setFileSize
}
})

View File

@@ -355,8 +355,13 @@ describe('useNodeReplacement', () => {
{ name: 'largest_size', link: null }
],
[{ name: 'IMAGE', links: null }],
[{ name: 'largest_size', value: 0 }]
[
{ name: 'largest_size', value: 0 },
{ name: 'face_point_size', value: 1 }
]
)
const setNodeId = vi.fn()
Object.assign(newNode.widgets![1], { setNodeId })
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
@@ -374,8 +379,8 @@ describe('useNodeReplacement', () => {
})
])
// Widget value should be transferred: old "longer_edge" (idx 0, value 512) → new "largest_size"
expect(newNode.widgets![0].value).toBe(512)
expect(setNodeId).toHaveBeenCalledWith(1)
})
it('should skip replacement when new node type is not registered', () => {

View File

@@ -2,6 +2,7 @@ import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
import { t } from '@/i18n'
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -170,6 +171,9 @@ function replaceWithMapping(
nodeGraph._nodes[idx] = newNode
newNode.graph = nodeGraph
nodeGraph._nodes_by_id[newNode.id] = newNode
for (const widget of newNode.widgets ?? []) {
if (isNodeBindable(widget)) widget.setNodeId(newNode.id)
}
const serialized = node.last_serialization ?? node.serialize()

View File

@@ -82,6 +82,11 @@ export type RemoteConfig = {
posthog_project_token?: string
posthog_api_host?: string
posthog_config?: Partial<PostHogConfig>
customer_io?: {
write_key?: string
site_id?: string
user_id?: string
}
subscription_required?: boolean
server_health_alert?: ServerHealthAlert
max_upload_size?: number

View File

@@ -1,5 +1,5 @@
<template>
<BaseModalLayout content-title="" data-testid="settings-dialog" size="sm">
<BaseModalLayout content-title="" data-testid="settings-dialog" size="full">
<template #leftPanelHeaderTitle>
<i class="icon-[lucide--settings]" />
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>

View File

@@ -53,13 +53,16 @@ describe('useSettingsDialog', () => {
isCloudRef.value = false
})
it("show() opens the Reka renderer with size 'full' and 960px content sizing", () => {
it("show() opens the Reka renderer with size 'full' and 1280px content sizing", () => {
useSettingsDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('global-settings')
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.size).toBe('full')
expect(args.dialogComponentProps.contentClass).toContain('max-w-[960px]')
expect(args.dialogComponentProps.contentClass).toContain('max-w-[1280px]')
expect(args.dialogComponentProps.contentClass).not.toContain(
'max-w-[960px]'
)
expect(args.dialogComponentProps.contentClass).toContain('h-[80vh]')
})

View File

@@ -8,8 +8,9 @@ import type { SettingPanelType } from '@/platform/settings/types'
const DIALOG_KEY = 'global-settings'
// The redesigned Settings dialog is 1280px wide (DES 3253-16079).
const SETTINGS_CONTENT_CLASS =
'w-[90vw] max-w-[960px] sm:max-w-[960px] h-[80vh] max-h-none rounded-2xl overflow-hidden'
'w-[90vw] max-w-[1280px] sm:max-w-[1280px] h-[80vh] max-h-none rounded-2xl overflow-hidden'
export function useSettingsDialog() {
const dialogService = useDialogService()

View File

@@ -26,14 +26,16 @@ export async function initTelemetry(): Promise<void> {
{ GtmTelemetryProvider },
{ ImpactTelemetryProvider },
{ PostHogTelemetryProvider },
{ ClickHouseTelemetryProvider }
{ ClickHouseTelemetryProvider },
{ CustomerIoTelemetryProvider }
] = await Promise.all([
import('./TelemetryRegistry'),
import('./providers/cloud/MixpanelTelemetryProvider'),
import('./providers/cloud/GtmTelemetryProvider'),
import('./providers/cloud/ImpactTelemetryProvider'),
import('./providers/cloud/PostHogTelemetryProvider'),
import('./providers/cloud/ClickHouseTelemetryProvider')
import('./providers/cloud/ClickHouseTelemetryProvider'),
import('./providers/cloud/CustomerIoTelemetryProvider')
])
const registry = new TelemetryRegistry()
@@ -42,6 +44,7 @@ export async function initTelemetry(): Promise<void> {
registry.registerProvider(new ImpactTelemetryProvider())
registry.registerProvider(new PostHogTelemetryProvider())
registry.registerProvider(new ClickHouseTelemetryProvider())
registry.registerProvider(new CustomerIoTelemetryProvider())
setTelemetryRegistry(registry)
})()

View File

@@ -0,0 +1,264 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const hoisted = vi.hoisted(() => {
const analytics = {
identify: vi.fn(),
track: vi.fn(),
reset: vi.fn(),
register: vi.fn().mockResolvedValue(undefined)
}
let resolvedCb: ((user: { id: string }) => void) | undefined
let logoutCb: (() => void) | undefined
return {
analytics,
load: vi.fn(() => analytics),
inAppPlugin: vi.fn(() => ({ name: 'Customer.io In-App Plugin' })),
onUserResolved: vi.fn((cb: (user: { id: string }) => void) => {
resolvedCb = cb
}),
onUserLogout: vi.fn((cb: () => void) => {
logoutCb = cb
}),
resolveUser: (id: string) => resolvedCb?.({ id }),
logoutUser: () => logoutCb?.()
}
})
vi.mock('@customerio/cdp-analytics-browser', () => ({
AnalyticsBrowser: { load: hoisted.load },
InAppPlugin: hoisted.inAppPlugin
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
onUserResolved: hoisted.onUserResolved,
onUserLogout: hoisted.onUserLogout
})
}))
import {
CustomerIoTelemetryProvider,
EVENT_SOURCE
} from './CustomerIoTelemetryProvider'
const WRITE_KEY = 'cdp_test_write_key'
const SITE_ID = 'f87746f8c188c8ddcf41'
const SOURCE = { event_source: EVENT_SOURCE }
function createProvider(
config: Partial<typeof window.__CONFIG__> = {
customer_io: { write_key: WRITE_KEY, site_id: SITE_ID }
}
): CustomerIoTelemetryProvider {
window.__CONFIG__ = config as typeof window.__CONFIG__
return new CustomerIoTelemetryProvider()
}
describe('CustomerIoTelemetryProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
hoisted.load.mockReturnValue(hoisted.analytics)
hoisted.analytics.register.mockResolvedValue(undefined)
window.__CONFIG__ = {} as typeof window.__CONFIG__
})
it('loads the client and registers the in-app plugin with the site id', async () => {
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.load).toHaveBeenCalledWith({ writeKey: WRITE_KEY })
expect(hoisted.inAppPlugin).toHaveBeenCalledWith(
expect.objectContaining({ siteId: SITE_ID })
)
expect(hoisted.analytics.register).toHaveBeenCalled()
})
it('does not initialize without a write key', async () => {
const provider = createProvider({ customer_io: { site_id: SITE_ID } })
await vi.dynamicImportSettled()
provider.trackWorkflowExecution()
expect(hoisted.load).not.toHaveBeenCalled()
expect(hoisted.analytics.track).not.toHaveBeenCalled()
})
it('does not initialize without a site id', async () => {
createProvider({ customer_io: { write_key: WRITE_KEY } })
await vi.dynamicImportSettled()
expect(hoisted.load).not.toHaveBeenCalled()
})
it('identifies the person by uid only on auth resolve', async () => {
createProvider()
await vi.dynamicImportSettled()
hoisted.resolveUser('test-uid-7f3a9c')
expect(hoisted.analytics.identify).toHaveBeenCalledWith('test-uid-7f3a9c')
})
it('identifies with the configured user_id override without waiting for auth', async () => {
createProvider({
customer_io: {
write_key: WRITE_KEY,
site_id: SITE_ID,
user_id: 'forced-uid'
}
})
await vi.dynamicImportSettled()
expect(hoisted.analytics.identify).toHaveBeenCalledWith('forced-uid')
expect(hoisted.onUserResolved).not.toHaveBeenCalled()
})
it('identifies before flushing events buffered before the SDK loads', async () => {
const provider = createProvider({
customer_io: {
write_key: WRITE_KEY,
site_id: SITE_ID,
user_id: 'forced-uid'
}
})
provider.trackWorkflowExecution()
await vi.dynamicImportSettled()
const identifyOrder = hoisted.analytics.identify.mock.invocationCallOrder[0]
const trackOrder = hoisted.analytics.track.mock.invocationCallOrder[0]
expect(identifyOrder).toBeLessThan(trackOrder)
})
it('resets on logout', async () => {
createProvider()
await vi.dynamicImportSettled()
hoisted.logoutUser()
expect(hoisted.analytics.reset).toHaveBeenCalledOnce()
})
const DIRECT_EVENTS: Array<{
event: string
invoke: (p: CustomerIoTelemetryProvider) => void
expected: Record<string, unknown>
}> = [
{
event: 'app:user_auth_completed',
invoke: (p) => p.trackAuth({ method: 'google', is_new_user: true }),
expected: { ...SOURCE, method: 'google', is_new_user: true }
},
{
event: 'app:subscription_required_modal_opened',
invoke: (p) =>
p.trackSubscription('modal_opened', { current_tier: 'pro' }),
expected: { ...SOURCE, current_tier: 'pro' }
},
{
event: 'app:subscribe_now_button_clicked',
invoke: (p) => p.trackSubscription('subscribe_clicked'),
expected: SOURCE
},
{
event: 'app:add_api_credit_button_clicked',
invoke: (p) => p.trackAddApiCreditButtonClicked(),
expected: SOURCE
},
{
event: 'execution_start',
invoke: (p) => p.trackWorkflowExecution(),
expected: SOURCE
},
{
event: 'execution_success',
invoke: (p) => p.trackExecutionSuccess({ jobId: 'job-abc' }),
expected: { ...SOURCE, jobId: 'job-abc' }
},
{
event: 'app:template_workflow_opened',
invoke: (p) => p.trackTemplate({ workflow_name: 'flux-dev' }),
expected: { ...SOURCE, workflow_name: 'flux-dev' }
},
{
event: 'app:template_library_opened',
invoke: (p) => p.trackTemplateLibraryOpened({ source: 'sidebar' }),
expected: { ...SOURCE, source: 'sidebar' }
},
{
event: 'app:share_flow',
invoke: (p) =>
p.trackShareFlow({
step: 'dialog_opened',
view_mode: 'graph',
is_app_mode: false
}),
expected: {
...SOURCE,
step: 'dialog_opened',
view_mode: 'graph',
is_app_mode: false
}
}
]
it.for(DIRECT_EVENTS)(
'sends $event with its metadata merged under the event_source tag',
async ({ event, invoke, expected }) => {
const provider = createProvider()
await vi.dynamicImportSettled()
invoke(provider)
expect(hoisted.analytics.track).toHaveBeenCalledWith(event, expected)
}
)
it('flushes events buffered before load once, in order', async () => {
const provider = createProvider()
provider.trackWorkflowExecution()
provider.trackAddApiCreditButtonClicked()
expect(hoisted.analytics.track).not.toHaveBeenCalled()
await vi.dynamicImportSettled()
expect(hoisted.analytics.track.mock.calls).toEqual([
['execution_start', SOURCE],
['app:add_api_credit_button_clicked', SOURCE]
])
})
it('disables tracking when the SDK fails to load', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
hoisted.load.mockImplementation(() => {
throw new Error('network down')
})
const provider = createProvider()
provider.trackWorkflowExecution()
await vi.dynamicImportSettled()
provider.trackWorkflowExecution()
expect(hoisted.analytics.track).not.toHaveBeenCalled()
})
it('keeps tracking after an individual event fails to send', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const provider = createProvider()
await vi.dynamicImportSettled()
hoisted.analytics.track.mockImplementationOnce(() =>
Promise.reject(new Error('network blip'))
)
provider.trackWorkflowExecution()
provider.trackAddApiCreditButtonClicked()
expect(hoisted.analytics.track).toHaveBeenCalledTimes(2)
await vi.waitFor(() =>
expect(console.error).toHaveBeenCalledWith(
'Failed to track Customer.io event:',
expect.any(Error)
)
)
})
})

View File

@@ -0,0 +1,142 @@
import type { AnalyticsBrowser } from '@customerio/cdp-analytics-browser'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { TelemetryEvents } from '../../types'
import type {
AuthMetadata,
ExecutionSuccessMetadata,
ShareFlowMetadata,
SubscriptionMetadata,
TelemetryEventProperties,
TelemetryProvider,
TemplateLibraryMetadata,
TemplateMetadata
} from '../../types'
export const EVENT_SOURCE = 'web-sdk'
interface QueuedEvent {
event: string
properties: Record<string, unknown>
}
/**
* Customer.io (Data Pipelines) Telemetry Provider - Cloud Build Implementation
*
* CRITICAL: OSS Build Safety
* Only registered from the cloud-only initTelemetry, so the file and its SDK
* import are tree-shaken away in OSS/desktop builds.
*/
export class CustomerIoTelemetryProvider implements TelemetryProvider {
private analytics: AnalyticsBrowser | null = null
private isEnabled = true
private eventQueue: QueuedEvent[] = []
constructor() {
const {
write_key: writeKey,
site_id: siteId,
user_id: userIdOverride
} = window.__CONFIG__?.customer_io ?? {}
if (!writeKey || !siteId) {
this.isEnabled = false
return
}
void import('@customerio/cdp-analytics-browser')
.then(({ AnalyticsBrowser, InAppPlugin }) => {
const analytics = AnalyticsBrowser.load({ writeKey })
void analytics.register(
InAppPlugin({
siteId,
events: null,
anonymousInApp: false,
_env: undefined,
_logging: undefined,
colorScheme: 'system'
})
)
this.analytics = analytics
const currentUser = useCurrentUser()
if (userIdOverride) {
void analytics.identify(userIdOverride)
} else {
currentUser.onUserResolved((user) => void analytics.identify(user.id))
}
currentUser.onUserLogout(() => void analytics.reset())
this.flushQueue()
})
.catch((error) => {
console.error('Failed to load Customer.io:', error)
this.isEnabled = false
this.eventQueue = []
})
}
private send(event: string, properties: Record<string, unknown>): void {
void this.analytics?.track(event, properties)?.catch((error) => {
console.error('Failed to track Customer.io event:', error)
})
}
private track(event: string, metadata?: TelemetryEventProperties): void {
if (!this.isEnabled) return
const properties = { ...metadata, event_source: EVENT_SOURCE }
if (this.analytics) {
this.send(event, properties)
} else {
this.eventQueue.push({ event, properties })
}
}
private flushQueue(): void {
if (!this.analytics) return
for (const { event, properties } of this.eventQueue) {
this.send(event, properties)
}
this.eventQueue = []
}
trackAuth(metadata: AuthMetadata): void {
this.track(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
}
trackSubscription(
event: 'modal_opened' | 'subscribe_clicked',
metadata?: SubscriptionMetadata
): void {
this.track(
event === 'modal_opened'
? TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED
: TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED,
metadata
)
}
trackAddApiCreditButtonClicked(): void {
this.track(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
}
trackWorkflowExecution(): void {
this.track(TelemetryEvents.EXECUTION_START)
}
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
this.track(TelemetryEvents.EXECUTION_SUCCESS, metadata)
}
trackTemplate(metadata: TemplateMetadata): void {
this.track(TelemetryEvents.TEMPLATE_WORKFLOW_OPENED, metadata)
}
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void {
this.track(TelemetryEvents.TEMPLATE_LIBRARY_OPENED, metadata)
}
trackShareFlow(metadata: ShareFlowMetadata): void {
this.track(TelemetryEvents.SHARE_FLOW, metadata)
}
}

View File

@@ -591,5 +591,31 @@ describe('useWorkflowPersistenceV2', () => {
'Comfy.BrowseTemplates'
)
})
it('does not open templates browser when template param is in URL', async () => {
routeMocks.query = { template: 'default-template-id' }
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('does not open templates browser when template intent is preserved across /user-select redirect', async () => {
preservedQueryMocks.payloads.template = {
template: 'default-template-id'
}
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
})
})

View File

@@ -161,18 +161,24 @@ export function useWorkflowPersistenceV2() {
})
}
const hasSharedWorkflowIntent = () => {
if (typeof route.query.share === 'string') return true
hydratePreservedQuery(SHARE_NAMESPACE)
const merged = mergePreservedQueryIntoQuery(SHARE_NAMESPACE, route.query)
return typeof merged?.share === 'string'
const hasPreservedIntent = (namespace: string, key: string) => {
if (typeof route.query[key] === 'string') return true
hydratePreservedQuery(namespace)
const merged = mergePreservedQueryIntoQuery(namespace, route.query)
return typeof merged?.[key] === 'string'
}
const hasSharedWorkflowIntent = () =>
hasPreservedIntent(SHARE_NAMESPACE, 'share')
const hasTemplateUrlIntent = () =>
hasPreservedIntent(TEMPLATE_NAMESPACE, 'template')
const loadDefaultWorkflow = async () => {
if (!settingStore.get('Comfy.TutorialCompleted')) {
await settingStore.set('Comfy.TutorialCompleted', true)
await useWorkflowService().loadBlankWorkflow()
if (!hasSharedWorkflowIntent()) {
if (!hasSharedWorkflowIntent() && !hasTemplateUrlIntent()) {
await useCommandStore().execute('Comfy.BrowseTemplates')
}
} else {

View File

@@ -0,0 +1,161 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import CurrentUserPopoverWorkspace from './CurrentUserPopoverWorkspace.vue'
const showCreateWorkspaceDialog = vi.fn()
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
userDisplayName: ref('Liz'),
userEmail: ref('liz@example.com'),
userPhotoUrl: ref(null),
handleSignOut: vi.fn()
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: ref(true),
isFreeTier: ref(false),
subscription: ref(null),
balance: ref(null),
isLoading: ref(false),
fetchBalance: vi.fn()
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: computed(() => ({
canTopUp: false,
canManageSubscription: false
}))
})
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({ showPricingTable: vi.fn() })
})
)
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: () => ({ show: vi.fn() })
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showCreateWorkspaceDialog,
showTopUpCreditsDialog: vi.fn()
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => undefined
}))
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: () => ({
buildDocsUrl: vi.fn(() => 'https://docs.comfy.org'),
docsPaths: { partnerNodesPricing: 'partner-nodes' }
})
}))
const WorkspaceSwitcherPopoverStub = defineComponent({
emits: ['select', 'create'],
template: `
<div>
<button data-testid="stub-select-workspace" @click="$emit('select')" />
<button data-testid="stub-create-workspace" @click="$emit('create')" />
</div>
`
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderComponent() {
return render(CurrentUserPopoverWorkspace, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
initialState: {
teamWorkspace: {
initState: 'ready',
activeWorkspaceId: 'ws-personal'
}
}
}),
i18n
],
directives: {
tooltip: {}
},
stubs: {
WorkspaceSwitcherPopover: WorkspaceSwitcherPopoverStub,
SubscribeButton: true,
UserAvatar: true,
WorkspaceProfilePic: true,
Skeleton: true,
Divider: true
}
}
})
}
describe('CurrentUserPopoverWorkspace', () => {
it('toggles the workspace switcher panel from the selector row', async () => {
const user = userEvent.setup()
renderComponent()
expect(
screen.queryByTestId('workspace-switcher-panel')
).not.toBeInTheDocument()
await user.click(screen.getByTestId('workspace-switcher-trigger'))
expect(screen.getByTestId('workspace-switcher-panel')).toBeInTheDocument()
await user.click(screen.getByTestId('workspace-switcher-trigger'))
expect(
screen.queryByTestId('workspace-switcher-panel')
).not.toBeInTheDocument()
})
it('closes the switcher panel after selecting a workspace', async () => {
const user = userEvent.setup()
renderComponent()
await user.click(screen.getByTestId('workspace-switcher-trigger'))
await user.click(screen.getByTestId('stub-select-workspace'))
expect(
screen.queryByTestId('workspace-switcher-panel')
).not.toBeInTheDocument()
})
it('opens the create-workspace dialog and closes the popover on create', async () => {
const user = userEvent.setup()
const { emitted } = renderComponent()
await user.click(screen.getByTestId('workspace-switcher-trigger'))
await user.click(screen.getByTestId('stub-create-workspace'))
expect(showCreateWorkspaceDialog).toHaveBeenCalled()
expect(emitted('close')).toHaveLength(1)
expect(
screen.queryByTestId('workspace-switcher-panel')
).not.toBeInTheDocument()
})
})

View File

@@ -24,36 +24,37 @@
</div>
<!-- Workspace Selector -->
<div
class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 hover:bg-secondary-background-hover"
@click="toggleWorkspaceSwitcher"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<WorkspaceProfilePic
class="size-6 shrink-0 text-xs"
:workspace-name="workspaceName"
/>
<span class="truncate text-sm text-base-foreground">{{
workspaceName
}}</span>
<div class="relative">
<div
ref="workspaceSwitcherTrigger"
class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 hover:bg-secondary-background-hover"
data-testid="workspace-switcher-trigger"
@click="toggleWorkspaceSwitcher"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<WorkspaceProfilePic
class="size-6 shrink-0 text-xs"
:workspace-name="workspaceName"
/>
<span class="truncate text-sm text-base-foreground">{{
workspaceName
}}</span>
</div>
<i class="pi pi-chevron-down shrink-0 text-sm text-muted-foreground" />
</div>
<i class="pi pi-chevron-down shrink-0 text-sm text-muted-foreground" />
</div>
<Popover
ref="workspaceSwitcherPopover"
append-to="body"
:pt="{
content: {
class: 'p-0'
}
}"
>
<WorkspaceSwitcherPopover
@select="workspaceSwitcherPopover?.hide()"
@create="handleCreateWorkspace"
/>
</Popover>
<div
v-if="isWorkspaceSwitcherOpen"
ref="workspaceSwitcherPanel"
class="absolute top-0 right-full z-10 mr-4 rounded-lg border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
data-testid="workspace-switcher-panel"
>
<WorkspaceSwitcherPopover
@select="isWorkspaceSwitcherOpen = false"
@create="handleCreateWorkspace"
/>
</div>
</div>
<!-- Credits Section -->
@@ -214,11 +215,11 @@
</template>
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
import Skeleton from 'primevue/skeleton'
import { computed, ref } from 'vue'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
@@ -246,7 +247,17 @@ const {
isInPersonalWorkspace: isPersonalWorkspace
} = storeToRefs(workspaceStore)
const { permissions } = useWorkspaceUI()
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
const isWorkspaceSwitcherOpen = ref(false)
const workspaceSwitcherTrigger = useTemplateRef('workspaceSwitcherTrigger')
const workspaceSwitcherPanel = useTemplateRef('workspaceSwitcherPanel')
onClickOutside(
workspaceSwitcherPanel,
() => {
isWorkspaceSwitcherOpen.value = false
},
{ ignore: [workspaceSwitcherTrigger] }
)
const emit = defineEmits<{
close: []
@@ -358,13 +369,13 @@ const handleLogout = async () => {
}
const handleCreateWorkspace = () => {
workspaceSwitcherPopover.value?.hide()
isWorkspaceSwitcherOpen.value = false
dialogService.showCreateWorkspaceDialog()
emit('close')
}
const toggleWorkspaceSwitcher = (event: MouseEvent) => {
workspaceSwitcherPopover.value?.toggle(event)
const toggleWorkspaceSwitcher = () => {
isWorkspaceSwitcherOpen.value = !isWorkspaceSwitcherOpen.value
}
const refreshBalance = () => {

View File

@@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SubscriptionInactiveMemberDialog from './SubscriptionInactiveMemberDialog.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { close: 'Close' },
subscription: {
inactive: {
memberTitle: "This workspace's subscription is inactive",
memberDescription:
"Ask your workspace owner to reactivate the workspace's subscription to run workflows.",
memberCta: 'Ok, got it'
}
}
}
}
})
function renderComponent(onClose = vi.fn()) {
render(SubscriptionInactiveMemberDialog, {
props: { onClose },
global: { plugins: [i18n] }
})
return onClose
}
describe('SubscriptionInactiveMemberDialog', () => {
it('renders the inactive title, description and CTA', () => {
renderComponent()
expect(
screen.getByText("This workspace's subscription is inactive")
).toBeInTheDocument()
expect(
screen.getByText(
"Ask your workspace owner to reactivate the workspace's subscription to run workflows."
)
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Ok, got it' })
).toBeInTheDocument()
})
it('exposes no subscribe affordance', () => {
renderComponent()
expect(screen.queryByText(/subscribe/i)).not.toBeInTheDocument()
})
it('calls onClose when the CTA is clicked', async () => {
const user = userEvent.setup()
const onClose = renderComponent()
await user.click(screen.getByRole('button', { name: 'Ok, got it' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('calls onClose when the header close button is clicked', async () => {
const user = userEvent.setup()
const onClose = renderComponent()
await user.click(screen.getByRole('button', { name: 'Close' }))
expect(onClose).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,42 @@
<template>
<div
class="flex flex-col overflow-hidden rounded-2xl border border-border-default bg-base-background"
data-testid="member-resubscribe-message"
>
<div
class="flex h-12 items-center gap-2 border-b border-border-default p-4"
>
<p class="m-0 min-w-0 flex-1 font-inter text-sm text-base-foreground">
{{ $t('subscription.inactive.memberTitle') }}
</p>
<button
type="button"
:aria-label="$t('g.close')"
class="flex size-4 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent text-base-foreground"
@click="onClose"
>
<i class="pi pi-times text-xs" />
</button>
</div>
<div class="p-4">
<p class="m-0 font-inter text-sm text-muted-foreground">
{{ $t('subscription.inactive.memberDescription') }}
</p>
</div>
<div class="flex items-center justify-end p-4">
<Button variant="secondary" size="lg" @click="onClose">
{{ $t('subscription.inactive.memberCta') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
const { onClose } = defineProps<{
onClose: () => void
}>()
</script>

View File

@@ -0,0 +1,165 @@
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 type { CreateTopupResponse } from '@/platform/workspace/api/workspaceApi'
import TopUpCreditsDialogContentWorkspace from './TopUpCreditsDialogContentWorkspace.vue'
const mockFetchBalance = vi.fn()
const mockFetchStatus = vi.fn()
const mockTopup = vi.fn<(amountCents: number) => Promise<CreateTopupResponse>>()
const mockStartOperation = vi.fn()
const mockShowSettings = vi.fn()
const mockToastAdd = vi.fn()
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
fetchBalance: mockFetchBalance,
fetchStatus: mockFetchStatus,
topup: (amountCents: number) => mockTopup(amountCents)
})
}))
vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
useBillingOperationStore: () => ({
hasPendingOperations: false,
startOperation: mockStartOperation
})
}))
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: () => ({ show: mockShowSettings })
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ closeDialog: vi.fn() })
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackApiCreditTopupButtonPurchaseClicked: vi.fn()
})
}))
vi.mock('@/platform/telemetry/topupTracker', () => ({
clearTopupTracking: vi.fn()
}))
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: () => ({
buildDocsUrl: () => 'https://docs.comfy.org',
docsPaths: { partnerNodesPricing: '' }
})
}))
vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: mockToastAdd })
}))
vi.mock('@/base/credits/comfyCredits', () => ({
creditsToUsd: (credits: number) => credits,
usdToCredits: (usd: number) => usd
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { close: 'Close' },
subscription: { addCredits: 'Add credits' },
credits: {
topUp: {
addMoreCredits: 'Add more credits',
addMoreCreditsToRun: 'Add more credits to run',
selectAmount: 'Select amount',
youPay: 'You pay',
youGet: 'You get',
purchaseSuccess: 'Credits added successfully!',
purchaseError: 'Purchase Failed',
purchaseErrorDetail: 'Failed to purchase credits: {error}',
unknownError: 'An unknown error occurred',
minRequired: 'Minimum required',
maxAllowed: 'Maximum allowed',
needMore: 'Need more?',
contactUs: 'Contact us',
viewPricing: 'View pricing',
insufficientWorkflowMessage: 'Insufficient credits'
}
}
}
}
})
function topupResponse(
status: CreateTopupResponse['status']
): CreateTopupResponse {
return {
billing_op_id: 'op-1',
topup_id: 'topup-1',
status,
amount_cents: 5000
}
}
function renderDialog() {
return render(TopUpCreditsDialogContentWorkspace, {
global: {
plugins: [i18n],
stubs: {
FormattedNumberStepper: {
name: 'FormattedNumberStepper',
props: ['modelValue'],
template: '<div />'
}
}
}
})
}
async function clickAddCredits() {
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Add credits' }))
}
describe('TopUpCreditsDialogContentWorkspace', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchBalance.mockResolvedValue(undefined)
mockFetchStatus.mockResolvedValue(undefined)
})
it('refreshes both balance and status after a completed top-up', async () => {
mockTopup.mockResolvedValue(topupResponse('completed'))
renderDialog()
await clickAddCredits()
expect(mockFetchBalance).toHaveBeenCalledOnce()
expect(mockFetchStatus).toHaveBeenCalledOnce()
expect(mockShowSettings).toHaveBeenCalledWith('workspace')
})
it('does not refresh balance or status for a pending top-up', async () => {
mockTopup.mockResolvedValue(topupResponse('pending'))
renderDialog()
await clickAddCredits()
expect(mockStartOperation).toHaveBeenCalledWith('op-1', 'topup')
expect(mockFetchBalance).not.toHaveBeenCalled()
expect(mockFetchStatus).not.toHaveBeenCalled()
})
it('does not refresh balance or status for a failed top-up', async () => {
mockTopup.mockResolvedValue(topupResponse('failed'))
renderDialog()
await clickAddCredits()
expect(mockFetchBalance).not.toHaveBeenCalled()
expect(mockFetchStatus).not.toHaveBeenCalled()
})
})

View File

@@ -176,7 +176,7 @@ const settingsDialog = useSettingsDialog()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { fetchBalance, topup } = useBillingContext()
const { fetchBalance, fetchStatus, topup } = useBillingContext()
const billingOperationStore = useBillingOperationStore()
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
@@ -265,7 +265,7 @@ async function handleBuy() {
summary: t('credits.topUp.purchaseSuccess'),
life: 5000
})
await fetchBalance()
await Promise.all([fetchBalance(), fetchStatus()])
handleClose(false)
settingsDialog.show('workspace')
} else if (response.status === 'pending') {

View File

@@ -1,7 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import WorkspaceSwitcherPopover from './WorkspaceSwitcherPopover.vue'
@@ -10,8 +9,14 @@ vi.mock('@/platform/workspace/composables/useWorkspaceSwitch', () => ({
useWorkspaceSwitch: () => ({ switchWorkspace: vi.fn() })
}))
const billingMocks = vi.hoisted(() => ({
subscription: {
value: null as { tier: string; duration: string } | null
}
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({ subscription: ref(null) })
useBillingContext: () => ({ subscription: billingMocks.subscription })
}))
const LONG_WORKSPACE_NAME =
@@ -26,9 +31,14 @@ const i18n = createI18n({
personal: 'Personal',
roleOwner: 'Owner',
roleMember: 'Member',
createWorkspace: 'Create new workspace',
createWorkspace: 'Create a team workspace',
maxWorkspacesReached:
'You can only own 10 workspaces. Delete one to create a new one.'
},
subscription: {
tiers: {
pro: { name: 'Pro' }
}
}
}
}
@@ -47,7 +57,12 @@ function createWorkspaceState(overrides: Record<string, unknown>) {
}
}
function renderComponent() {
function renderComponent(
overrides: {
activeWorkspaceId?: string
workspaces?: Record<string, unknown>[]
} = {}
) {
return render(WorkspaceSwitcherPopover, {
global: {
plugins: [
@@ -55,9 +70,9 @@ function renderComponent() {
createSpy: vi.fn,
initialState: {
teamWorkspace: {
activeWorkspaceId: 'ws-personal',
activeWorkspaceId: overrides.activeWorkspaceId ?? 'ws-personal',
isFetchingWorkspaces: false,
workspaces: [
workspaces: overrides.workspaces ?? [
createWorkspaceState({
id: 'ws-personal',
name: 'Personal Workspace',
@@ -84,6 +99,10 @@ function renderComponent() {
}
describe('WorkspaceSwitcherPopover', () => {
beforeEach(() => {
billingMocks.subscription.value = null
})
it('exposes the full team workspace name as a tooltip on the row', () => {
renderComponent()
@@ -91,4 +110,55 @@ describe('WorkspaceSwitcherPopover', () => {
expect(name).toHaveAttribute('title', LONG_WORKSPACE_NAME)
})
it('does not render a tier badge on team workspace rows', () => {
billingMocks.subscription.value = { tier: 'PRO', duration: 'MONTHLY' }
renderComponent({
activeWorkspaceId: 'ws-team',
workspaces: [
createWorkspaceState({
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
}),
createWorkspaceState({
id: 'ws-team',
name: 'Team Comfy',
type: 'team',
role: 'owner',
isSubscribed: true,
subscriptionTier: 'PRO'
})
]
})
expect(screen.getByText('Team Comfy')).toBeInTheDocument()
expect(screen.queryByText('Pro')).not.toBeInTheDocument()
})
it('keeps the tier badge on a subscribed personal workspace row', () => {
renderComponent({
activeWorkspaceId: 'ws-team',
workspaces: [
createWorkspaceState({
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
role: 'owner',
isSubscribed: true,
subscriptionTier: 'PRO'
}),
createWorkspaceState({
id: 'ws-team',
name: 'Team Comfy',
type: 'team',
role: 'owner'
})
]
})
expect(screen.getByText('Pro')).toBeInTheDocument()
})
})

View File

@@ -183,6 +183,8 @@ function getRoleLabel(role: AvailableWorkspace['role']): string {
}
function resolveTierLabel(workspace: AvailableWorkspace): string | null {
if (workspace.type !== 'personal') return null
if (isCurrentWorkspace(workspace)) {
return currentSubscriptionTierName.value || null
}

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
class="flex w-full max-w-lg flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
@@ -24,13 +24,13 @@
{{ $t('workspacePanel.createWorkspaceDialog.message') }}
</p>
<div class="flex flex-col gap-2">
<label class="text-sm text-base-foreground">
<label class="text-sm text-muted-foreground">
{{ $t('workspacePanel.createWorkspaceDialog.nameLabel') }}
</label>
<input
v-model="workspaceName"
type="text"
class="focus:ring-secondary-foreground w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:ring-1 focus:outline-none"
class="focus:ring-secondary-foreground h-10 w-full rounded-lg border-none bg-secondary-background px-4 text-sm text-base-foreground placeholder:text-muted-foreground focus:ring-1 focus:outline-none"
:placeholder="
$t('workspacePanel.createWorkspaceDialog.namePlaceholder')
"

View File

@@ -149,12 +149,12 @@ export function useSubscriptionCheckout(emit: {
response.payment_method_url
) {
window.open(response.payment_method_url, '_blank')
billingOperationStore.startOperation(
void billingOperationStore.startOperation(
response.billing_op_id,
'subscription'
)
} else if (response.status === 'pending_payment') {
billingOperationStore.startOperation(
void billingOperationStore.startOperation(
response.billing_op_id,
'subscription'
)

View File

@@ -1,8 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, effectScope, h } from 'vue'
import { effectScope } from 'vue'
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
import type { BillingActions, BillingState } from '@/composables/billing/types'
const mockWorkspaceApi = vi.hoisted(() => ({
getBillingStatus: vi.fn(),
@@ -24,7 +23,7 @@ const mockBillingPlans = vi.hoisted(() => ({
}))
const mockShow = vi.hoisted(() => vi.fn())
const mockUpdateActiveWorkspace = vi.hoisted(() => vi.fn())
const mockStartOperation = vi.hoisted(() => vi.fn())
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: mockWorkspaceApi
@@ -43,9 +42,9 @@ vi.mock(
})
)
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
updateActiveWorkspace: mockUpdateActiveWorkspace
vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
useBillingOperationStore: () => ({
startOperation: mockStartOperation
})
}))
@@ -400,54 +399,44 @@ describe('useWorkspaceBilling', () => {
})
})
describe('cancelSubscription polling', () => {
beforeEach(() => {
vi.useFakeTimers()
})
describe('cancelSubscription', () => {
function operation(
overrides: Partial<{
status: 'pending' | 'succeeded' | 'failed' | 'timeout'
errorMessage: string | null
}> = {}
) {
return {
opId: 'op-cancel',
type: 'cancel' as const,
status: overrides.status ?? ('succeeded' as const),
errorMessage: overrides.errorMessage ?? null,
startedAt: 0
}
}
afterEach(() => {
vi.useRealTimers()
})
it('updates workspace store when op succeeds', async () => {
it('drives the shared billing operation poller with a cancel op', async () => {
mockWorkspaceApi.cancelSubscription.mockResolvedValue({
billing_op_id: 'op-cancel',
cancel_at: '2026-06-01T00:00:00Z'
})
mockWorkspaceApi.getBillingOpStatus.mockResolvedValue({
id: 'op-cancel',
status: 'succeeded',
started_at: '2026-04-01T00:00:00Z'
})
mockWorkspaceApi.getBillingStatus.mockResolvedValue({
...activeStatus,
is_active: false,
subscription_status: 'canceled'
})
mockStartOperation.mockResolvedValue(operation())
const billing = setupBilling()
await billing.cancelSubscription()
expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledWith(
'op-cancel'
)
expect(mockUpdateActiveWorkspace).toHaveBeenCalledWith({
isSubscribed: false
})
expect(mockWorkspaceApi.getBillingStatus).toHaveBeenCalled()
expect(mockStartOperation).toHaveBeenCalledWith('op-cancel', 'cancel')
expect(billing.error.value).toBeNull()
})
it('rethrows when the op reports failure', async () => {
it('throws the op error message when the cancel op fails', async () => {
mockWorkspaceApi.cancelSubscription.mockResolvedValue({
billing_op_id: 'op-fail',
cancel_at: '2026-06-01T00:00:00Z'
})
mockWorkspaceApi.getBillingOpStatus.mockResolvedValue({
id: 'op-fail',
status: 'failed',
started_at: '2026-04-01T00:00:00Z',
error_message: 'processor rejected'
})
mockStartOperation.mockResolvedValue(
operation({ status: 'failed', errorMessage: 'processor rejected' })
)
const billing = setupBilling()
@@ -455,88 +444,44 @@ describe('useWorkspaceBilling', () => {
'processor rejected'
)
expect(billing.error.value).toBe('processor rejected')
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalled()
})
it('schedules the second poll at the 2000ms backoff boundary', async () => {
it('throws when the cancel op times out', async () => {
mockWorkspaceApi.cancelSubscription.mockResolvedValue({
billing_op_id: 'op-slow',
billing_op_id: 'op-timeout',
cancel_at: '2026-06-01T00:00:00Z'
})
const pendingResponse = {
id: 'op-slow',
status: 'pending' as const,
started_at: '2026-04-01T00:00:00Z'
}
mockWorkspaceApi.getBillingOpStatus
.mockResolvedValueOnce(pendingResponse)
.mockResolvedValueOnce({
id: 'op-slow',
status: 'succeeded',
started_at: '2026-04-01T00:00:00Z'
mockStartOperation.mockResolvedValue(
operation({
status: 'timeout',
errorMessage: 'billingOperation.cancelTimeout'
})
mockWorkspaceApi.getBillingStatus.mockResolvedValue({
...activeStatus,
is_active: false
})
)
const billing = setupBilling()
const cancelPromise = billing.cancelSubscription()
// First poll runs synchronously inside cancelSubscription.
await cancelPromise
expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(1)
// Boundary check: still only 1 call just before the 2000ms mark.
await vi.advanceTimersByTimeAsync(1999)
expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(1)
// Crossing 2000ms total fires the scheduled retry.
await vi.advanceTimersByTimeAsync(1)
expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(2)
expect(mockUpdateActiveWorkspace).toHaveBeenCalledWith({
isSubscribed: false
})
await expect(billing.cancelSubscription()).rejects.toThrow(
'billingOperation.cancelTimeout'
)
})
it('caps the backoff at 5000ms once 2^attempt exceeds the cap', async () => {
it('falls back to a generic message when a non-success op omits errorMessage', async () => {
mockWorkspaceApi.cancelSubscription.mockResolvedValue({
billing_op_id: 'op-cap',
billing_op_id: 'op-noerr',
cancel_at: '2026-06-01T00:00:00Z'
})
const pending = {
id: 'op-cap',
status: 'pending' as const,
started_at: '2026-04-01T00:00:00Z'
}
mockWorkspaceApi.getBillingOpStatus
.mockResolvedValueOnce(pending) // #1, schedules +2000ms
.mockResolvedValueOnce(pending) // #2 at t=2000, schedules +4000ms
.mockResolvedValueOnce(pending) // #3 at t=6000, schedules capped +5000ms
.mockResolvedValueOnce({
id: 'op-cap',
status: 'succeeded',
started_at: '2026-04-01T00:00:00Z'
})
mockWorkspaceApi.getBillingStatus.mockResolvedValue(activeStatus)
mockStartOperation.mockResolvedValue(
operation({ status: 'failed', errorMessage: null })
)
const billing = setupBilling()
await billing.cancelSubscription()
await vi.advanceTimersByTimeAsync(2000) // fires #2
expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(4000) // fires #3 at t=6000
expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(3)
// After #3 attempt=3, next delay should be capped at 5000ms (not 8000).
await vi.advanceTimersByTimeAsync(4999)
expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(3)
await vi.advanceTimersByTimeAsync(1)
expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(4)
await expect(billing.cancelSubscription()).rejects.toThrow(
'Failed to cancel subscription'
)
})
it('propagates error before polling when the cancel API itself fails', async () => {
it('propagates the error and skips polling when the cancel API fails', async () => {
mockWorkspaceApi.cancelSubscription.mockRejectedValue(
new Error('API down')
)
@@ -545,8 +490,7 @@ describe('useWorkspaceBilling', () => {
await expect(billing.cancelSubscription()).rejects.toThrow('API down')
expect(billing.error.value).toBe('API down')
expect(mockWorkspaceApi.getBillingOpStatus).not.toHaveBeenCalled()
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalled()
expect(mockStartOperation).not.toHaveBeenCalled()
})
it('falls back to a generic error message when cancel rejects with a non-Error', async () => {
@@ -557,71 +501,6 @@ describe('useWorkspaceBilling', () => {
await expect(billing.cancelSubscription()).rejects.toBe('boom')
expect(billing.error.value).toBe('Failed to cancel subscription')
})
it('stops polling after 30 attempts and refreshes status without marking unsubscribed', async () => {
mockWorkspaceApi.cancelSubscription.mockResolvedValue({
billing_op_id: 'op-stuck',
cancel_at: '2026-06-01T00:00:00Z'
})
mockWorkspaceApi.getBillingOpStatus.mockResolvedValue({
id: 'op-stuck',
status: 'pending',
started_at: '2026-04-01T00:00:00Z'
})
mockWorkspaceApi.getBillingStatus.mockResolvedValue(activeStatus)
const billing = setupBilling()
await billing.cancelSubscription()
// Advance well past all scheduled polls (worst-case ~146s).
await vi.advanceTimersByTimeAsync(200_000)
expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(30)
expect(mockWorkspaceApi.getBillingStatus).toHaveBeenCalledTimes(1)
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalled()
})
it('stops polling when the host component is unmounted', async () => {
mockWorkspaceApi.cancelSubscription.mockResolvedValue({
billing_op_id: 'op-dispose',
cancel_at: '2026-06-01T00:00:00Z'
})
mockWorkspaceApi.getBillingOpStatus.mockResolvedValue({
id: 'op-dispose',
status: 'pending',
started_at: '2026-04-01T00:00:00Z'
})
let billing: (BillingState & BillingActions) | undefined
const HostComponent = defineComponent({
setup() {
billing = useWorkspaceBilling()
return () => h('div')
}
})
const host = document.createElement('div')
const app = createApp(HostComponent)
app.mount(host)
if (!billing) throw new Error('composable not initialized')
const cancelPromise = billing.cancelSubscription().catch(() => undefined)
await cancelPromise
// Cross one backoff interval so the second poll is actually scheduled
// and then confirm that unmount freezes the counter across subsequent ticks.
await vi.advanceTimersByTimeAsync(2000)
const callsBeforeUnmount =
mockWorkspaceApi.getBillingOpStatus.mock.calls.length
expect(callsBeforeUnmount).toBeGreaterThanOrEqual(2)
app.unmount()
await vi.advanceTimersByTimeAsync(20_000)
expect(mockWorkspaceApi.getBillingOpStatus.mock.calls.length).toBe(
callsBeforeUnmount
)
})
})
describe('resubscribe', () => {
@@ -827,43 +706,4 @@ describe('useWorkspaceBilling', () => {
expect(mockWorkspaceApi.getBillingBalance).not.toHaveBeenCalled()
})
})
describe('pollCancelStatus error paths', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('uses a default error message when failed status omits error_message', async () => {
mockWorkspaceApi.cancelSubscription.mockResolvedValue({
billing_op_id: 'op-noerr',
cancel_at: '2026-06-01T00:00:00Z'
})
mockWorkspaceApi.getBillingOpStatus.mockResolvedValue({
id: 'op-noerr',
status: 'failed',
started_at: '2026-04-01T00:00:00Z'
// intentionally no error_message
})
const billing = setupBilling()
await expect(billing.cancelSubscription()).rejects.toThrow(
'Failed to cancel subscription'
)
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalled()
})
// Intentionally NOT covered: a rejection on a later scheduled poll is
// emitted from a void-discarded poll() inside setTimeout, so it surfaces
// as an unhandled rejection that cancelSubscription has already returned
// from. Codifying that as "polling stops cleanly" requires installing a
// process unhandledRejection handler to hide the evidence — which would
// bless a real bug: the dialog can already show success while the
// backing op silently fails. Fix in the source (retry transient poll
// failures or surface a pending/error state) before adding coverage here.
})
})

View File

@@ -1,4 +1,4 @@
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
import { computed, ref, shallowRef } from 'vue'
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
@@ -10,7 +10,7 @@ import type {
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
import type {
BalanceInfo,
@@ -26,7 +26,7 @@ import type {
*/
export function useWorkspaceBilling(): BillingState & BillingActions {
const billingPlans = useBillingPlans()
const workspaceStore = useTeamWorkspaceStore()
const billingOperationStore = useBillingOperationStore()
const isInitialized = ref(false)
const isLoading = ref(false)
@@ -83,68 +83,6 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
() => statusData.value?.plan_slug ?? billingPlans.currentPlanSlug.value
)
const pendingCancelOpId = ref<string | null>(null)
let cancelPollTimeout: number | null = null
const stopCancelPolling = () => {
if (cancelPollTimeout !== null) {
window.clearTimeout(cancelPollTimeout)
cancelPollTimeout = null
}
}
async function pollCancelStatus(opId: string): Promise<void> {
stopCancelPolling()
const maxAttempts = 30
let attempt = 0
const poll = async () => {
if (pendingCancelOpId.value !== opId) return
try {
const response = await workspaceApi.getBillingOpStatus(opId)
if (response.status === 'succeeded') {
pendingCancelOpId.value = null
stopCancelPolling()
await fetchStatus()
workspaceStore.updateActiveWorkspace({
isSubscribed: false
})
return
}
if (response.status === 'failed') {
pendingCancelOpId.value = null
stopCancelPolling()
throw new Error(
response.error_message ?? 'Failed to cancel subscription'
)
}
attempt += 1
if (attempt >= maxAttempts) {
pendingCancelOpId.value = null
stopCancelPolling()
await fetchStatus()
return
}
} catch (err) {
pendingCancelOpId.value = null
stopCancelPolling()
throw err
}
cancelPollTimeout = window.setTimeout(
() => {
void poll()
},
Math.min(1000 * 2 ** attempt, 5000)
)
}
await poll()
}
async function initialize(): Promise<void> {
if (isInitialized.value) return
@@ -259,8 +197,16 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
error.value = null
try {
const response = await workspaceApi.cancelSubscription()
pendingCancelOpId.value = response.billing_op_id
await pollCancelStatus(response.billing_op_id)
const operation = await billingOperationStore.startOperation(
response.billing_op_id,
'cancel'
)
if (operation.status !== 'succeeded') {
throw new Error(
operation.errorMessage ?? 'Failed to cancel subscription'
)
}
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to cancel subscription'
@@ -324,10 +270,6 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
subscriptionDialog.show()
}
onBeforeUnmount(() => {
stopCancelPolling()
})
return {
// State
isInitialized,

View File

@@ -33,9 +33,11 @@ vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
const mockSettingsDialogShow = vi.fn()
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: () => ({
show: vi.fn(),
show: mockSettingsDialogShow,
hide: vi.fn(),
showAbout: vi.fn()
})
@@ -55,6 +57,14 @@ vi.mock('@/platform/telemetry', () => ({
})
}))
const mockUpdateActiveWorkspace = vi.fn()
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
updateActiveWorkspace: mockUpdateActiveWorkspace
})
}))
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useBillingOperationStore } from './billingOperationStore'
@@ -79,7 +89,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
void store.startOperation('op-1', 'subscription')
expect(store.operations.size).toBe(1)
const operation = store.getOperation('op-1')
@@ -97,13 +107,34 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
store.startOperation('op-1', 'topup')
void store.startOperation('op-1', 'subscription')
void store.startOperation('op-1', 'topup')
expect(store.operations.size).toBe(1)
expect(store.getOperation('op-1')?.type).toBe('subscription')
})
it('returns the in-flight terminal promise for duplicate starts', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
const first = store.startOperation('op-1', 'cancel')
const second = store.startOperation('op-1', 'cancel')
await vi.advanceTimersByTimeAsync(0)
const [firstOutcome, secondOutcome] = await Promise.all([first, second])
expect(firstOutcome.status).toBe('succeeded')
expect(secondOutcome.status).toBe('succeeded')
const afterTerminal = await store.startOperation('op-1', 'cancel')
expect(afterTerminal.status).toBe('succeeded')
})
it('shows immediate processing toast for subscription operations', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
@@ -112,7 +143,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
void store.startOperation('op-1', 'subscription')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'info',
@@ -129,7 +160,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
void store.startOperation('op-1', 'topup')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'info',
@@ -149,7 +180,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
void store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
@@ -176,7 +207,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
void store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
@@ -191,7 +222,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
void store.startOperation('op-1', 'topup')
await vi.advanceTimersByTimeAsync(0)
@@ -206,7 +237,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
void store.startOperation('op-1', 'topup')
await vi.advanceTimersByTimeAsync(0)
@@ -225,7 +256,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
void store.startOperation('op-1', 'subscription')
const receivedToast = mockToastAdd.mock.calls[0][0]
@@ -246,7 +277,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
void store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
@@ -270,7 +301,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
void store.startOperation('op-1', 'topup')
await vi.advanceTimersByTimeAsync(0)
@@ -291,7 +322,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
void store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
@@ -316,7 +347,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
void store.startOperation('op-1', 'topup')
await vi.advanceTimersByTimeAsync(121_000)
await vi.runAllTimersAsync()
@@ -328,6 +359,114 @@ describe('billingOperationStore', () => {
})
})
describe('cancel operations', () => {
it('does not show a processing toast for cancel operations', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
void store.startOperation('op-1', 'cancel')
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('resolves with the succeeded operation and refreshes status', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
const terminal = store.startOperation('op-1', 'cancel')
await vi.advanceTimersByTimeAsync(0)
const operation = await terminal
expect(operation.status).toBe('succeeded')
expect(mockFetchStatus).toHaveBeenCalled()
expect(mockUpdateActiveWorkspace).toHaveBeenCalledWith({
isSubscribed: false
})
})
it('resolves the terminal outcome even when the post-success refresh fails', async () => {
mockFetchStatus.mockRejectedValueOnce(new Error('refresh failed'))
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
const terminal = store.startOperation('op-1', 'cancel')
await vi.advanceTimersByTimeAsync(0)
const operation = await terminal
expect(operation.status).toBe('succeeded')
})
it('does not open the settings dialog or toast on cancel success', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
const terminal = store.startOperation('op-1', 'cancel')
await vi.advanceTimersByTimeAsync(0)
await terminal
expect(mockSettingsDialogShow).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('resolves with a failed operation and default message, no toast', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'failed',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
const terminal = store.startOperation('op-1', 'cancel')
await vi.advanceTimersByTimeAsync(0)
const operation = await terminal
expect(operation.status).toBe('failed')
expect(operation.errorMessage).toBe('billingOperation.cancelFailed')
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('resolves with a timeout operation after 2 minutes, no toast', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
const terminal = store.startOperation('op-1', 'cancel')
await vi.advanceTimersByTimeAsync(121_000)
await vi.runAllTimersAsync()
const operation = await terminal
expect(operation.status).toBe('timeout')
expect(operation.errorMessage).toBe('billingOperation.cancelTimeout')
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
})
})
describe('exponential backoff', () => {
it('uses exponential backoff for polling intervals', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
@@ -337,7 +476,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
void store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
expect(workspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(1)
@@ -357,7 +496,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
void store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(60_000)
@@ -384,7 +523,7 @@ describe('billingOperationStore', () => {
} satisfies BillingOpStatusResponse)
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
void store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
expect(store.getOperation('op-1')?.status).toBe('pending')
@@ -406,7 +545,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
void store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
@@ -430,8 +569,8 @@ describe('billingOperationStore', () => {
)
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
store.startOperation('op-2', 'topup')
void store.startOperation('op-1', 'subscription')
void store.startOperation('op-2', 'topup')
expect(store.operations.size).toBe(2)
expect(store.hasPendingOperations).toBe(true)
@@ -462,7 +601,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
void store.startOperation('op-1', 'subscription')
expect(store.isSettingUp).toBe(true)
})
@@ -475,7 +614,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
void store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
@@ -490,7 +629,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
void store.startOperation('op-1', 'topup')
expect(store.isSettingUp).toBe(false)
})
@@ -505,7 +644,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
void store.startOperation('op-1', 'topup')
expect(store.isAddingCredits).toBe(true)
})
@@ -518,7 +657,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
void store.startOperation('op-1', 'topup')
await vi.advanceTimersByTimeAsync(0)
@@ -533,7 +672,7 @@ describe('billingOperationStore', () => {
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
void store.startOperation('op-1', 'subscription')
expect(store.isAddingCredits).toBe(false)
})

View File

@@ -4,10 +4,11 @@ import { computed, ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { t } from '@/i18n'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const INITIAL_INTERVAL_MS = 1000
@@ -15,7 +16,7 @@ const MAX_INTERVAL_MS = 8000
const BACKOFF_MULTIPLIER = 1.5
const TIMEOUT_MS = 120_000 // 2 minutes
type OperationType = 'subscription' | 'topup'
type OperationType = 'subscription' | 'topup' | 'cancel'
type OperationStatus = 'pending' | 'succeeded' | 'failed' | 'timeout'
interface BillingOperation {
@@ -26,11 +27,15 @@ interface BillingOperation {
startedAt: number
}
type TerminalResolver = (operation: BillingOperation) => void
export const useBillingOperationStore = defineStore('billingOperation', () => {
const operations = ref<Map<string, BillingOperation>>(new Map())
const timeouts = new Map<string, ReturnType<typeof setTimeout>>()
const intervals = new Map<string, number>()
const receivedToasts = new Map<string, ToastMessageOptions>()
const terminalResolvers = new Map<string, TerminalResolver>()
const terminalPromises = new Map<string, Promise<BillingOperation>>()
const hasPendingOperations = computed(() =>
[...operations.value.values()].some((op) => op.status === 'pending')
@@ -52,8 +57,14 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
return operations.value.get(opId)
}
function startOperation(opId: string, type: OperationType) {
if (operations.value.has(opId)) return
function startOperation(
opId: string,
type: OperationType
): Promise<BillingOperation> {
const existing = operations.value.get(opId)
if (existing) {
return terminalPromises.get(opId) ?? Promise.resolve(existing)
}
const operation: BillingOperation = {
opId,
@@ -66,21 +77,29 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
operations.value = new Map(operations.value).set(opId, operation)
intervals.set(opId, INITIAL_INTERVAL_MS)
// Show immediate feedback toast (persists until operation completes)
const messageKey =
type === 'subscription'
? 'billingOperation.subscriptionProcessing'
: 'billingOperation.topupProcessing'
if (type !== 'cancel') {
const messageKey =
type === 'subscription'
? 'billingOperation.subscriptionProcessing'
: 'billingOperation.topupProcessing'
const toastMessage: ToastMessageOptions = {
severity: 'info',
summary: t(messageKey),
group: 'billing-operation'
const toastMessage: ToastMessageOptions = {
severity: 'info',
summary: t(messageKey),
group: 'billing-operation'
}
receivedToasts.set(opId, toastMessage)
useToastStore().add(toastMessage)
}
receivedToasts.set(opId, toastMessage)
useToastStore().add(toastMessage)
const terminal = new Promise<BillingOperation>((resolve) => {
terminalResolvers.set(opId, resolve)
})
terminalPromises.set(opId, terminal)
void poll(opId)
return terminal
}
async function poll(opId: string) {
@@ -139,12 +158,17 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
}
const billingContext = useBillingContext()
await Promise.all([
await Promise.allSettled([
billingContext.fetchStatus(),
billingContext.fetchBalance()
])
// Close any open billing dialogs and show settings
if (operation.type === 'cancel') {
useTeamWorkspaceStore().updateActiveWorkspace({ isSubscribed: false })
resolveTerminal(opId)
return
}
const dialogStore = useDialogStore()
dialogStore.closeDialog({ key: 'subscription-required' })
dialogStore.closeDialog({ key: 'top-up-credits' })
@@ -161,43 +185,70 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
summary: t(messageKey),
life: 5000
})
resolveTerminal(opId)
}
function handleFailure(opId: string, errorMessage: string | null) {
const operation = operations.value.get(opId)
if (!operation) return
const defaultMessage =
operation.type === 'subscription'
? t('billingOperation.subscriptionFailed')
: t('billingOperation.topupFailed')
const defaultMessage = failureMessage(operation.type)
updateOperationStatus(opId, 'failed', errorMessage ?? defaultMessage)
cleanup(opId)
useToastStore().add({
severity: 'error',
summary: defaultMessage,
detail: errorMessage ?? undefined
})
if (operation.type !== 'cancel') {
useToastStore().add({
severity: 'error',
summary: defaultMessage,
detail: errorMessage ?? undefined
})
}
resolveTerminal(opId)
}
function handleTimeout(opId: string) {
const operation = operations.value.get(opId)
if (!operation) return
const message =
operation.type === 'subscription'
? t('billingOperation.subscriptionTimeout')
: t('billingOperation.topupTimeout')
const message = timeoutMessage(operation.type)
updateOperationStatus(opId, 'timeout', message)
cleanup(opId)
useToastStore().add({
severity: 'error',
summary: message
})
if (operation.type !== 'cancel') {
useToastStore().add({
severity: 'error',
summary: message
})
}
resolveTerminal(opId)
}
function failureMessage(type: OperationType) {
if (type === 'subscription') return t('billingOperation.subscriptionFailed')
if (type === 'topup') return t('billingOperation.topupFailed')
return t('billingOperation.cancelFailed')
}
function timeoutMessage(type: OperationType) {
if (type === 'subscription')
return t('billingOperation.subscriptionTimeout')
if (type === 'topup') return t('billingOperation.topupTimeout')
return t('billingOperation.cancelTimeout')
}
function resolveTerminal(opId: string) {
const resolve = terminalResolvers.get(opId)
const operation = operations.value.get(opId)
if (resolve && operation) {
resolve(operation)
}
terminalResolvers.delete(opId)
terminalPromises.delete(opId)
}
function updateOperationStatus(
@@ -233,6 +284,8 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
const newMap = new Map(operations.value)
newMap.delete(opId)
operations.value = newMap
terminalResolvers.delete(opId)
terminalPromises.delete(opId)
}
return {

View File

@@ -1170,7 +1170,7 @@ export class ComfyApp {
useWorkflowService().beforeLoadNewGraph()
if (skipAssetScans) {
// Only reset candidates; preserve UI state (fileSizes, urlInputs, etc.)
// Only reset candidates; preserve UI state (fileSizes, etc.)
// so cached results restored by showPendingWarnings still display sizes.
// Abort any in-flight verification from the outgoing workflow so a late
// result cannot repopulate the store after we've switched workflows.

View File

@@ -1,6 +1,6 @@
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
export const defaultGraph: ComfyWorkflowJSON = {
const testDefaultGraph: ComfyWorkflowJSON = {
last_node_id: 9,
last_link_id: 9,
nodes: [
@@ -149,6 +149,418 @@ export const defaultGraph: ComfyWorkflowJSON = {
version: 0.4
}
const prodDefaultGraph: ComfyWorkflowJSON = {
last_node_id: 71,
last_link_id: 82,
nodes: [
{
id: 9,
type: 'SaveImage',
pos: [1279.9999726783708, 319.9999392082668],
size: [300, 420],
flags: {},
order: 9,
mode: 0,
inputs: [
{
name: 'images',
type: 'IMAGE',
link: 80
}
],
outputs: [],
properties: {
'Node name for S&R': 'SaveImage',
cnr_id: 'comfy-core',
ver: '0.3.64',
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: ['ComfyUI']
},
{
id: 62,
type: 'CLIPLoader',
pos: [-239.9999987113997, 420.0000536491848],
size: [340, 169.3125],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [
{
name: 'CLIP',
type: 'CLIP',
links: [79, 81]
}
],
properties: {
'Node name for S&R': 'CLIPLoader',
cnr_id: 'comfy-core',
ver: '0.3.73',
models: [
{
name: 'qwen_3_4b.safetensors',
url: 'https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors',
directory: 'text_encoders'
}
],
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: ['qwen_3_4b.safetensors', 'lumina2', 'default']
},
{
id: 63,
type: 'VAELoader',
pos: [659.9998200904802, 699.9998629143215],
size: [320, 106.65625],
flags: {},
order: 1,
mode: 0,
inputs: [],
outputs: [
{
name: 'VAE',
type: 'VAE',
links: [74]
}
],
properties: {
'Node name for S&R': 'VAELoader',
cnr_id: 'comfy-core',
ver: '0.3.73',
models: [
{
name: 'ae.safetensors',
url: 'https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors',
directory: 'vae'
}
],
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: ['ae.safetensors']
},
{
id: 65,
type: 'VAEDecode',
pos: [1019.9998200904802, 319.9999392082668],
size: [225, 96],
flags: {},
order: 8,
mode: 0,
inputs: [
{
name: 'samples',
type: 'LATENT',
link: 73
},
{
name: 'vae',
type: 'VAE',
link: 74
}
],
outputs: [
{
name: 'IMAGE',
type: 'IMAGE',
slot_index: 0,
links: [80]
}
],
properties: {
'Node name for S&R': 'VAEDecode',
cnr_id: 'comfy-core',
ver: '0.3.64',
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: []
},
{
id: 66,
type: 'UNETLoader',
pos: [-239.9999987113997, 110.00000596546897],
size: [380, 134.65625],
flags: {},
order: 2,
mode: 0,
inputs: [],
outputs: [
{
name: 'MODEL',
type: 'MODEL',
links: [72]
}
],
properties: {
'Node name for S&R': 'UNETLoader',
cnr_id: 'comfy-core',
ver: '0.3.73',
models: [
{
name: 'z_image_turbo_bf16.safetensors',
url: 'https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors',
directory: 'diffusion_models'
}
],
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: ['z_image_turbo_bf16.safetensors', 'default']
},
{
id: 67,
type: 'CLIPTextEncode',
pos: [170.00001082534345, 290.0000536491848],
size: [410, 160],
flags: {},
order: 4,
mode: 0,
inputs: [
{
name: 'clip',
type: 'CLIP',
link: 79
}
],
outputs: [
{
name: 'CONDITIONING',
type: 'CONDITIONING',
links: [76]
}
],
properties: {
'Node name for S&R': 'CLIPTextEncode',
cnr_id: 'comfy-core',
ver: '0.3.73',
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: [
'anime RPG game style, cute anime girl with gigantic fennec ears and a big fluffy fox tail with long wavy blonde hair and large blue eyes blonde colored eyelashes wearing a pink sweater a large oversized gold trimmed black winter coat and a long blue maxi skirt and a red scarf, she is sitting beside a campfire in the wilderness at night playing guitar with a milky way galaxy sky'
]
},
{
id: 68,
type: 'EmptySD3LatentImage',
pos: [310.0000489723161, 700.0000155022121],
size: [260, 168],
flags: {},
order: 3,
mode: 0,
inputs: [],
outputs: [
{
name: 'LATENT',
type: 'LATENT',
slot_index: 0,
links: [78]
}
],
properties: {
'Node name for S&R': 'EmptySD3LatentImage',
cnr_id: 'comfy-core',
ver: '0.3.64',
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: [1024, 1024, 1]
},
{
id: 69,
type: 'ModelSamplingAuraFlow',
pos: [220.00001082534345, 110.00000596546897],
size: [310, 104],
flags: {},
order: 6,
mode: 0,
inputs: [
{
name: 'model',
type: 'MODEL',
link: 72
}
],
outputs: [
{
name: 'MODEL',
type: 'MODEL',
slot_index: 0,
links: [75]
}
],
properties: {
'Node name for S&R': 'ModelSamplingAuraFlow',
cnr_id: 'comfy-core',
ver: '0.3.64',
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: [3]
},
{
id: 70,
type: 'KSampler',
pos: [659.9998200904802, 319.9999392082668],
size: [315, 341.3125],
flags: {},
order: 7,
mode: 0,
inputs: [
{
name: 'model',
type: 'MODEL',
link: 75
},
{
name: 'positive',
type: 'CONDITIONING',
link: 76
},
{
name: 'negative',
type: 'CONDITIONING',
link: 82
},
{
name: 'latent_image',
type: 'LATENT',
link: 78
}
],
outputs: [
{
name: 'LATENT',
type: 'LATENT',
slot_index: 0,
links: [73]
}
],
properties: {
'Node name for S&R': 'KSampler',
cnr_id: 'comfy-core',
ver: '0.3.64',
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: [42, 'fixed', 8, 1, 'res_multistep', 'simple', 1]
},
{
id: 71,
type: 'CLIPTextEncode',
pos: [170.00001082534345, 520.0000536491848],
size: [405.46875, 140],
flags: {},
order: 5,
mode: 0,
inputs: [
{
name: 'clip',
type: 'CLIP',
link: 81
}
],
outputs: [
{
name: 'CONDITIONING',
type: 'CONDITIONING',
links: [82]
}
],
properties: {
'Node name for S&R': 'CLIPTextEncode',
cnr_id: 'comfy-core',
ver: '0.3.73',
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: [
'low quality, bad anatomy, extra digits, missing digits, extra limbs, missing limbs'
]
}
],
links: [
[72, 66, 0, 69, 0, 'MODEL'],
[73, 70, 0, 65, 0, 'LATENT'],
[74, 63, 0, 65, 1, 'VAE'],
[75, 69, 0, 70, 0, 'MODEL'],
[76, 67, 0, 70, 1, 'CONDITIONING'],
[78, 68, 0, 70, 3, 'LATENT'],
[79, 62, 0, 67, 0, 'CLIP'],
[80, 65, 0, 9, 0, 'IMAGE'],
[81, 62, 0, 71, 0, 'CLIP'],
[82, 71, 0, 70, 2, 'CONDITIONING']
],
groups: [],
config: {},
extra: {
ds: {
scale: 0.9,
offset: [416, 110]
}
},
version: 0.4
}
export const defaultGraph: ComfyWorkflowJSON =
import.meta.env.VITE_USE_LEGACY_DEFAULT_GRAPH === 'true'
? testDefaultGraph
: prodDefaultGraph
export const defaultGraphJSON = JSON.stringify(defaultGraph)
export const blankGraph: ComfyWorkflowJSON = {