mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 14:45:36 +00:00
Compare commits
10 Commits
glary/widg
...
glary/mode
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b87b5eba54 | ||
|
|
4931b0c4b2 | ||
|
|
fc7e6a0935 | ||
|
|
a97f46b497 | ||
|
|
448ad73fae | ||
|
|
cf267acffe | ||
|
|
3b75d9c1e1 | ||
|
|
954dbd1f4a | ||
|
|
ae7e16c7fc | ||
|
|
8d2b1d16e6 |
14
apps/website/public/favicon.svg
Normal file
14
apps/website/public/favicon.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.bg { fill: #000000; }
|
||||
.fg { fill: #F2FF59; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bg { fill: #F2FF59; }
|
||||
.fg { fill: #000000; }
|
||||
}
|
||||
</style>
|
||||
<circle class="bg" cx="24" cy="24" r="24"/>
|
||||
<g transform="translate(7.8 6.72) scale(0.72)">
|
||||
<path class="fg" d="M35.6487 36.021C35.733 35.7387 35.7791 35.4411 35.7791 35.1283C35.7791 33.3963 34.3675 31.9924 32.6262 31.9924H18.4956C17.7361 32 17.1147 31.3896 17.1147 30.6342C17.1147 30.4969 17.1377 30.3672 17.1684 30.2451L20.9734 17.0606C21.1345 16.4807 21.6715 16.0534 22.3005 16.0534L36.4848 16.0382C39.4766 16.0382 42.0005 14.0315 42.76 11.2923L44.8926 3.94468C44.9616 3.68526 45 3.40296 45 3.12065C45 1.39628 43.5961 0 41.8624 0L24.7017 0C21.7252 0 19.209 1.99142 18.4342 4.70005L16.992 9.71292C16.8232 10.2852 16.2939 10.7048 15.6648 10.7048H11.5453C8.59189 10.7048 6.0987 12.6581 5.30089 15.3362L0.11507 33.3505C0.0383566 33.6175 0 33.9075 0 34.1974C0 35.9294 1.41152 37.3333 3.15292 37.3333H7.20338C7.96284 37.3333 8.58421 37.9437 8.58421 38.7067C8.58421 38.8364 8.56887 38.9661 8.53051 39.0882L7.09598 44.0553C7.02694 44.3224 6.98091 44.597 6.98091 44.8794C6.98091 46.6037 8.38476 48 10.1185 48L27.2869 47.9847C30.2711 47.9847 32.7873 45.9857 33.5544 43.2618L35.641 36.0286L35.6487 36.021Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -71,7 +71,7 @@ const websiteJsonLd = {
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
<title>{title}</title>
|
||||
|
||||
<link rel="icon" href="/icons/logomark.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
<link rel="preconnect" href="https://www.googletagmanager.com" />
|
||||
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
|
||||
|
||||
103
browser_tests/fixtures/components/AssetBrowserModal.ts
Normal file
103
browser_tests/fixtures/components/AssetBrowserModal.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class AssetBrowserModal {
|
||||
public readonly root: Locator
|
||||
public readonly assetGrid: Locator
|
||||
public readonly modelInfoPanel: Locator
|
||||
|
||||
public readonly basicInfoSection: Locator
|
||||
public readonly modelTaggingSection: Locator
|
||||
public readonly modelDescriptionSection: Locator
|
||||
|
||||
public readonly displayNameText: Locator
|
||||
public readonly editDisplayNameButton: Locator
|
||||
public readonly displayNameInput: Locator
|
||||
public readonly fileNameText: Locator
|
||||
public readonly sourceLink: Locator
|
||||
public readonly modelTypeSelect: Locator
|
||||
public readonly baseModelsField: Locator
|
||||
public readonly additionalTagsField: Locator
|
||||
public readonly baseModelsInput: Locator
|
||||
public readonly additionalTagsInput: Locator
|
||||
public readonly descriptionText: Locator
|
||||
public readonly userDescriptionTextarea: Locator
|
||||
public readonly triggerPhrasesCopyAllButton: Locator
|
||||
public readonly triggerPhraseButtons: Locator
|
||||
public readonly selectModelPrompt: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.root = page.locator('[data-component-id="AssetBrowserModal"]')
|
||||
this.assetGrid = this.root.locator('[data-component-id="AssetGrid"]')
|
||||
this.modelInfoPanel = page.locator('[data-component-id="ModelInfoPanel"]')
|
||||
|
||||
const sections = this.modelInfoPanel.locator(':scope > div')
|
||||
this.basicInfoSection = sections.nth(0)
|
||||
this.modelTaggingSection = sections.nth(1)
|
||||
this.modelDescriptionSection = sections.nth(2)
|
||||
|
||||
this.displayNameText = this.basicInfoSection
|
||||
.locator('.editable-text')
|
||||
.first()
|
||||
this.editDisplayNameButton = this.basicInfoSection.getByRole('button', {
|
||||
name: /edit/i
|
||||
})
|
||||
this.displayNameInput = this.basicInfoSection.locator('input[type="text"]')
|
||||
this.fileNameText = this.basicInfoSection
|
||||
.locator('span.break-all.text-muted-foreground')
|
||||
.first()
|
||||
this.sourceLink = this.basicInfoSection
|
||||
.locator('a[target="_blank"]')
|
||||
.first()
|
||||
|
||||
this.modelTypeSelect = this.modelTaggingSection.getByRole('combobox')
|
||||
this.baseModelsField = this.modelTaggingSection
|
||||
.locator('div')
|
||||
.filter({ hasText: /base model/i })
|
||||
.first()
|
||||
this.additionalTagsField = this.modelTaggingSection
|
||||
.locator('div')
|
||||
.filter({ hasText: /additional tag/i })
|
||||
.first()
|
||||
this.baseModelsInput = this.baseModelsField.locator('input')
|
||||
this.additionalTagsInput = this.additionalTagsField.locator('input')
|
||||
|
||||
this.descriptionText = this.modelDescriptionSection.locator('p').first()
|
||||
this.userDescriptionTextarea =
|
||||
this.modelDescriptionSection.locator('textarea')
|
||||
this.triggerPhrasesCopyAllButton = this.modelDescriptionSection.getByRole(
|
||||
'button',
|
||||
{ name: /copy all/i }
|
||||
)
|
||||
this.triggerPhraseButtons = this.modelDescriptionSection
|
||||
.locator('button')
|
||||
.filter({ hasText: /.+/ })
|
||||
|
||||
this.selectModelPrompt = this.root.locator('.wrap-break-word.text-muted')
|
||||
}
|
||||
|
||||
async clickAsset(name: string, assetId?: string): Promise<void> {
|
||||
const assetCard = assetId
|
||||
? this.assetGrid.locator(
|
||||
`[data-component-id="AssetCard"][data-asset-id="${assetId}"]`
|
||||
)
|
||||
: this.assetGrid.locator('[data-component-id="AssetCard"]').filter({
|
||||
has: this.page.getByRole('heading', {
|
||||
name,
|
||||
exact: true
|
||||
})
|
||||
})
|
||||
|
||||
await assetCard.first().click()
|
||||
}
|
||||
|
||||
async waitForModelInfoPanel(): Promise<void> {
|
||||
await this.modelInfoPanel.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async waitForAssetContent(text: string): Promise<void> {
|
||||
await this.modelInfoPanel
|
||||
.getByText(text, { exact: false })
|
||||
.first()
|
||||
.waitFor({ state: 'visible' })
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class QueuePanel {
|
||||
readonly overlayToggle: Locator
|
||||
readonly overlay: Locator
|
||||
readonly moreOptionsButton: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.overlayToggle = page.getByTestId(TestIds.queue.overlayToggle)
|
||||
this.moreOptionsButton = page.getByLabel(/More options/i).first()
|
||||
this.overlay = page.getByTestId(TestIds.queue.progressOverlay)
|
||||
this.moreOptionsButton = this.overlay.getByLabel(/More options/i)
|
||||
}
|
||||
|
||||
async openClearHistoryDialog() {
|
||||
|
||||
64
browser_tests/fixtures/data/assetBrowserFixtures.ts
Normal file
64
browser_tests/fixtures/data/assetBrowserFixtures.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
|
||||
function createAssetBrowserModel(overrides: Partial<Asset> = {}): Asset {
|
||||
return {
|
||||
id: 'browser-model-001',
|
||||
name: 'test_model.safetensors',
|
||||
asset_hash:
|
||||
'blake3:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef',
|
||||
size: 2_147_483_648,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
created_at: '2025-01-15T10:00:00Z',
|
||||
updated_at: '2025-01-15T10:00:00Z',
|
||||
last_access_time: '2025-01-15T10:00:00Z',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export const EDITABLE_MODEL: Asset = createAssetBrowserModel({
|
||||
id: 'browser-model-editable-001',
|
||||
name: 'cinematic_details_v2.safetensors',
|
||||
tags: ['models', 'loras'],
|
||||
is_immutable: false,
|
||||
metadata: {
|
||||
description: 'A cinematic detail enhancer LoRA tuned for portraits.',
|
||||
source_arn: 'civitai:model:12345:version:67890',
|
||||
trained_words: ['cinematic lighting', 'sharp details', 'portrait glow'],
|
||||
filename: 'cinematic_details_v2.safetensors'
|
||||
},
|
||||
user_metadata: {
|
||||
name: 'Cinematic Details v2',
|
||||
base_model: ['sdxl', 'flux.1-dev'],
|
||||
additional_tags: ['portrait', 'detail'],
|
||||
user_description: 'Great for close-up portraits and high-frequency details.'
|
||||
}
|
||||
})
|
||||
|
||||
export const IMMUTABLE_MODEL: Asset = createAssetBrowserModel({
|
||||
id: 'browser-model-immutable-001',
|
||||
name: 'sdxl_base_1.0.safetensors',
|
||||
tags: ['models', 'checkpoints'],
|
||||
is_immutable: true,
|
||||
metadata: {
|
||||
description: 'Official SDXL base checkpoint from Hugging Face.',
|
||||
repo_url: 'https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0'
|
||||
},
|
||||
user_metadata: {}
|
||||
})
|
||||
|
||||
export const BARE_MODEL: Asset = createAssetBrowserModel({
|
||||
id: 'browser-model-bare-001',
|
||||
name: 'bare_checkpoint.safetensors',
|
||||
tags: ['models', 'checkpoints'],
|
||||
is_immutable: false,
|
||||
metadata: {},
|
||||
user_metadata: {}
|
||||
})
|
||||
|
||||
export const MOCK_MODEL_FOLDERS: Array<{ name: string; folders: string[] }> = [
|
||||
{ name: 'checkpoints', folders: ['main'] },
|
||||
{ name: 'loras', folders: ['style', 'detail'] },
|
||||
{ name: 'vae', folders: ['default'] },
|
||||
{ name: 'controlnet', folders: ['canny', 'depth'] }
|
||||
]
|
||||
146
browser_tests/fixtures/helpers/AssetBrowserHelper.ts
Normal file
146
browser_tests/fixtures/helpers/AssetBrowserHelper.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
|
||||
export type TagMutationCall = {
|
||||
method: string
|
||||
assetId: string
|
||||
body: { tags: string[] }
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const modelFoldersRoutePattern = /\/api\/experiment\/models(?:\?.*)?$/
|
||||
const assetTagsRoutePattern = /\/api\/assets\/([^/]+)\/tags(?:\?.*)?$/
|
||||
|
||||
export class AssetBrowserHelper {
|
||||
private readonly routeHandlers: Array<{
|
||||
pattern: string | RegExp
|
||||
handler: (route: Route) => Promise<void>
|
||||
}> = []
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockModelFolders(
|
||||
folders: Array<{ name: string; folders: string[] }>
|
||||
): Promise<void> {
|
||||
const handler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(folders)
|
||||
})
|
||||
}
|
||||
|
||||
this.routeHandlers.push({ pattern: modelFoldersRoutePattern, handler })
|
||||
await this.page.route(modelFoldersRoutePattern, handler)
|
||||
}
|
||||
|
||||
async mockAssetTags(
|
||||
initialAssets?: Array<{ id: string; tags: string[] }>
|
||||
): Promise<{ getCalls(): TagMutationCall[] }> {
|
||||
const calls: TagMutationCall[] = []
|
||||
const tagsByAssetId = new Map<string, string[]>()
|
||||
|
||||
if (initialAssets) {
|
||||
for (const asset of initialAssets) {
|
||||
tagsByAssetId.set(asset.id, [...asset.tags])
|
||||
}
|
||||
}
|
||||
|
||||
const handler = async (route: Route) => {
|
||||
const request = route.request()
|
||||
const method = request.method()
|
||||
if (method !== 'POST' && method !== 'DELETE') {
|
||||
await route.fallback()
|
||||
return
|
||||
}
|
||||
|
||||
const match = request.url().match(assetTagsRoutePattern)
|
||||
const assetId = match?.[1]
|
||||
if (!assetId) {
|
||||
await route.fallback()
|
||||
return
|
||||
}
|
||||
|
||||
const rawBody = request.postDataJSON() as { tags?: unknown }
|
||||
const tags = Array.isArray(rawBody?.tags)
|
||||
? rawBody.tags.filter((tag): tag is string => typeof tag === 'string')
|
||||
: []
|
||||
|
||||
const body = { tags }
|
||||
calls.push({
|
||||
method,
|
||||
assetId,
|
||||
body,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
const existing = tagsByAssetId.get(assetId) ?? ['models']
|
||||
const totalTags =
|
||||
method === 'POST'
|
||||
? Array.from(new Set([...existing, ...tags]))
|
||||
: existing.filter((tag) => !tags.includes(tag))
|
||||
|
||||
const added =
|
||||
method === 'POST'
|
||||
? totalTags.filter((tag) => !existing.includes(tag))
|
||||
: []
|
||||
const removed =
|
||||
method === 'DELETE'
|
||||
? existing.filter((tag) => !totalTags.includes(tag))
|
||||
: []
|
||||
|
||||
tagsByAssetId.set(assetId, totalTags)
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
total_tags: totalTags,
|
||||
added,
|
||||
removed
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
this.routeHandlers.push({ pattern: assetTagsRoutePattern, handler })
|
||||
await this.page.route(assetTagsRoutePattern, handler)
|
||||
|
||||
return {
|
||||
getCalls: () => [...calls]
|
||||
}
|
||||
}
|
||||
|
||||
async enableAssetApiSetting(): Promise<void> {
|
||||
await this.page.evaluate(async () => {
|
||||
await window.app!.extensionManager.setting.set(
|
||||
'Comfy.Assets.UseAssetAPI',
|
||||
true
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async openModelLibrary(): Promise<void> {
|
||||
await this.page.evaluate(async () => {
|
||||
await window.app!.extensionManager.command.execute(
|
||||
'Comfy.BrowseModelAssets'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
for (const { pattern, handler } of this.routeHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assetToDisplayName(asset: Asset): string {
|
||||
if (typeof asset.user_metadata?.name === 'string') {
|
||||
return asset.user_metadata.name
|
||||
}
|
||||
if (typeof asset.metadata?.name === 'string') {
|
||||
return asset.metadata.name
|
||||
}
|
||||
return asset.name
|
||||
}
|
||||
@@ -1,19 +1,26 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import type { Page, Route, WebSocketRoute } from '@playwright/test'
|
||||
|
||||
import type { LogsRawResponse } from '@/schemas/apiSchema'
|
||||
|
||||
const RAW_LOGS_URL = '**/internal/logs/raw**'
|
||||
const SUBSCRIBE_LOGS_URL = '**/internal/logs/subscribe**'
|
||||
|
||||
export class LogsTerminalHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockRawLogs(messages: string[]) {
|
||||
await this.page.route('**/internal/logs/raw**', (route: Route) =>
|
||||
route.fulfill({
|
||||
async mockRawLogs(messages: string[]): Promise<() => number> {
|
||||
let count = 0
|
||||
await this.page.unroute(RAW_LOGS_URL)
|
||||
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
|
||||
count += 1
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages))
|
||||
})
|
||||
)
|
||||
})
|
||||
return () => count
|
||||
}
|
||||
|
||||
async mockRawLogsPending(messages: string[] = []): Promise<() => void> {
|
||||
@@ -21,7 +28,8 @@ export class LogsTerminalHelper {
|
||||
const pending = new Promise<void>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
await this.page.route('**/internal/logs/raw**', async (route: Route) => {
|
||||
await this.page.unroute(RAW_LOGS_URL)
|
||||
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
|
||||
await pending
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -33,15 +41,39 @@ export class LogsTerminalHelper {
|
||||
}
|
||||
|
||||
async mockRawLogsError() {
|
||||
await this.page.route('**/internal/logs/raw**', (route: Route) =>
|
||||
await this.page.unroute(RAW_LOGS_URL)
|
||||
await this.page.route(RAW_LOGS_URL, (route: Route) =>
|
||||
route.fulfill({ status: 500, body: 'Internal Server Error' })
|
||||
)
|
||||
}
|
||||
|
||||
async mockSubscribeLogs() {
|
||||
await this.page.route('**/internal/logs/subscribe**', (route: Route) =>
|
||||
route.fulfill({ status: 200, body: '' })
|
||||
)
|
||||
async mockSubscribeLogs(): Promise<() => number> {
|
||||
let count = 0
|
||||
await this.page.unroute(SUBSCRIBE_LOGS_URL)
|
||||
await this.page.route(SUBSCRIBE_LOGS_URL, async (route: Route) => {
|
||||
count += 1
|
||||
await route.fulfill({ status: 200, body: '' })
|
||||
})
|
||||
return () => count
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the frontend to reconnect by closing the proxied WebSocket. The
|
||||
* api layer reschedules a fresh `WebSocket(...)`, the routeWebSocket
|
||||
* handler fires again, and on `open` with `isReconnect=true` it dispatches
|
||||
* `'reconnected'`, which triggers the logs-terminal resync.
|
||||
*
|
||||
* Use the resync's `subscribeLogs(true)` HTTP call as the sync point — by
|
||||
* the time the count goes up, the new socket is open and resync has
|
||||
* completed enough to assert against the terminal.
|
||||
*/
|
||||
async triggerReconnect(
|
||||
ws: WebSocketRoute,
|
||||
subscribeFetches: () => number
|
||||
): Promise<void> {
|
||||
const before = subscribeFetches()
|
||||
await ws.close()
|
||||
await expect.poll(subscribeFetches).toBeGreaterThan(before)
|
||||
}
|
||||
|
||||
static buildWsLogFrame(messages: string[]): string {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { z } from 'zod'
|
||||
import {
|
||||
zHistoryManageRequest,
|
||||
zQueueManageRequest,
|
||||
zQueueManageResponse
|
||||
} from '@comfyorg/ingest-types/zod'
|
||||
|
||||
import type {
|
||||
JobStatus,
|
||||
@@ -9,6 +14,8 @@ import type {
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
type JobsListResponse = z.infer<typeof zJobsListResponse>
|
||||
type HistoryManageRequest = z.infer<typeof zHistoryManageRequest>
|
||||
type QueueManageRequest = z.infer<typeof zQueueManageRequest>
|
||||
|
||||
const terminalJobStatuses = [
|
||||
'completed',
|
||||
@@ -22,7 +29,8 @@ const activeJobStatuses = [
|
||||
const defaultJobsListLimit = 200
|
||||
const defaultScenarioHistoryLimit = 64
|
||||
const defaultJobsListOffset = 0
|
||||
const defaultRouteMockJobTimestamp = Date.UTC(2026, 0, 1, 12)
|
||||
|
||||
export const routeMockJobTimestamp = Date.UTC(2026, 0, 1, 12)
|
||||
|
||||
interface JobsListRoute {
|
||||
statuses: readonly JobStatus[]
|
||||
@@ -65,11 +73,9 @@ function hasJobsListPageParams(
|
||||
)
|
||||
}
|
||||
|
||||
function isJobsListRequest(url: URL, route: JobsListRoute): boolean {
|
||||
function matchesJobsListRoute(url: URL, route: JobsListRoute): boolean {
|
||||
return (
|
||||
url.pathname.endsWith('/api/jobs') &&
|
||||
hasExactStatuses(url, route.statuses) &&
|
||||
hasJobsListPageParams(url, route)
|
||||
hasExactStatuses(url, route.statuses) && hasJobsListPageParams(url, route)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -99,9 +105,9 @@ export function createRouteMockJob({
|
||||
return {
|
||||
id,
|
||||
status: 'completed',
|
||||
create_time: defaultRouteMockJobTimestamp,
|
||||
execution_start_time: defaultRouteMockJobTimestamp,
|
||||
execution_end_time: defaultRouteMockJobTimestamp + 5_000,
|
||||
create_time: routeMockJobTimestamp,
|
||||
execution_start_time: routeMockJobTimestamp,
|
||||
execution_end_time: routeMockJobTimestamp + 5_000,
|
||||
preview_output: {
|
||||
filename: `output_${id}.png`,
|
||||
subfolder: '',
|
||||
@@ -150,7 +156,8 @@ export class JobsRouteMocker {
|
||||
const response = createJobsListResponse(route)
|
||||
|
||||
await this.page.route(
|
||||
(url) => isJobsListRequest(url, route),
|
||||
(url) =>
|
||||
url.pathname.endsWith('/api/jobs') && matchesJobsListRoute(url, route),
|
||||
async (requestRoute) => {
|
||||
if (requestRoute.request().method().toUpperCase() !== 'GET') {
|
||||
await requestRoute.fallback()
|
||||
@@ -161,6 +168,44 @@ export class JobsRouteMocker {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async mockClearQueue(): Promise<QueueManageRequest[]> {
|
||||
const response = zQueueManageResponse.parse({ cleared: true })
|
||||
return await this.mockPostManageRoute(
|
||||
'queue',
|
||||
zQueueManageRequest,
|
||||
response
|
||||
)
|
||||
}
|
||||
|
||||
async mockClearHistory(): Promise<HistoryManageRequest[]> {
|
||||
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
|
||||
}
|
||||
|
||||
private async mockPostManageRoute<TRequest>(
|
||||
type: 'queue' | 'history',
|
||||
requestSchema: z.ZodType<TRequest>,
|
||||
response: unknown
|
||||
): Promise<TRequest[]> {
|
||||
const requests: TRequest[] = []
|
||||
|
||||
await this.page.route(
|
||||
(url) => url.pathname.endsWith(`/api/${type}`),
|
||||
async (requestRoute) => {
|
||||
if (requestRoute.request().method().toUpperCase() !== 'POST') {
|
||||
await requestRoute.fallback()
|
||||
return
|
||||
}
|
||||
|
||||
requests.push(
|
||||
requestSchema.parse(requestRoute.request().postDataJSON())
|
||||
)
|
||||
await requestRoute.fulfill({ json: response })
|
||||
}
|
||||
)
|
||||
|
||||
return requests
|
||||
}
|
||||
}
|
||||
|
||||
export const jobsRouteFixture = base.extend<{
|
||||
@@ -168,6 +213,5 @@ export const jobsRouteFixture = base.extend<{
|
||||
}>({
|
||||
jobsRoutes: async ({ page }, use) => {
|
||||
await use(new JobsRouteMocker(page))
|
||||
await page.unrouteAll({ behavior: 'wait' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -226,7 +226,10 @@ export const TestIds = {
|
||||
currentUserIndicator: 'current-user-indicator'
|
||||
},
|
||||
queue: {
|
||||
jobHistorySidebar: 'job-history-sidebar',
|
||||
progressOverlay: 'queue-progress-overlay',
|
||||
overlayToggle: 'queue-overlay-toggle',
|
||||
dockedJobHistoryAction: 'docked-job-history-action',
|
||||
jobDetailsPopover: 'queue-job-details-popover',
|
||||
clearHistoryAction: 'clear-history-action',
|
||||
jobAssetsList: 'job-assets-list',
|
||||
|
||||
25
browser_tests/tests/assetBrowser/AGENTS.md
Normal file
25
browser_tests/tests/assetBrowser/AGENTS.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Asset Browser E2E Tests
|
||||
|
||||
Tests for the Asset Browser modal right panel (`ModelInfoPanel.vue`).
|
||||
|
||||
## Structure
|
||||
|
||||
| File | Coverage |
|
||||
| ------------------------ | ------------------------------------------------------------------------------ |
|
||||
| `modelInfoPanel.spec.ts` | Rendering, mutable/immutable behavior, editing flows, watcher resets, debounce |
|
||||
|
||||
## Shared Test Utilities
|
||||
|
||||
- `@e2e/fixtures/components/AssetBrowserModal` — Page object for modal/root grid
|
||||
and all ModelInfoPanel locators.
|
||||
- `@e2e/fixtures/helpers/AssetBrowserHelper` — Route mocks for endpoints not
|
||||
covered by `AssetHelper` (`GET /experiment/models`, `POST/DELETE /assets/:id/tags`).
|
||||
- `@e2e/fixtures/data/assetBrowserFixtures` — Typed fixtures for editable,
|
||||
immutable, and bare model states.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Set all route mocks **before** `await comfyPage.setup()` so startup fetches hit
|
||||
the mocked endpoints.
|
||||
- Use `expect.poll()` for debounced behavior assertions (metadata and tags updates).
|
||||
- Do not use `waitForTimeout()`.
|
||||
514
browser_tests/tests/assetBrowser/modelInfoPanel.spec.ts
Normal file
514
browser_tests/tests/assetBrowser/modelInfoPanel.spec.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { AssetBrowserModal } from '@e2e/fixtures/components/AssetBrowserModal'
|
||||
import {
|
||||
BARE_MODEL,
|
||||
EDITABLE_MODEL,
|
||||
IMMUTABLE_MODEL,
|
||||
MOCK_MODEL_FOLDERS
|
||||
} from '@e2e/fixtures/data/assetBrowserFixtures'
|
||||
import {
|
||||
assetToDisplayName,
|
||||
AssetBrowserHelper
|
||||
} from '@e2e/fixtures/helpers/AssetBrowserHelper'
|
||||
import type { TagMutationCall } from '@e2e/fixtures/helpers/AssetBrowserHelper'
|
||||
import { withAsset } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
|
||||
type MetadataBody = {
|
||||
user_metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
test.describe('Asset Browser - ModelInfoPanel', () => {
|
||||
let modal: AssetBrowserModal
|
||||
let assetBrowserHelper: AssetBrowserHelper
|
||||
let tagCalls: { getCalls(): TagMutationCall[] }
|
||||
|
||||
async function focusEditableModel() {
|
||||
await modal.clickAsset(
|
||||
assetToDisplayName(EDITABLE_MODEL),
|
||||
EDITABLE_MODEL.id
|
||||
)
|
||||
await modal.waitForAssetContent('cinematic_details_v2.safetensors')
|
||||
}
|
||||
|
||||
async function focusImmutableModel() {
|
||||
await modal.clickAsset(
|
||||
assetToDisplayName(IMMUTABLE_MODEL),
|
||||
IMMUTABLE_MODEL.id
|
||||
)
|
||||
await modal.waitForAssetContent('sdxl_base_1.0.safetensors')
|
||||
}
|
||||
|
||||
async function focusBareModel() {
|
||||
await modal.clickAsset(assetToDisplayName(BARE_MODEL), BARE_MODEL.id)
|
||||
await modal.waitForAssetContent('bare_checkpoint.safetensors')
|
||||
}
|
||||
|
||||
function metadataMutations(comfyPage: {
|
||||
assetApi: {
|
||||
getMutations(): Array<{ method: string; endpoint: string; body: unknown }>
|
||||
}
|
||||
}) {
|
||||
return comfyPage.assetApi
|
||||
.getMutations()
|
||||
.filter((mutation) => mutation.method === 'PUT')
|
||||
.filter((mutation) => /\/assets\/[^/]+$/.test(mutation.endpoint))
|
||||
}
|
||||
|
||||
function getLastMetadataBody(comfyPage: {
|
||||
assetApi: {
|
||||
getMutations(): Array<{ method: string; endpoint: string; body: unknown }>
|
||||
}
|
||||
}): MetadataBody | undefined {
|
||||
const list = metadataMutations(comfyPage)
|
||||
const last = list[list.length - 1]
|
||||
if (!last) return undefined
|
||||
return (last.body ?? undefined) as MetadataBody | undefined
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
comfyPage.assetApi.configure(
|
||||
withAsset(EDITABLE_MODEL),
|
||||
withAsset(IMMUTABLE_MODEL),
|
||||
withAsset(BARE_MODEL)
|
||||
)
|
||||
await comfyPage.assetApi.mock()
|
||||
|
||||
assetBrowserHelper = new AssetBrowserHelper(comfyPage.page)
|
||||
await assetBrowserHelper.mockModelFolders(MOCK_MODEL_FOLDERS)
|
||||
tagCalls = await assetBrowserHelper.mockAssetTags([
|
||||
{ id: EDITABLE_MODEL.id, tags: [...(EDITABLE_MODEL.tags ?? [])] },
|
||||
{ id: IMMUTABLE_MODEL.id, tags: [...(IMMUTABLE_MODEL.tags ?? [])] },
|
||||
{ id: BARE_MODEL.id, tags: [...(BARE_MODEL.tags ?? [])] }
|
||||
])
|
||||
|
||||
await comfyPage.setup()
|
||||
await assetBrowserHelper.enableAssetApiSetting()
|
||||
await assetBrowserHelper.openModelLibrary()
|
||||
|
||||
modal = new AssetBrowserModal(comfyPage.page)
|
||||
await expect(modal.root).toBeVisible()
|
||||
|
||||
await focusEditableModel()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await assetBrowserHelper.clearMocks()
|
||||
await comfyPage.assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test.describe('1) Panel Rendering & Basic Info', () => {
|
||||
test('shows panel after focusing an asset', async () => {
|
||||
await expect(modal.modelInfoPanel).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders display name text', async () => {
|
||||
await expect(modal.displayNameText).toContainText('Cinematic Details v2')
|
||||
})
|
||||
|
||||
test('renders filename from metadata filename', async () => {
|
||||
await expect(modal.fileNameText).toContainText(
|
||||
'cinematic_details_v2.safetensors'
|
||||
)
|
||||
})
|
||||
|
||||
test('renders source link for editable model', async () => {
|
||||
await expect(modal.sourceLink).toBeVisible()
|
||||
})
|
||||
|
||||
test('maps civitai source_arn to expected URL', async () => {
|
||||
await expect(modal.sourceLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://civitai.com/models/12345?modelVersionId=67890'
|
||||
)
|
||||
})
|
||||
|
||||
test('renders trigger phrases copy-all button', async () => {
|
||||
await expect(modal.triggerPhrasesCopyAllButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders trigger phrase buttons', async () => {
|
||||
await expect
|
||||
.poll(() => modal.triggerPhraseButtons.count())
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('renders metadata description paragraph', async () => {
|
||||
await expect(modal.descriptionText).toContainText(
|
||||
'cinematic detail enhancer'
|
||||
)
|
||||
})
|
||||
|
||||
test('renders user description in textarea', async () => {
|
||||
await expect(modal.userDescriptionTextarea).toHaveValue(
|
||||
'Great for close-up portraits and high-frequency details.'
|
||||
)
|
||||
})
|
||||
|
||||
test('hides optional metadata blocks for bare model', async () => {
|
||||
await focusBareModel()
|
||||
await expect(modal.sourceLink).toBeHidden()
|
||||
await expect(modal.descriptionText).toBeHidden()
|
||||
await expect(modal.triggerPhrasesCopyAllButton).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('2) Immutable vs Mutable', () => {
|
||||
test('hides display-name edit button for immutable asset', async () => {
|
||||
await focusImmutableModel()
|
||||
await expect(modal.editDisplayNameButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('does not render model type combobox for immutable asset', async () => {
|
||||
await focusImmutableModel()
|
||||
await expect(modal.modelTypeSelect).toBeHidden()
|
||||
})
|
||||
|
||||
test('disables base-model tags input for immutable asset', async () => {
|
||||
await focusImmutableModel()
|
||||
await expect(modal.baseModelsInput).toBeDisabled()
|
||||
})
|
||||
|
||||
test('disables additional-tags input for immutable asset', async () => {
|
||||
await focusImmutableModel()
|
||||
await expect(modal.additionalTagsInput).toBeDisabled()
|
||||
})
|
||||
|
||||
test('disables user description textarea for immutable asset', async () => {
|
||||
await focusImmutableModel()
|
||||
await expect(modal.userDescriptionTextarea).toBeDisabled()
|
||||
})
|
||||
|
||||
test('shows edit controls for mutable asset', async () => {
|
||||
await focusImmutableModel()
|
||||
await focusEditableModel()
|
||||
await expect(modal.editDisplayNameButton).toBeVisible()
|
||||
await expect(modal.modelTypeSelect).toBeVisible()
|
||||
})
|
||||
|
||||
test('enables user description textarea for mutable asset', async () => {
|
||||
await focusImmutableModel()
|
||||
await focusEditableModel()
|
||||
await expect(modal.userDescriptionTextarea).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('3) Display Name Editing', () => {
|
||||
test('enters edit mode on display-name double-click', async () => {
|
||||
await modal.displayNameText.dblclick()
|
||||
await expect(modal.displayNameInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('enters edit mode on edit button click', async () => {
|
||||
await modal.editDisplayNameButton.click()
|
||||
await expect(modal.displayNameInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('submitting new display name sends metadata update', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
await modal.editDisplayNameButton.click()
|
||||
await modal.displayNameInput.fill('My Renamed Model')
|
||||
await modal.displayNameInput.press('Enter')
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length)
|
||||
.toBeGreaterThan(initial)
|
||||
|
||||
const lastBody = getLastMetadataBody(comfyPage)
|
||||
expect(lastBody?.user_metadata?.name).toBe('My Renamed Model')
|
||||
})
|
||||
|
||||
test('submitting same display name does not send metadata update', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
await modal.editDisplayNameButton.click()
|
||||
await modal.displayNameInput.fill('Cinematic Details v2')
|
||||
await modal.displayNameInput.press('Enter')
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length, { timeout: 1200 })
|
||||
.toBe(initial)
|
||||
})
|
||||
|
||||
test('canceling display-name edit restores original text', async () => {
|
||||
await modal.editDisplayNameButton.click()
|
||||
await modal.displayNameInput.fill('Temporary Name')
|
||||
await modal.displayNameInput.press('Escape')
|
||||
|
||||
await expect(modal.displayNameText).toContainText('Cinematic Details v2')
|
||||
await expect(modal.displayNameInput).toBeHidden()
|
||||
})
|
||||
|
||||
test('submitting empty display name does not send metadata update', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
await modal.editDisplayNameButton.click()
|
||||
await modal.displayNameInput.fill('')
|
||||
await modal.displayNameInput.press('Enter')
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length, { timeout: 1200 })
|
||||
.toBe(initial)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('4) Model Type Selection', () => {
|
||||
test('shows model type options when combobox is opened', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await modal.modelTypeSelect.click()
|
||||
await expect(comfyPage.page.getByRole('option')).not.toHaveCount(0)
|
||||
})
|
||||
|
||||
test('changing model type sends tag mutation requests', async () => {
|
||||
const initial = tagCalls.getCalls().length
|
||||
|
||||
await modal.modelTypeSelect.click()
|
||||
await modal.page.getByRole('option', { name: /checkpoints/i }).click()
|
||||
|
||||
await expect
|
||||
.poll(() => tagCalls.getCalls().length)
|
||||
.toBeGreaterThan(initial)
|
||||
|
||||
const lastCall = tagCalls.getCalls().at(-1)
|
||||
expect(lastCall).toBeDefined()
|
||||
expect(lastCall?.body.tags).toContain('checkpoints')
|
||||
})
|
||||
|
||||
test('selecting same model type does not send tag mutations', async () => {
|
||||
const initial = tagCalls.getCalls().length
|
||||
|
||||
await modal.modelTypeSelect.click()
|
||||
await modal.page.getByRole('option', { name: /lora/i }).click()
|
||||
|
||||
await expect
|
||||
.poll(() => tagCalls.getCalls().length, { timeout: 1200 })
|
||||
.toBe(initial)
|
||||
})
|
||||
|
||||
test('updates combobox value immediately after selecting new model type', async () => {
|
||||
await modal.modelTypeSelect.click()
|
||||
await modal.page.getByRole('option', { name: /checkpoints/i }).click()
|
||||
await expect(modal.modelTypeSelect).toContainText(/checkpoints/i)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('5) Base Models & Additional Tags', () => {
|
||||
test('shows existing base model values', async () => {
|
||||
await expect(modal.modelTaggingSection).toContainText('sdxl')
|
||||
await expect(modal.modelTaggingSection).toContainText('flux.1-dev')
|
||||
})
|
||||
|
||||
test('shows existing additional tags values', async () => {
|
||||
await expect(modal.modelTaggingSection).toContainText('portrait')
|
||||
await expect(modal.modelTaggingSection).toContainText('detail')
|
||||
})
|
||||
|
||||
test('adding a base model sends metadata update', async ({ comfyPage }) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
await modal.baseModelsInput.click()
|
||||
await modal.baseModelsInput.fill('sd3.5-large')
|
||||
await modal.baseModelsInput.press('Enter')
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length)
|
||||
.toBeGreaterThan(initial)
|
||||
|
||||
const lastBody = getLastMetadataBody(comfyPage)
|
||||
const baseModels = lastBody?.user_metadata?.base_model as
|
||||
| string[]
|
||||
| undefined
|
||||
expect(baseModels).toContain('sd3.5-large')
|
||||
expect(baseModels).toContain('sdxl')
|
||||
expect(baseModels).toContain('flux.1-dev')
|
||||
})
|
||||
|
||||
test('removing a base model sends metadata update', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
const removeButtons = modal.baseModelsField.getByRole('button', {
|
||||
name: /remove/i
|
||||
})
|
||||
await removeButtons.first().click()
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length)
|
||||
.toBeGreaterThan(initial)
|
||||
|
||||
const lastBody = getLastMetadataBody(comfyPage)
|
||||
const baseModels = lastBody?.user_metadata?.base_model as
|
||||
| string[]
|
||||
| undefined
|
||||
expect(baseModels).toBeDefined()
|
||||
expect(baseModels!.length).toBeLessThan(2)
|
||||
})
|
||||
|
||||
test('adding an additional tag sends metadata update', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
await modal.additionalTagsInput.click()
|
||||
await modal.additionalTagsInput.fill('cinematic')
|
||||
await modal.additionalTagsInput.press('Enter')
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length)
|
||||
.toBeGreaterThan(initial)
|
||||
|
||||
const lastBody = getLastMetadataBody(comfyPage)
|
||||
const tags = lastBody?.user_metadata?.additional_tags as
|
||||
| string[]
|
||||
| undefined
|
||||
expect(tags).toContain('cinematic')
|
||||
expect(tags).toContain('portrait')
|
||||
expect(tags).toContain('detail')
|
||||
})
|
||||
|
||||
test('removing an additional tag sends metadata update', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
const removeButtons = modal.additionalTagsField.getByRole('button', {
|
||||
name: /remove/i
|
||||
})
|
||||
await removeButtons.first().click()
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length)
|
||||
.toBeGreaterThan(initial)
|
||||
|
||||
const lastBody = getLastMetadataBody(comfyPage)
|
||||
const tags = lastBody?.user_metadata?.additional_tags as
|
||||
| string[]
|
||||
| undefined
|
||||
expect(tags).toBeDefined()
|
||||
expect(tags!.length).toBeLessThan(2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('6) User Description', () => {
|
||||
test('shows existing user description value', async () => {
|
||||
await expect(modal.userDescriptionTextarea).toHaveValue(
|
||||
'Great for close-up portraits and high-frequency details.'
|
||||
)
|
||||
})
|
||||
|
||||
test('typing new description sends debounced metadata update', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
await modal.userDescriptionTextarea.fill('Updated description body')
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length)
|
||||
.toBeGreaterThan(initial)
|
||||
|
||||
const lastBody = getLastMetadataBody(comfyPage)
|
||||
expect(lastBody?.user_metadata?.user_description).toBe(
|
||||
'Updated description body'
|
||||
)
|
||||
})
|
||||
|
||||
test('escape key blurs user description textarea', async () => {
|
||||
await modal.userDescriptionTextarea.click()
|
||||
await modal.userDescriptionTextarea.press('Escape')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
modal.userDescriptionTextarea.evaluate(
|
||||
(element) => element === document.activeElement
|
||||
)
|
||||
)
|
||||
.toBe(false)
|
||||
})
|
||||
|
||||
test('clearing description sends empty-string metadata update', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
await modal.userDescriptionTextarea.fill('')
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length)
|
||||
.toBeGreaterThan(initial)
|
||||
|
||||
const lastBody = getLastMetadataBody(comfyPage)
|
||||
expect(lastBody?.user_metadata?.user_description).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('7) Watchers & State Reset', () => {
|
||||
test('switching assets resets pending metadata updates', async () => {
|
||||
await modal.userDescriptionTextarea.fill('pending draft')
|
||||
|
||||
await focusBareModel()
|
||||
await focusEditableModel()
|
||||
|
||||
await expect(modal.userDescriptionTextarea).toHaveValue(
|
||||
'Great for close-up portraits and high-frequency details.'
|
||||
)
|
||||
})
|
||||
|
||||
test('switching assets resets pending model-type state', async () => {
|
||||
await modal.modelTypeSelect.click()
|
||||
await modal.page.getByRole('option', { name: /checkpoints/i }).click()
|
||||
await expect(modal.modelTypeSelect).toContainText(/checkpoints/i)
|
||||
|
||||
await focusImmutableModel()
|
||||
await focusEditableModel()
|
||||
|
||||
await expect(modal.modelTypeSelect).toContainText(/lora/i)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('8) Debounce Behavior', () => {
|
||||
test('rapid description edits coalesce into one metadata update', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
await modal.userDescriptionTextarea.fill('draft 1')
|
||||
await modal.userDescriptionTextarea.fill('draft 2')
|
||||
await modal.userDescriptionTextarea.fill('final debounced value')
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length)
|
||||
.toBe(initial + 1)
|
||||
|
||||
const lastBody = getLastMetadataBody(comfyPage)
|
||||
expect(lastBody?.user_metadata?.user_description).toBe(
|
||||
'final debounced value'
|
||||
)
|
||||
})
|
||||
|
||||
test('rapid model type changes coalesce to final debounced mutation set', async () => {
|
||||
const initial = tagCalls.getCalls().length
|
||||
|
||||
await modal.modelTypeSelect.click()
|
||||
await modal.page.getByRole('option', { name: /checkpoints/i }).click()
|
||||
await modal.modelTypeSelect.click()
|
||||
await modal.page.getByRole('option', { name: /vae/i }).click()
|
||||
await modal.modelTypeSelect.click()
|
||||
await modal.page.getByRole('option', { name: /lora/i }).click()
|
||||
|
||||
await expect
|
||||
.poll(() => tagCalls.getCalls().length, { timeout: 1200 })
|
||||
.toBe(initial)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -147,5 +147,68 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
|
||||
)
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeHidden()
|
||||
})
|
||||
|
||||
test('resyncs the terminal when the WebSocket reconnects', async ({
|
||||
comfyPage,
|
||||
logsTerminal,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const subscribeFetches = await logsTerminal.mockSubscribeLogs()
|
||||
const initialLine = 'pre-reboot log line'
|
||||
const postRebootLineA = 'post-reboot line A'
|
||||
const postRebootLineB = 'post-reboot line B'
|
||||
|
||||
await logsTerminal.mockRawLogs([initialLine])
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
initialLine
|
||||
)
|
||||
|
||||
// Swap the raw-logs mock so the next fetch returns the post-reboot view.
|
||||
await logsTerminal.mockRawLogs([postRebootLineA, postRebootLineB])
|
||||
|
||||
const ws = await getWebSocket()
|
||||
await logsTerminal.triggerReconnect(ws, subscribeFetches)
|
||||
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
postRebootLineA
|
||||
)
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
postRebootLineB
|
||||
)
|
||||
// reset() before write means the pre-reboot line must be gone.
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).not.toContainText(
|
||||
initialLine
|
||||
)
|
||||
})
|
||||
|
||||
test('resumes WebSocket log streaming after the reconnect', async ({
|
||||
comfyPage,
|
||||
logsTerminal,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const subscribeFetches = await logsTerminal.mockSubscribeLogs()
|
||||
await logsTerminal.mockRawLogs(['initial'])
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
'initial'
|
||||
)
|
||||
|
||||
await logsTerminal.mockRawLogs(['after-reboot snapshot'])
|
||||
|
||||
const ws = await getWebSocket()
|
||||
await logsTerminal.triggerReconnect(ws, subscribeFetches)
|
||||
|
||||
// The route handler fires again on the new connection; pull the latest
|
||||
// WebSocketRoute and push a live frame to prove the 'logs' listener
|
||||
// survived the reconnect.
|
||||
const liveLine = 'live log emitted after the reconnect'
|
||||
const newWs = await getWebSocket()
|
||||
newWs.send(LogsTerminalHelper.buildWsLogFrame([liveLine]))
|
||||
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
liveLine
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
280
browser_tests/tests/sidebar/jobHistory.spec.ts
Normal file
280
browser_tests/tests/sidebar/jobHistory.spec.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
createRouteMockJob,
|
||||
jobsRouteFixture,
|
||||
routeMockJobTimestamp
|
||||
} from '@e2e/fixtures/jobsRouteFixture'
|
||||
import type { JobsRouteMocker } from '@e2e/fixtures/jobsRouteFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
|
||||
|
||||
const historyJobs: RawJobListItem[] = [
|
||||
createRouteMockJob({
|
||||
id: 'history-completed',
|
||||
status: 'completed',
|
||||
create_time: routeMockJobTimestamp - 60_000,
|
||||
execution_start_time: routeMockJobTimestamp - 60_000,
|
||||
execution_end_time: routeMockJobTimestamp - 55_000,
|
||||
preview_output: {
|
||||
filename: 'completed-output.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
}),
|
||||
createRouteMockJob({
|
||||
id: 'history-failed',
|
||||
status: 'failed',
|
||||
create_time: routeMockJobTimestamp - 120_000,
|
||||
execution_start_time: routeMockJobTimestamp - 120_000,
|
||||
execution_end_time: routeMockJobTimestamp - 118_000,
|
||||
outputs_count: 0,
|
||||
execution_error: {
|
||||
node_id: '1',
|
||||
node_type: 'SaveImage',
|
||||
exception_message: 'Intentional fixture failure',
|
||||
exception_type: 'Error',
|
||||
traceback: [],
|
||||
current_inputs: {},
|
||||
current_outputs: {}
|
||||
}
|
||||
}),
|
||||
createRouteMockJob({
|
||||
id: 'history-cancelled',
|
||||
status: 'cancelled',
|
||||
create_time: routeMockJobTimestamp - 180_000,
|
||||
execution_start_time: routeMockJobTimestamp - 180_000,
|
||||
execution_end_time: routeMockJobTimestamp - 179_000,
|
||||
outputs_count: 0
|
||||
})
|
||||
]
|
||||
|
||||
const activeJobs: RawJobListItem[] = [
|
||||
createRouteMockJob({
|
||||
id: 'queue-running',
|
||||
status: 'in_progress',
|
||||
create_time: routeMockJobTimestamp - 10_000,
|
||||
execution_start_time: routeMockJobTimestamp - 9_000,
|
||||
execution_end_time: null,
|
||||
outputs_count: 0
|
||||
}),
|
||||
createRouteMockJob({
|
||||
id: 'queue-pending',
|
||||
status: 'pending',
|
||||
create_time: routeMockJobTimestamp - 5_000,
|
||||
execution_start_time: null,
|
||||
execution_end_time: null,
|
||||
outputs_count: 0
|
||||
})
|
||||
]
|
||||
const runningOnlyJobs = activeJobs.filter((job) => job.status !== 'pending')
|
||||
|
||||
async function setupJobHistorySidebar(
|
||||
comfyPage: ComfyPage,
|
||||
jobsRoutes: JobsRouteMocker,
|
||||
{
|
||||
history = historyJobs,
|
||||
queue = activeJobs
|
||||
}: {
|
||||
history?: readonly RawJobListItem[]
|
||||
queue?: readonly RawJobListItem[]
|
||||
} = {}
|
||||
) {
|
||||
await jobsRoutes.mockJobsScenario({ history, queue })
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', true)
|
||||
await comfyPage.setup()
|
||||
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.sidebar.toolbar)
|
||||
.getByRole('button', { name: 'Job History', exact: true })
|
||||
.click()
|
||||
await expect(jobHistorySidebar(comfyPage)).toBeVisible()
|
||||
}
|
||||
|
||||
function jobRow(comfyPage: ComfyPage) {
|
||||
const list = comfyPage.page.getByTestId(TestIds.queue.jobAssetsList)
|
||||
|
||||
return (jobId: string) => list.locator(`[data-job-id="${jobId}"]`)
|
||||
}
|
||||
|
||||
function jobHistorySidebar(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.queue.jobHistorySidebar)
|
||||
}
|
||||
|
||||
function clearQueueButton(comfyPage: ComfyPage) {
|
||||
return jobHistorySidebar(comfyPage).getByRole('button', {
|
||||
name: 'Clear queue',
|
||||
exact: true
|
||||
})
|
||||
}
|
||||
|
||||
async function openSidebarClearHistoryDialog(comfyPage: ComfyPage) {
|
||||
await jobHistorySidebar(comfyPage)
|
||||
.getByLabel(/More options/i)
|
||||
.click()
|
||||
await comfyPage.page.getByTestId(TestIds.queue.clearHistoryAction).click()
|
||||
}
|
||||
|
||||
test.describe('Job history sidebar', { tag: '@ui' }, () => {
|
||||
test('opens from the queue overlay docked history action', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await jobsRoutes.mockJobsScenario({
|
||||
history: historyJobs,
|
||||
queue: activeJobs
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
|
||||
await comfyPage.setup()
|
||||
|
||||
await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click()
|
||||
await comfyPage.queuePanel.moreOptionsButton.click()
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.queue.dockedJobHistoryAction)
|
||||
.click()
|
||||
|
||||
await expect(jobHistorySidebar(comfyPage)).toBeVisible()
|
||||
await expect(jobRow(comfyPage)('history-completed')).toBeVisible()
|
||||
await expect(jobRow(comfyPage)('queue-pending')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows terminal and active job states', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await setupJobHistorySidebar(comfyPage, jobsRoutes)
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('queue-pending')).toBeVisible()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('history-failed')).toBeVisible()
|
||||
await expect(row('history-cancelled')).toBeVisible()
|
||||
|
||||
await expect(clearQueueButton(comfyPage)).toBeEnabled()
|
||||
})
|
||||
|
||||
test('filters completed and failed history jobs', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await setupJobHistorySidebar(comfyPage, jobsRoutes)
|
||||
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Completed', exact: true })
|
||||
.click()
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('history-failed')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeHidden()
|
||||
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Failed', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(row('history-failed')).toBeVisible()
|
||||
await expect(row('history-cancelled')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeHidden()
|
||||
})
|
||||
|
||||
test('searches by job id and output filename', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await setupJobHistorySidebar(comfyPage, jobsRoutes)
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
const searchInput = comfyPage.page.getByPlaceholder('Search...')
|
||||
|
||||
await searchInput.fill('history-failed')
|
||||
await expect(row('history-failed')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeHidden()
|
||||
|
||||
await searchInput.fill('completed-output')
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('history-failed')).toBeHidden()
|
||||
|
||||
await searchInput.clear()
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
})
|
||||
|
||||
test('disables clear queue when there are no pending jobs', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await setupJobHistorySidebar(comfyPage, jobsRoutes, {
|
||||
queue: runningOnlyJobs
|
||||
})
|
||||
|
||||
await expect(clearQueueButton(comfyPage)).toBeDisabled()
|
||||
})
|
||||
|
||||
test('clears pending queue jobs and leaves running/history jobs', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await setupJobHistorySidebar(comfyPage, jobsRoutes)
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('queue-pending')).toBeVisible()
|
||||
|
||||
const clearQueueRequests = await jobsRoutes.mockClearQueue()
|
||||
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
|
||||
await jobsRoutes.mockJobsScenario({
|
||||
history: historyJobs,
|
||||
queue: runningOnlyJobs
|
||||
})
|
||||
|
||||
await clearQueueButton(comfyPage).click()
|
||||
|
||||
await expect.poll(() => clearQueueRequests.length).toBe(1)
|
||||
expect(clearQueueRequests).toContainEqual({ clear: true })
|
||||
await expect(row('queue-pending')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(clearQueueButton(comfyPage)).toBeDisabled()
|
||||
expect(clearHistoryRequests).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('clears history from the sidebar menu and keeps active jobs', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await setupJobHistorySidebar(comfyPage, jobsRoutes)
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
|
||||
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
|
||||
const clearQueueRequests = await jobsRoutes.mockClearQueue()
|
||||
await jobsRoutes.mockJobsScenario({
|
||||
history: [],
|
||||
queue: activeJobs
|
||||
})
|
||||
|
||||
await openSidebarClearHistoryDialog(comfyPage)
|
||||
await expect(
|
||||
comfyPage.page.getByText('Clear your job queue history?')
|
||||
).toBeVisible()
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Clear', exact: true })
|
||||
.click()
|
||||
|
||||
await expect.poll(() => clearHistoryRequests.length).toBe(1)
|
||||
expect(clearHistoryRequests).toContainEqual({ clear: true })
|
||||
await expect(row('history-completed')).toBeHidden()
|
||||
await expect(row('history-failed')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
await expect(row('queue-pending')).toBeVisible()
|
||||
expect(clearQueueRequests).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
291
src/components/bottomPanel/tabs/terminal/LogsTerminal.test.ts
Normal file
291
src/components/bottomPanel/tabs/terminal/LogsTerminal.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import LogsTerminal from '@/components/bottomPanel/tabs/terminal/LogsTerminal.vue'
|
||||
|
||||
const apiMock = vi.hoisted(
|
||||
() =>
|
||||
new (class extends EventTarget {
|
||||
clientId: string | null = 'test-client'
|
||||
getRawLogs = vi.fn(async () => ({ entries: [{ m: 'log line\n' }] }))
|
||||
subscribeLogs = vi.fn(async () => {})
|
||||
})()
|
||||
)
|
||||
|
||||
vi.mock('@/scripts/api', () => ({ api: apiMock }))
|
||||
|
||||
const terminalMock = vi.hoisted(() => ({
|
||||
open: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
write: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
scrollToBottom: vi.fn(),
|
||||
onSelectionChange: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
hasSelection: vi.fn(() => false),
|
||||
getSelection: vi.fn(() => ''),
|
||||
selectAll: vi.fn(),
|
||||
clearSelection: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/bottomPanelTabs/useTerminal', () => ({
|
||||
useTerminal: vi.fn(() => ({
|
||||
terminal: terminalMock,
|
||||
useAutoSize: vi.fn(() => ({ resize: vi.fn() }))
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/components/bottomPanel/tabs/terminal/BaseTerminal.vue', async () => {
|
||||
const { defineComponent, ref } = await import('vue')
|
||||
const { useTerminal } =
|
||||
await import('@/composables/bottomPanelTabs/useTerminal')
|
||||
return {
|
||||
default: defineComponent({
|
||||
emits: ['created'],
|
||||
setup(_, { emit }) {
|
||||
const root = ref<HTMLElement | undefined>(undefined)
|
||||
emit('created', useTerminal(root), root)
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
logsTerminal: {
|
||||
loadError:
|
||||
'Unable to load logs, please ensure you have updated your ComfyUI backend.',
|
||||
resyncError:
|
||||
'Unable to resync logs after the backend reconnected. Reopen the console to retry.'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const renderLogsTerminal = () =>
|
||||
render(LogsTerminal, {
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
stubActions: false,
|
||||
initialState: { execution: { clientId: 'test-client' } }
|
||||
}),
|
||||
i18n
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Silence the production console.error calls in error-path tests. Vitest
|
||||
// isolates this file's module graph so the spy does not affect other files.
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
// Resolve a getRawLogs call manually to drive deterministic timing in tests
|
||||
// that need to observe behavior mid-fetch.
|
||||
const deferredRawLogs = () => {
|
||||
let resolve!: (value: { entries: { m: string }[] }) => void
|
||||
let reject!: (err: unknown) => void
|
||||
const promise = new Promise<{ entries: { m: string }[] }>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('LogsTerminal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
apiMock.clientId = 'test-client'
|
||||
})
|
||||
|
||||
it('loads logs and subscribes to streaming on mount', async () => {
|
||||
renderLogsTerminal()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
|
||||
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
|
||||
expect(terminalMock.write).toHaveBeenCalledWith('log line\n')
|
||||
})
|
||||
})
|
||||
|
||||
it('resyncs, snaps to tail, and re-subscribes on "reconnected"', async () => {
|
||||
renderLogsTerminal()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
apiMock.dispatchEvent(new CustomEvent('reconnected'))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(2)
|
||||
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
|
||||
expect(terminalMock.scrollToBottom).toHaveBeenCalledTimes(1)
|
||||
expect(apiMock.subscribeLogs).toHaveBeenCalledTimes(2)
|
||||
expect(apiMock.subscribeLogs).toHaveBeenLastCalledWith(true)
|
||||
})
|
||||
|
||||
// The full sequence must be: reset -> write -> scroll -> subscribe
|
||||
const resetOrder = terminalMock.reset.mock.invocationCallOrder[0]
|
||||
const writeOrder = terminalMock.write.mock.invocationCallOrder.at(-1)!
|
||||
const scrollOrder = terminalMock.scrollToBottom.mock.invocationCallOrder[0]
|
||||
const subscribeOrder =
|
||||
apiMock.subscribeLogs.mock.invocationCallOrder.at(-1)!
|
||||
expect(resetOrder).toBeLessThan(writeOrder)
|
||||
expect(writeOrder).toBeLessThan(scrollOrder)
|
||||
expect(scrollOrder).toBeLessThan(subscribeOrder)
|
||||
})
|
||||
|
||||
it('aborts an in-flight resync when a second "reconnected" arrives', async () => {
|
||||
renderLogsTerminal()
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
// First resync hangs on getRawLogs
|
||||
const first = deferredRawLogs()
|
||||
apiMock.getRawLogs.mockImplementationOnce(() => first.promise)
|
||||
apiMock.dispatchEvent(new CustomEvent('reconnected'))
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
// Second resync resolves immediately
|
||||
apiMock.getRawLogs.mockImplementationOnce(async () => ({
|
||||
entries: [{ m: 'fresh\n' }]
|
||||
}))
|
||||
apiMock.dispatchEvent(new CustomEvent('reconnected'))
|
||||
await vi.waitFor(() => {
|
||||
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Now resolve the first (aborted) resync — none of its side effects must apply
|
||||
first.resolve({ entries: [{ m: 'stale\n' }] })
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
|
||||
expect(terminalMock.write).not.toHaveBeenCalledWith('stale\n')
|
||||
expect(terminalMock.write).toHaveBeenCalledWith('fresh\n')
|
||||
})
|
||||
|
||||
it('aborts an in-flight mount fetch when "reconnected" arrives first', async () => {
|
||||
// Mount's getRawLogs hangs so we can drive the race deterministically.
|
||||
const mount = deferredRawLogs()
|
||||
apiMock.getRawLogs.mockImplementationOnce(() => mount.promise)
|
||||
renderLogsTerminal()
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Resync wins the race and writes the post-reboot snapshot.
|
||||
apiMock.getRawLogs.mockImplementationOnce(async () => ({
|
||||
entries: [{ m: 'fresh\n' }]
|
||||
}))
|
||||
apiMock.dispatchEvent(new CustomEvent('reconnected'))
|
||||
await vi.waitFor(() => {
|
||||
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
|
||||
expect(terminalMock.write).toHaveBeenCalledWith('fresh\n')
|
||||
})
|
||||
|
||||
// Mount's late response must not stomp on the freshly-reset terminal.
|
||||
mount.resolve({ entries: [{ m: 'stale-mount\n' }] })
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(terminalMock.write).not.toHaveBeenCalledWith('stale-mount\n')
|
||||
})
|
||||
|
||||
it('surfaces an inline error when the resync fetch fails', async () => {
|
||||
renderLogsTerminal()
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
apiMock.getRawLogs.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
apiMock.dispatchEvent(new CustomEvent('reconnected'))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('terminal-error-message').textContent
|
||||
).toContain('Unable to resync logs')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a load error when the initial fetch fails', async () => {
|
||||
apiMock.getRawLogs.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
renderLogsTerminal()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('terminal-error-message').textContent
|
||||
).toContain('Unable to load logs')
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers from an initial load failure when a reconnect arrives', async () => {
|
||||
apiMock.getRawLogs
|
||||
.mockRejectedValueOnce(new Error('initial fail'))
|
||||
.mockResolvedValueOnce({ entries: [{ m: 'recovered\n' }] })
|
||||
|
||||
renderLogsTerminal()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('terminal-error-message').textContent
|
||||
).toContain('Unable to load logs')
|
||||
})
|
||||
|
||||
apiMock.dispatchEvent(new CustomEvent('reconnected'))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId('terminal-error-message')).toBeNull()
|
||||
expect(screen.queryByTestId('terminal-loading-spinner')).toBeNull()
|
||||
expect(terminalMock.write).toHaveBeenCalledWith('recovered\n')
|
||||
})
|
||||
})
|
||||
|
||||
it('cleans up listeners and unsubscribes on unmount', async () => {
|
||||
const { unmount } = renderLogsTerminal()
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
unmount()
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
apiMock.dispatchEvent(new CustomEvent('reconnected'))
|
||||
await nextTick()
|
||||
|
||||
expect(terminalMock.reset).not.toHaveBeenCalled()
|
||||
// No additional getRawLogs beyond the mount-time call
|
||||
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not write to the terminal when unmount happens mid-fetch', async () => {
|
||||
const pending = deferredRawLogs()
|
||||
apiMock.getRawLogs.mockImplementationOnce(() => pending.promise)
|
||||
|
||||
const { unmount } = renderLogsTerminal()
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
unmount()
|
||||
pending.resolve({ entries: [{ m: 'late\n' }] })
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(terminalMock.write).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -12,79 +12,36 @@
|
||||
data-testid="terminal-loading-spinner"
|
||||
class="relative inset-0 z-10 flex h-full items-center justify-center"
|
||||
/>
|
||||
<BaseTerminal v-show="!loading" @created="terminalCreated" />
|
||||
<BaseTerminal
|
||||
v-show="!loading && !errorMessage"
|
||||
@created="terminalCreated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { until } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
import type { LogEntry, LogsWsMessage } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useLogsTerminal } from '@/composables/bottomPanelTabs/useLogsTerminal'
|
||||
|
||||
import BaseTerminal from './BaseTerminal.vue'
|
||||
|
||||
const errorMessage = ref('')
|
||||
const loading = ref(true)
|
||||
const terminal = shallowRef<Terminal>()
|
||||
const { errorMessage, loading } = useLogsTerminal(terminal)
|
||||
|
||||
const terminalCreated = (
|
||||
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
|
||||
{ terminal: instance, useAutoSize }: ReturnType<typeof useTerminal>,
|
||||
root: Ref<HTMLElement | undefined>
|
||||
) => {
|
||||
// Auto-size terminal to fill container width.
|
||||
// minCols: 80 ensures minimum width for colab environments.
|
||||
// See https://github.com/comfyanonymous/ComfyUI/issues/6396
|
||||
useAutoSize({ root, autoRows: true, autoCols: true, minCols: 80 })
|
||||
|
||||
const update = (entries: Array<LogEntry>) => {
|
||||
terminal.write(entries.map((e) => e.m).join(''))
|
||||
}
|
||||
|
||||
const logReceived = (e: CustomEvent<LogsWsMessage>) => {
|
||||
update(e.detail.entries)
|
||||
}
|
||||
|
||||
const loadLogEntries = async () => {
|
||||
const logs = await api.getRawLogs()
|
||||
update(logs.entries)
|
||||
}
|
||||
|
||||
const watchLogs = async () => {
|
||||
const { clientId } = storeToRefs(useExecutionStore())
|
||||
if (!clientId.value) {
|
||||
await until(clientId).not.toBeNull()
|
||||
}
|
||||
await api.subscribeLogs(true)
|
||||
api.addEventListener('logs', logReceived)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadLogEntries()
|
||||
} catch (err) {
|
||||
console.error('Error loading logs', err)
|
||||
// On older backends the endpoints won't exist
|
||||
errorMessage.value =
|
||||
'Unable to load logs, please ensure you have updated your ComfyUI backend.'
|
||||
return
|
||||
}
|
||||
|
||||
await watchLogs()
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
onUnmounted(async () => {
|
||||
if (api.clientId) {
|
||||
await api.subscribeLogs(false)
|
||||
}
|
||||
api.removeEventListener('logs', logReceived)
|
||||
})
|
||||
terminal.value = instance
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
:class="['flex', 'justify-end', 'w-full', 'pointer-events-none']"
|
||||
>
|
||||
<div
|
||||
data-testid="queue-progress-overlay"
|
||||
class="pointer-events-auto flex max-h-[60vh] w-[350px] min-w-[310px] flex-col overflow-hidden rounded-lg border font-inter transition-colors duration-200 ease-in-out"
|
||||
:class="containerClass"
|
||||
@mouseenter="isHovered = true"
|
||||
|
||||
@@ -237,7 +237,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||
@@ -309,8 +309,8 @@ const formattedExecutionTime = computed(() => {
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const inputAssets = useMediaAssets('input')
|
||||
const outputAssets = useMediaAssets('output')
|
||||
const inputAssets = useAssetsApi('input')
|
||||
const outputAssets = useAssetsApi('output')
|
||||
|
||||
// Asset selection
|
||||
const {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<SidebarTabTemplate :title="$t('queue.jobHistory')">
|
||||
<SidebarTabTemplate
|
||||
data-testid="job-history-sidebar"
|
||||
:title="$t('queue.jobHistory')"
|
||||
>
|
||||
<template #alt-title>
|
||||
<div class="ml-auto flex shrink-0 items-center">
|
||||
<JobHistoryActionsMenu @clear-history="onClearHistory" />
|
||||
|
||||
123
src/composables/bottomPanelTabs/useLogsTerminal.ts
Normal file
123
src/composables/bottomPanelTabs/useLogsTerminal.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { until, useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onScopeDispose, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LogEntry, LogsWsMessage } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
type TerminalLike = {
|
||||
write: (data: string) => void
|
||||
reset: () => void
|
||||
scrollToBottom: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the built-in logs terminal: initial load, live `logs` stream, and
|
||||
* full resync when the backend WebSocket reconnects (e.g., after a reboot).
|
||||
*
|
||||
* Listeners are registered synchronously so we cannot miss a `reconnected`
|
||||
* event during the mount-time fetch/subscribe awaits. In-flight fetches are
|
||||
* tied to AbortControllers so that:
|
||||
* - rapid double-reconnects don't interleave writes / double-subscribe
|
||||
* - unmount mid-fetch never writes to a disposed terminal
|
||||
*/
|
||||
export function useLogsTerminal(
|
||||
terminal: Readonly<Ref<TerminalLike | undefined>>
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
const errorMessage = ref('')
|
||||
const loading = ref(true)
|
||||
|
||||
let mountController: AbortController | undefined
|
||||
let resyncController: AbortController | undefined
|
||||
|
||||
const writeEntries = (entries: LogEntry[]) => {
|
||||
terminal.value?.write(entries.map((e) => e.m).join(''))
|
||||
}
|
||||
|
||||
const resyncLogs = async () => {
|
||||
// Cancel both the in-flight mount fetch and any prior resync so a late
|
||||
// mount response can't write a stale snapshot on top of a freshly-reset
|
||||
// terminal after we've already written the post-reconnect view.
|
||||
mountController?.abort()
|
||||
resyncController?.abort()
|
||||
const controller = new AbortController()
|
||||
resyncController = controller
|
||||
const { signal } = controller
|
||||
|
||||
try {
|
||||
const logs = await api.getRawLogs()
|
||||
if (signal.aborted || !terminal.value) return
|
||||
terminal.value.reset()
|
||||
writeEntries(logs.entries)
|
||||
terminal.value.scrollToBottom()
|
||||
// Backend lost the per-client log subscription across the restart;
|
||||
// re-subscribe so new runtime logs stream over the fresh WebSocket.
|
||||
await api.subscribeLogs(true)
|
||||
if (signal.aborted) return
|
||||
errorMessage.value = ''
|
||||
loading.value = false
|
||||
} catch (err) {
|
||||
if (signal.aborted) return
|
||||
console.error('Error resyncing logs after reconnect', err)
|
||||
errorMessage.value = t('logsTerminal.resyncError')
|
||||
}
|
||||
}
|
||||
|
||||
// Register listeners synchronously, before any awaits, so a reconnect
|
||||
// fired during mount cannot be missed. useEventListener handles cleanup
|
||||
// on scope dispose.
|
||||
useEventListener(api, 'logs', (e: CustomEvent<LogsWsMessage>) => {
|
||||
writeEntries(e.detail.entries)
|
||||
})
|
||||
useEventListener(api, 'reconnected', () => {
|
||||
void resyncLogs()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!terminal.value) await until(terminal).toBeTruthy()
|
||||
|
||||
const controller = new AbortController()
|
||||
mountController = controller
|
||||
const { signal } = controller
|
||||
|
||||
try {
|
||||
const logs = await api.getRawLogs()
|
||||
if (signal.aborted || !terminal.value) return
|
||||
writeEntries(logs.entries)
|
||||
} catch (err) {
|
||||
if (signal.aborted) return
|
||||
console.error('Error loading logs', err)
|
||||
errorMessage.value = t('logsTerminal.loadError')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const { clientId } = storeToRefs(useExecutionStore())
|
||||
if (!clientId.value) await until(clientId).not.toBeNull()
|
||||
if (signal.aborted) return
|
||||
|
||||
try {
|
||||
await api.subscribeLogs(true)
|
||||
} catch (err) {
|
||||
if (signal.aborted) return
|
||||
console.error('Error subscribing to logs', err)
|
||||
}
|
||||
|
||||
if (!signal.aborted) loading.value = false
|
||||
})
|
||||
|
||||
onScopeDispose(() => {
|
||||
mountController?.abort()
|
||||
resyncController?.abort()
|
||||
if (!api.clientId) return
|
||||
api.subscribeLogs(false).catch((err) => {
|
||||
console.error('Error unsubscribing from logs', err)
|
||||
})
|
||||
})
|
||||
|
||||
return { errorMessage, loading }
|
||||
}
|
||||
@@ -1160,6 +1160,10 @@
|
||||
"saveAsTemplate": "Save as template",
|
||||
"enterName": "Enter name"
|
||||
},
|
||||
"logsTerminal": {
|
||||
"loadError": "Unable to load logs, please ensure you have updated your ComfyUI backend.",
|
||||
"resyncError": "Unable to resync logs after the backend reconnected. Reopen the console to retry."
|
||||
},
|
||||
"workflowService": {
|
||||
"exportWorkflow": "Export Workflow",
|
||||
"enterFilename": "Enter the filename",
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
|
||||
/**
|
||||
* Composable for fetching media assets from local environment
|
||||
* Uses AssetsStore for centralized state management
|
||||
*/
|
||||
export function useInternalFilesApi(directory: 'input' | 'output') {
|
||||
const assetsStore = useAssetsStore()
|
||||
|
||||
const media = computed(() =>
|
||||
directory === 'input' ? assetsStore.inputAssets : assetsStore.historyAssets
|
||||
)
|
||||
|
||||
const loading = computed(() =>
|
||||
directory === 'input'
|
||||
? assetsStore.inputLoading
|
||||
: assetsStore.historyLoading
|
||||
)
|
||||
|
||||
const error = computed(() =>
|
||||
directory === 'input' ? assetsStore.inputError : assetsStore.historyError
|
||||
)
|
||||
|
||||
const fetchMediaList = async (): Promise<AssetItem[]> => {
|
||||
if (directory === 'input') {
|
||||
await assetsStore.updateInputs()
|
||||
return assetsStore.inputAssets
|
||||
} else {
|
||||
await assetsStore.updateHistory()
|
||||
return assetsStore.historyAssets
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = () => fetchMediaList()
|
||||
|
||||
const loadMore = async (): Promise<void> => {
|
||||
if (directory === 'output') {
|
||||
await assetsStore.loadMoreHistory()
|
||||
}
|
||||
}
|
||||
|
||||
const hasMore = computed(() => {
|
||||
return directory === 'output' ? assetsStore.hasMoreHistory : false
|
||||
})
|
||||
|
||||
const isLoadingMore = computed(() => {
|
||||
return directory === 'output' ? assetsStore.isLoadingMore : false
|
||||
})
|
||||
|
||||
return {
|
||||
media,
|
||||
loading,
|
||||
error,
|
||||
fetchMediaList,
|
||||
refresh,
|
||||
loadMore,
|
||||
hasMore,
|
||||
isLoadingMore
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
import type { IAssetsProvider } from './IAssetsProvider'
|
||||
import { useAssetsApi } from './useAssetsApi'
|
||||
import { useInternalFilesApi } from './useInternalFilesApi'
|
||||
|
||||
/**
|
||||
* Factory function that returns the appropriate media assets implementation
|
||||
* based on the current distribution (cloud vs internal)
|
||||
* @param directory - The directory to fetch assets from
|
||||
* @returns IAssetsProvider implementation
|
||||
*/
|
||||
export function useMediaAssets(directory: 'input' | 'output'): IAssetsProvider {
|
||||
return isCloud ? useAssetsApi(directory) : useInternalFilesApi(directory)
|
||||
}
|
||||
91
src/platform/cloud/onboarding/auth.test.ts
Normal file
91
src/platform/cloud/onboarding/auth.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
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('404 → true (do not bounce on missing key)', async () => {
|
||||
fetchApi.mockResolvedValueOnce(mockResponse({ ok: false, status: 404 }))
|
||||
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
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 fall under the same "ambiguous => treat as completed" policy.
|
||||
// The dedicated auth layer handles re-authentication on the next API
|
||||
// call; this function deliberately does not try to disambiguate auth
|
||||
// failures from other non-OK responses. 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)
|
||||
})
|
||||
})
|
||||
@@ -96,23 +96,24 @@ export async function getSurveyCompletedStatus(): Promise<boolean> {
|
||||
}
|
||||
})
|
||||
if (!response.ok) {
|
||||
// Not an error case - survey not completed is a valid state
|
||||
// Ambiguous response (404/5xx/etc). Treat as completed to avoid
|
||||
// bouncing working customers to /cloud/survey on transient hiccups.
|
||||
// Real "not completed" only comes from a 200 with empty value.
|
||||
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 policy as ambiguous HTTP responses.
|
||||
Sentry.captureException(error, {
|
||||
tags: {
|
||||
api_endpoint: '/settings/{key}',
|
||||
@@ -124,7 +125,7 @@ export async function getSurveyCompletedStatus(): Promise<boolean> {
|
||||
},
|
||||
level: 'warning'
|
||||
})
|
||||
return false
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ const selectAsLatestFn = vi.fn()
|
||||
const resolveIfReadyFn = vi.fn()
|
||||
const resolvedOutputsCacheRef = new Map<string, ResultItemImpl[]>()
|
||||
|
||||
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
|
||||
useMediaAssets: () => ({
|
||||
vi.mock('@/platform/assets/composables/media/useAssetsApi', () => ({
|
||||
useAssetsApi: () => ({
|
||||
media: mediaRef,
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ComputedRef } from 'vue'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
|
||||
import type { IAssetsProvider } from '@/platform/assets/composables/media/IAssetsProvider'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -25,7 +25,7 @@ export function useOutputHistory(): {
|
||||
isWorkflowActive: ComputedRef<boolean>
|
||||
cancelActiveWorkflowJobs: () => Promise<void>
|
||||
} {
|
||||
const backingOutputs = useMediaAssets('output')
|
||||
const backingOutputs = useAssetsApi('output')
|
||||
void backingOutputs.fetchMediaList()
|
||||
const linearStore = useLinearOutputStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
@@ -69,8 +69,8 @@ const { mockMediaAssets } = vi.hoisted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
|
||||
useMediaAssets: () => mockMediaAssets
|
||||
vi.mock('@/platform/assets/composables/media/useAssetsApi', () => ({
|
||||
useAssetsApi: () => mockMediaAssets
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
|
||||
import { AssetKindKey } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
@@ -47,7 +47,7 @@ const modelValue = defineModel<string | undefined>({
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const outputMediaAssets = useMediaAssets('output')
|
||||
const outputMediaAssets = useAssetsApi('output')
|
||||
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ function createMockMediaAssets() {
|
||||
|
||||
let mockMediaAssets = createMockMediaAssets()
|
||||
|
||||
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
|
||||
useMediaAssets: () => mockMediaAssets
|
||||
vi.mock('@/platform/assets/composables/media/useAssetsApi', () => ({
|
||||
useAssetsApi: () => mockMediaAssets
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/composables/useAssetFilterOptions', () => ({
|
||||
|
||||
@@ -25,7 +25,7 @@ import type { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import type { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import type { IAssetsProvider } from '@/platform/assets/composables/media/IAssetsProvider'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
@@ -65,7 +65,7 @@ interface UseWidgetSelectItemsOptions {
|
||||
>
|
||||
modelValue: Ref<string | undefined>
|
||||
assetKind: MaybeRefOrGetter<AssetKind | undefined>
|
||||
outputMediaAssets: ReturnType<typeof useMediaAssets>
|
||||
outputMediaAssets: IAssetsProvider
|
||||
assetData: ReturnType<typeof useAssetWidgetData> | null
|
||||
isAssetMode: MaybeRefOrGetter<boolean | undefined>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user