mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-02 21:28:08 +00:00
Compare commits
1 Commits
codex/cove
...
codex/cove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccd33bb505 |
140
src/platform/secrets/api/secretsApi.test.ts
Normal file
140
src/platform/secrets/api/secretsApi.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import {
|
||||
createSecret,
|
||||
deleteSecret,
|
||||
listSecrets,
|
||||
updateSecret
|
||||
} from './secretsApi'
|
||||
import type { SecretsApiError } from './secretsApi'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const fetchApi = vi.mocked(api.fetchApi)
|
||||
|
||||
const secret = {
|
||||
id: 'secret-1',
|
||||
name: 'HF token',
|
||||
provider: 'huggingface' as const,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, init: ResponseInit = {}) {
|
||||
const headers = new Headers(init.headers)
|
||||
if (!headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
...init,
|
||||
headers
|
||||
})
|
||||
}
|
||||
|
||||
describe('secretsApi', () => {
|
||||
beforeEach(() => {
|
||||
fetchApi.mockReset()
|
||||
})
|
||||
|
||||
it('lists secrets from the API response data field', async () => {
|
||||
fetchApi.mockResolvedValue(jsonResponse({ data: [secret] }))
|
||||
|
||||
await expect(listSecrets()).resolves.toEqual([secret])
|
||||
expect(fetchApi).toHaveBeenCalledWith('/secrets')
|
||||
})
|
||||
|
||||
it('creates and updates secrets with JSON payloads', async () => {
|
||||
fetchApi
|
||||
.mockResolvedValueOnce(jsonResponse(secret))
|
||||
.mockResolvedValueOnce(jsonResponse(secret))
|
||||
|
||||
await expect(
|
||||
createSecret({
|
||||
name: 'HF token',
|
||||
secret_value: 'token',
|
||||
provider: 'huggingface'
|
||||
})
|
||||
).resolves.toEqual(secret)
|
||||
expect(fetchApi).toHaveBeenLastCalledWith('/secrets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'HF token',
|
||||
secret_value: 'token',
|
||||
provider: 'huggingface'
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
updateSecret('secret-1', { name: 'New name' })
|
||||
).resolves.toEqual(secret)
|
||||
expect(fetchApi).toHaveBeenLastCalledWith('/secrets/secret-1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'New name' })
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes secrets without reading a success body', async () => {
|
||||
fetchApi.mockResolvedValue(new Response(null, { status: 204 }))
|
||||
|
||||
await expect(deleteSecret('secret-1')).resolves.toBeUndefined()
|
||||
expect(fetchApi).toHaveBeenCalledWith('/secrets/secret-1', {
|
||||
method: 'DELETE'
|
||||
})
|
||||
})
|
||||
|
||||
it('throws a typed error for known API error codes', async () => {
|
||||
fetchApi.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ message: 'Duplicate provider', code: 'DUPLICATE_PROVIDER' },
|
||||
{ status: 409, statusText: 'Conflict' }
|
||||
)
|
||||
)
|
||||
|
||||
await expect(listSecrets()).rejects.toMatchObject({
|
||||
name: 'SecretsApiError',
|
||||
message: 'Duplicate provider',
|
||||
status: 409,
|
||||
code: 'DUPLICATE_PROVIDER'
|
||||
} satisfies Partial<SecretsApiError>)
|
||||
})
|
||||
|
||||
it('falls back to status text for non-JSON error responses', async () => {
|
||||
fetchApi.mockResolvedValue(
|
||||
new Response('not-json', {
|
||||
status: 500,
|
||||
statusText: 'Server Error'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(listSecrets()).rejects.toMatchObject({
|
||||
message: 'Server Error',
|
||||
status: 500,
|
||||
code: undefined
|
||||
} satisfies Partial<SecretsApiError>)
|
||||
})
|
||||
|
||||
it('ignores unknown API error codes', async () => {
|
||||
fetchApi.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ message: 'Unexpected', code: 'SOMETHING_ELSE' },
|
||||
{ status: 400, statusText: 'Bad Request' }
|
||||
)
|
||||
)
|
||||
|
||||
await expect(listSecrets()).rejects.toMatchObject({
|
||||
message: 'Unexpected',
|
||||
status: 400,
|
||||
code: undefined
|
||||
} satisfies Partial<SecretsApiError>)
|
||||
})
|
||||
})
|
||||
24
src/platform/secrets/providers.test.ts
Normal file
24
src/platform/secrets/providers.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getProviderLabel, getProviderLogo } from './providers'
|
||||
|
||||
import type { SecretProvider } from './types'
|
||||
|
||||
describe('secret providers', () => {
|
||||
it('returns empty display values when provider is missing', () => {
|
||||
expect(getProviderLabel(undefined)).toBe('')
|
||||
expect(getProviderLogo(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns configured labels and logos', () => {
|
||||
expect(getProviderLabel('huggingface')).toBe('HuggingFace')
|
||||
expect(getProviderLogo('civitai')).toBe('/assets/images/civitai.svg')
|
||||
})
|
||||
|
||||
it('falls back to the provider value for unknown labels', () => {
|
||||
const provider = 'custom' as SecretProvider
|
||||
|
||||
expect(getProviderLabel(provider)).toBe('custom')
|
||||
expect(getProviderLogo(provider)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
141
src/stores/aboutPanelStore.test.ts
Normal file
141
src/stores/aboutPanelStore.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import type { AboutPageBadge } from '@/types/comfy'
|
||||
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
|
||||
|
||||
type SystemInfo = Partial<SystemStats['system']>
|
||||
|
||||
const { dist, stats, exts } = vi.hoisted(() => ({
|
||||
dist: { isCloud: false, isDesktop: false },
|
||||
stats: { system: {} as SystemInfo },
|
||||
exts: { list: [] as { aboutPageBadges?: AboutPageBadge[] }[] }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return dist.isCloud
|
||||
},
|
||||
get isDesktop() {
|
||||
return dist.isDesktop
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: () => ({
|
||||
staticUrls: {
|
||||
github: 'https://github.com/comfyanonymous/ComfyUI',
|
||||
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
|
||||
comfyOrg: 'https://comfy.org',
|
||||
discord: 'https://discord.com'
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: () => ({ getComfyUIVersion: () => '9.9.9' })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/extensionStore', () => ({
|
||||
useExtensionStore: () => ({ extensions: exts.list })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/systemStatsStore', () => ({
|
||||
useSystemStatsStore: () => ({ systemStats: stats })
|
||||
}))
|
||||
|
||||
function label(badges: AboutPageBadge[], includes: string) {
|
||||
return badges.find((b) => b.label.includes(includes))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
dist.isCloud = false
|
||||
dist.isDesktop = false
|
||||
stats.system = {}
|
||||
exts.list = []
|
||||
})
|
||||
|
||||
describe('aboutPanelStore', () => {
|
||||
it('builds the default desktop-less, non-cloud core badges', () => {
|
||||
stats.system = { comfyui_version: 'abc1234' }
|
||||
const store = useAboutPanelStore()
|
||||
|
||||
const core = label(store.badges, 'ComfyUI ')!
|
||||
expect(core.icon).toBe('pi pi-github')
|
||||
expect(core.url).toContain('github.com/comfyanonymous')
|
||||
expect(label(store.badges, 'ComfyUI_frontend')).toBeDefined()
|
||||
expect(label(store.badges, 'Discord')).toBeDefined()
|
||||
expect(label(store.badges, 'Templates')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses cloud url and icon for the core badge when running on cloud', () => {
|
||||
dist.isCloud = true
|
||||
const store = useAboutPanelStore()
|
||||
|
||||
const core = label(store.badges, 'ComfyUI ')!
|
||||
expect(core.icon).toBe('pi pi-cloud')
|
||||
expect(core.url).toBe('https://comfy.org')
|
||||
})
|
||||
|
||||
it('uses the electron-reported version label on desktop', () => {
|
||||
dist.isDesktop = true
|
||||
const store = useAboutPanelStore()
|
||||
|
||||
expect(label(store.badges, 'ComfyUI v9.9.9')).toBeDefined()
|
||||
})
|
||||
|
||||
it('adds a danger templates badge when the installed version is outdated', () => {
|
||||
stats.system = {
|
||||
installed_templates_version: '1.0.0',
|
||||
required_templates_version: '1.1.0'
|
||||
}
|
||||
const store = useAboutPanelStore()
|
||||
|
||||
const templates = label(store.badges, 'Templates v1.0.0')!
|
||||
expect(templates.severity).toBe('danger')
|
||||
})
|
||||
|
||||
it('adds a templates badge without severity when versions match', () => {
|
||||
stats.system = {
|
||||
installed_templates_version: '1.1.0',
|
||||
required_templates_version: '1.1.0'
|
||||
}
|
||||
const store = useAboutPanelStore()
|
||||
|
||||
const templates = label(store.badges, 'Templates v1.1.0')!
|
||||
expect(templates.severity).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not mark templates outdated when the required version is missing', () => {
|
||||
stats.system = {
|
||||
installed_templates_version: '1.1.0'
|
||||
}
|
||||
const store = useAboutPanelStore()
|
||||
|
||||
const templates = label(store.badges, 'Templates v1.1.0')!
|
||||
expect(templates.severity).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits the templates badge when the installed version is missing', () => {
|
||||
stats.system = {
|
||||
required_templates_version: '1.1.0'
|
||||
}
|
||||
const store = useAboutPanelStore()
|
||||
|
||||
expect(label(store.badges, 'Templates')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('appends extension badges and tolerates extensions without any', () => {
|
||||
exts.list = [
|
||||
{
|
||||
aboutPageBadges: [{ label: 'My Ext', url: 'https://ext', icon: 'pi' }]
|
||||
},
|
||||
{} // extension without aboutPageBadges -> ?? [] branch
|
||||
]
|
||||
const store = useAboutPanelStore()
|
||||
|
||||
expect(label(store.badges, 'My Ext')).toBeDefined()
|
||||
})
|
||||
})
|
||||
137
src/stores/apiKeyAuthStore.test.ts
Normal file
137
src/stores/apiKeyAuthStore.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
|
||||
const authStoreMock = vi.hoisted(() => ({
|
||||
createCustomer: vi.fn()
|
||||
}))
|
||||
|
||||
const toastStoreMock = vi.hoisted(() => ({
|
||||
add: vi.fn()
|
||||
}))
|
||||
|
||||
const errorHandlingMock = vi.hoisted(() => ({
|
||||
toastErrorHandler: vi.fn(),
|
||||
forceGenericFailure: false,
|
||||
forceStorageFailure: false
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => authStoreMock
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => toastStoreMock
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
toastErrorHandler: errorHandlingMock.toastErrorHandler,
|
||||
wrapWithErrorHandlingAsync:
|
||||
(
|
||||
fn: (value?: string) => Promise<boolean>,
|
||||
onError: (e: unknown) => void
|
||||
) =>
|
||||
async (value?: string) => {
|
||||
try {
|
||||
if (errorHandlingMock.forceStorageFailure) {
|
||||
throw new Error('STORAGE_FAILED')
|
||||
}
|
||||
if (errorHandlingMock.forceGenericFailure) {
|
||||
throw new Error('OTHER_FAILED')
|
||||
}
|
||||
return await fn(value)
|
||||
} catch (e) {
|
||||
onError(e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
describe('apiKeyAuthStore', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
authStoreMock.createCustomer.mockReset()
|
||||
toastStoreMock.add.mockClear()
|
||||
errorHandlingMock.toastErrorHandler.mockClear()
|
||||
errorHandlingMock.forceGenericFailure = false
|
||||
errorHandlingMock.forceStorageFailure = false
|
||||
})
|
||||
|
||||
it('stores an API key, initializes the user, and returns an auth header', async () => {
|
||||
authStoreMock.createCustomer.mockResolvedValue({ id: 'user-1' })
|
||||
const store = useApiKeyAuthStore()
|
||||
|
||||
await expect(store.storeApiKey('secret')).resolves.toBe(true)
|
||||
await vi.waitFor(() => expect(store.currentUser).toEqual({ id: 'user-1' }))
|
||||
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(store.getApiKey()).toBe('secret')
|
||||
expect(store.getAuthHeader()).toEqual({ 'X-API-KEY': 'secret' })
|
||||
expect(toastStoreMock.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('clears the API key when user initialization cannot create a customer', async () => {
|
||||
authStoreMock.createCustomer.mockResolvedValue(undefined)
|
||||
const rejection = new Promise<unknown>((resolve) => {
|
||||
process.once('unhandledRejection', resolve)
|
||||
})
|
||||
const store = useApiKeyAuthStore()
|
||||
|
||||
await expect(store.storeApiKey('secret')).resolves.toBe(true)
|
||||
|
||||
await vi.waitFor(() => expect(store.getApiKey()).toBeNull())
|
||||
expect(store.currentUser).toBeNull()
|
||||
await expect(rejection).resolves.toEqual(
|
||||
expect.objectContaining({ message: 'auth.login.noAssociatedUser' })
|
||||
)
|
||||
})
|
||||
|
||||
it('clears the user when the API key is cleared', async () => {
|
||||
authStoreMock.createCustomer.mockResolvedValue({ id: 'user-1' })
|
||||
const store = useApiKeyAuthStore()
|
||||
|
||||
await store.storeApiKey('secret')
|
||||
await vi.waitFor(() => expect(store.currentUser).toEqual({ id: 'user-1' }))
|
||||
await expect(store.clearStoredApiKey()).resolves.toBe(true)
|
||||
|
||||
expect(store.currentUser).toBeNull()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(store.getAuthHeader()).toBeNull()
|
||||
})
|
||||
|
||||
it('reports storage failures through the API-key toast copy', async () => {
|
||||
errorHandlingMock.forceStorageFailure = true
|
||||
const store = useApiKeyAuthStore()
|
||||
|
||||
await expect(store.storeApiKey('secret')).resolves.toBe(false)
|
||||
|
||||
expect(toastStoreMock.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'auth.apiKey.storageFailed',
|
||||
detail: 'auth.apiKey.storageFailedDetail'
|
||||
})
|
||||
expect(errorHandlingMock.toastErrorHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports non-storage failures through the generic toast handler', async () => {
|
||||
errorHandlingMock.forceGenericFailure = true
|
||||
const store = useApiKeyAuthStore()
|
||||
|
||||
await expect(store.storeApiKey('secret')).resolves.toBe(false)
|
||||
|
||||
expect(errorHandlingMock.toastErrorHandler).toHaveBeenCalledWith(
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
188
src/stores/executionError.test.ts
Normal file
188
src/stores/executionError.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
const {
|
||||
handlers,
|
||||
openSet,
|
||||
errorStore,
|
||||
dist,
|
||||
resolvePrecondition,
|
||||
classifyCloud
|
||||
} = vi.hoisted(() => ({
|
||||
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
|
||||
openSet: new Set<unknown>(),
|
||||
errorStore: {
|
||||
clearExecutionStartErrors: () => {},
|
||||
clearPromptError: () => {}
|
||||
} as Record<string, unknown>,
|
||||
dist: { isCloud: false },
|
||||
resolvePrecondition: vi.fn(),
|
||||
classifyCloud: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
|
||||
handlers[name] = fn
|
||||
},
|
||||
removeEventListener: () => {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
isOpen: (workflow: unknown) => openSet.has(workflow),
|
||||
openWorkflows: [],
|
||||
nodeLocatorIdToNodeExecutionId: () => null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas: undefined })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionErrorStore', () => ({
|
||||
useExecutionErrorStore: () => errorStore
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
|
||||
|
||||
vi.mock('@/utils/appMode', () => ({
|
||||
getWorkflowMode: () => 'workflow',
|
||||
isAppModeValue: () => false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return dist.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/errorCatalog/accountPreconditionRouting', () => ({
|
||||
resolveAccountPrecondition: resolvePrecondition
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/executionErrorUtil', () => ({
|
||||
classifyCloudValidationError: classifyCloud
|
||||
}))
|
||||
|
||||
function workflow(path: string): ComfyWorkflow {
|
||||
return { path } as unknown as ComfyWorkflow
|
||||
}
|
||||
|
||||
function promptOutput(): ComfyApiWorkflow {
|
||||
return {}
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
return store
|
||||
}
|
||||
|
||||
function fireError(detail: Record<string, unknown>) {
|
||||
handlers['execution_error']?.({ detail })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
for (const key of Object.keys(handlers)) delete handlers[key]
|
||||
openSet.clear()
|
||||
dist.isCloud = false
|
||||
resolvePrecondition.mockReturnValue(null)
|
||||
classifyCloud.mockReturnValue(null)
|
||||
for (const key of ['lastExecutionError', 'lastPromptError', 'lastNodeErrors'])
|
||||
delete errorStore[key]
|
||||
})
|
||||
|
||||
describe('executionStore error handling', () => {
|
||||
it('marks an open workflow failed and records the raw execution error', () => {
|
||||
const store = setup()
|
||||
const wf = workflow('a.json')
|
||||
openSet.add(wf)
|
||||
store.storeJob({
|
||||
nodes: [],
|
||||
id: 'job-1',
|
||||
promptOutput: promptOutput(),
|
||||
workflow: wf
|
||||
})
|
||||
|
||||
const detail = {
|
||||
prompt_id: 'job-1',
|
||||
node_id: '5',
|
||||
exception_message: 'boom'
|
||||
}
|
||||
fireError(detail)
|
||||
|
||||
expect(store.getWorkflowStatus(wf)).toBe('failed')
|
||||
expect(errorStore.lastExecutionError).toBe(detail)
|
||||
})
|
||||
|
||||
it('routes account-precondition errors away from the failed badge', () => {
|
||||
resolvePrecondition.mockReturnValue({ type: 'credits' })
|
||||
const store = setup()
|
||||
const wf = workflow('b.json')
|
||||
openSet.add(wf)
|
||||
store.storeJob({
|
||||
nodes: [],
|
||||
id: 'job-2',
|
||||
promptOutput: promptOutput(),
|
||||
workflow: wf
|
||||
})
|
||||
|
||||
fireError({ prompt_id: 'job-2', exception_type: 'AccountError' })
|
||||
|
||||
expect(store.getWorkflowStatus(wf)).toBeUndefined()
|
||||
expect(errorStore.lastExecutionError).toBeUndefined()
|
||||
})
|
||||
|
||||
it('records a node-less service-level error as a prompt error', () => {
|
||||
setup()
|
||||
|
||||
fireError({
|
||||
prompt_id: 'job-3',
|
||||
exception_type: 'StagnationError',
|
||||
exception_message: 'stuck',
|
||||
traceback: ['line1', 'line2']
|
||||
})
|
||||
|
||||
expect(errorStore.lastPromptError).toEqual({
|
||||
type: 'StagnationError',
|
||||
message: 'StagnationError: stuck',
|
||||
details: 'line1\nline2'
|
||||
})
|
||||
})
|
||||
|
||||
it('records classified cloud validation node errors without a failed badge', () => {
|
||||
dist.isCloud = true
|
||||
classifyCloud.mockReturnValue({
|
||||
kind: 'nodeErrors',
|
||||
nodeErrors: { '5': { errors: [] } }
|
||||
})
|
||||
const store = setup()
|
||||
const wf = workflow('c.json')
|
||||
openSet.add(wf)
|
||||
store.storeJob({
|
||||
nodes: [],
|
||||
id: 'job-4',
|
||||
promptOutput: promptOutput(),
|
||||
workflow: wf
|
||||
})
|
||||
|
||||
fireError({ prompt_id: 'job-4', exception_message: '{"nodeErrors":{}}' })
|
||||
|
||||
expect(store.getWorkflowStatus(wf)).toBeUndefined()
|
||||
expect(errorStore.lastNodeErrors).toEqual({ '5': { errors: [] } })
|
||||
})
|
||||
})
|
||||
236
src/stores/nodeBookmarkStore.test.ts
Normal file
236
src/stores/nodeBookmarkStore.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
const BOOKMARK_ID = 'Comfy.NodeLibrary.Bookmarks.V2'
|
||||
const CUSTOMIZATION_ID = 'Comfy.NodeLibrary.BookmarksCustomization'
|
||||
|
||||
const { settings, setSpy, nodeDefs } = vi.hoisted(() => ({
|
||||
settings: {} as Record<string, unknown>,
|
||||
setSpy: vi.fn(),
|
||||
nodeDefs: {} as Record<string, unknown>
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', async () => {
|
||||
const { reactive } = await import('vue')
|
||||
const reactiveSettings = reactive(settings)
|
||||
setSpy.mockImplementation(async (id: string, value: unknown) => {
|
||||
reactiveSettings[id] = value
|
||||
})
|
||||
return {
|
||||
useSettingStore: () => ({
|
||||
get: (id: string) => reactiveSettings[id],
|
||||
set: setSpy
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({ allNodeDefsByName: nodeDefs }),
|
||||
buildNodeDefTree: (defs: unknown[]) => ({ key: 'root', children: defs }),
|
||||
createDummyFolderNodeDef: (path: string) => ({
|
||||
isDummyFolder: true,
|
||||
nodePath: path,
|
||||
name: path
|
||||
})
|
||||
}))
|
||||
|
||||
type BookmarkNodeFixture = Pick<
|
||||
ComfyNodeDefImpl,
|
||||
'isDummyFolder' | 'nodePath' | 'category' | 'name'
|
||||
>
|
||||
|
||||
function folderNode(nodePath: string) {
|
||||
const node = {
|
||||
isDummyFolder: true,
|
||||
nodePath,
|
||||
category: nodePath.replace(/\/$/, ''),
|
||||
name: nodePath
|
||||
} satisfies BookmarkNodeFixture
|
||||
return node as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
function leafNode(name: string, nodePath = name) {
|
||||
const node = {
|
||||
isDummyFolder: false,
|
||||
name,
|
||||
nodePath,
|
||||
category: ''
|
||||
} satisfies BookmarkNodeFixture
|
||||
return node as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
for (const key of Object.keys(settings)) delete settings[key]
|
||||
for (const key of Object.keys(nodeDefs)) delete nodeDefs[key]
|
||||
settings[BOOKMARK_ID] = []
|
||||
settings[CUSTOMIZATION_ID] = {}
|
||||
setSpy.mockClear()
|
||||
})
|
||||
|
||||
describe('nodeBookmarkStore', () => {
|
||||
it('reports isBookmarked by either nodePath or top-level name', () => {
|
||||
settings[BOOKMARK_ID] = ['sampling/KSampler', 'LoadImage']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
expect(store.isBookmarked(leafNode('KSampler', 'sampling/KSampler'))).toBe(
|
||||
true
|
||||
)
|
||||
expect(store.isBookmarked(leafNode('LoadImage'))).toBe(true)
|
||||
expect(store.isBookmarked(leafNode('VAEDecode'))).toBe(false)
|
||||
})
|
||||
|
||||
it('adds a bookmark by appending to the current list', async () => {
|
||||
settings[BOOKMARK_ID] = ['A']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.addBookmark('B')
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['A', 'B'])
|
||||
})
|
||||
|
||||
it('toggles an un-bookmarked node by adding its name', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.toggleBookmark(leafNode('KSampler'))
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['KSampler'])
|
||||
})
|
||||
|
||||
it('toggles a bookmarked node by deleting both nodePath and name', async () => {
|
||||
settings[BOOKMARK_ID] = ['sampling/KSampler', 'KSampler']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.toggleBookmark(leafNode('KSampler', 'sampling/KSampler'))
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['KSampler'])
|
||||
expect(setSpy).toHaveBeenLastCalledWith(BOOKMARK_ID, [])
|
||||
expect(store.bookmarks).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a folder under a parent and at the root', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
const rootPath = await store.addNewBookmarkFolder(undefined, 'Favorites')
|
||||
expect(rootPath).toBe('Favorites/')
|
||||
|
||||
const childPath = await store.addNewBookmarkFolder(
|
||||
folderNode('Favorites/'),
|
||||
'Nested'
|
||||
)
|
||||
expect(childPath).toBe('Favorites/Nested/')
|
||||
})
|
||||
|
||||
it('builds the bookmark tree, dropping unknown node defs', () => {
|
||||
nodeDefs['KSampler'] = leafNode('KSampler')
|
||||
settings[BOOKMARK_ID] = ['sampling/KSampler', 'sampling/Unknown', 'Folder/']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
const children = (store.bookmarkedRoot as { children: unknown[] }).children
|
||||
expect(children).toHaveLength(2)
|
||||
})
|
||||
|
||||
describe('renameBookmarkFolder', () => {
|
||||
it('rejects renaming a non-folder node', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
await expect(
|
||||
store.renameBookmarkFolder(leafNode('KSampler'), 'New')
|
||||
).rejects.toThrow('Cannot rename non-folder node')
|
||||
})
|
||||
|
||||
it('rejects a name containing a slash', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
await expect(
|
||||
store.renameBookmarkFolder(folderNode('Old/'), 'a/b')
|
||||
).rejects.toThrow('cannot contain')
|
||||
})
|
||||
|
||||
it('rejects a rename that collides with an existing folder', async () => {
|
||||
settings[BOOKMARK_ID] = ['Taken/']
|
||||
const store = useNodeBookmarkStore()
|
||||
await expect(
|
||||
store.renameBookmarkFolder(folderNode('Old/'), 'Taken')
|
||||
).rejects.toThrow('already exists')
|
||||
})
|
||||
|
||||
it('rewrites matching bookmark paths on a valid rename', async () => {
|
||||
settings[BOOKMARK_ID] = ['Old/', 'Old/KSampler', 'Other/Node']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.renameBookmarkFolder(folderNode('Old/'), 'New')
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, [
|
||||
'New/',
|
||||
'New/KSampler',
|
||||
'Other/Node'
|
||||
])
|
||||
})
|
||||
|
||||
it('does nothing when the folder keeps the same path', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.renameBookmarkFolder(folderNode('Old/'), 'Old')
|
||||
|
||||
expect(setSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes a folder and all its descendants', async () => {
|
||||
settings[BOOKMARK_ID] = ['Old/', 'Old/KSampler', 'Keep/Node']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.deleteBookmarkFolder(folderNode('Old/'))
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['Keep/Node'])
|
||||
})
|
||||
|
||||
it('rejects deleting a non-folder node', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await expect(
|
||||
store.deleteBookmarkFolder(leafNode('KSampler'))
|
||||
).rejects.toThrow('Cannot delete non-folder node')
|
||||
})
|
||||
|
||||
describe('updateBookmarkCustomization', () => {
|
||||
it('persists a non-default customization', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.updateBookmarkCustomization('Folder/', {
|
||||
color: '#ff0000',
|
||||
icon: 'pi-star'
|
||||
})
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
|
||||
'Folder/': { color: '#ff0000', icon: 'pi-star' }
|
||||
})
|
||||
})
|
||||
|
||||
it('drops attributes set to their default values', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.updateBookmarkCustomization('Folder/', {
|
||||
color: store.defaultBookmarkColor,
|
||||
icon: store.defaultBookmarkIcon
|
||||
})
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
|
||||
'Folder/': undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('renames a customization entry, moving the old key to the new one', async () => {
|
||||
settings[CUSTOMIZATION_ID] = { 'Old/': { color: '#abc' } }
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.renameBookmarkCustomization('Old/', 'New/')
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
|
||||
'New/': { color: '#abc' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import * as jobOutputCache from '@/services/jobOutputCache'
|
||||
import type { TaskOutput } from '@/schemas/apiSchema'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
@@ -44,7 +46,9 @@ const mockJobDetail = {
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
'1': { images: [{ filename: 'test.png', subfolder: '', type: 'output' }] }
|
||||
'1': {
|
||||
images: [{ filename: 'test.png', subfolder: '', type: 'output' as const }]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,4 +141,98 @@ describe('TaskItemImpl.loadWorkflow - workflow fetching', () => {
|
||||
expect(jobOutputCache.getJobDetail).toHaveBeenCalled()
|
||||
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should load full outputs for history tasks', async () => {
|
||||
const job = createHistoryJob('test-job-id')
|
||||
const task = new TaskItemImpl(job)
|
||||
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
|
||||
mockJobDetail as JobDetail
|
||||
)
|
||||
|
||||
const loaded = await task.loadFullOutputs()
|
||||
|
||||
expect(loaded).not.toBe(task)
|
||||
expect(loaded.flatOutputs[0].filename).toBe('test.png')
|
||||
})
|
||||
|
||||
it('should not load full outputs for running tasks', async () => {
|
||||
const job = createRunningJob('test-job-id')
|
||||
const task = new TaskItemImpl(job)
|
||||
const detailSpy = vi.spyOn(jobOutputCache, 'getJobDetail')
|
||||
|
||||
const loaded = await task.loadFullOutputs()
|
||||
|
||||
expect(loaded).toBe(task)
|
||||
expect(detailSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep history tasks when full outputs are unavailable', async () => {
|
||||
const job = createHistoryJob('test-job-id')
|
||||
const task = new TaskItemImpl(job)
|
||||
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
|
||||
fromPartial<JobDetail>({ id: 'test-job-id', status: 'completed' })
|
||||
)
|
||||
|
||||
const loaded = await task.loadFullOutputs()
|
||||
|
||||
expect(loaded).toBe(task)
|
||||
})
|
||||
|
||||
it('should load workflow outputs from the task when job detail has none', async () => {
|
||||
const job = createHistoryJob('test-job-id')
|
||||
const task = new TaskItemImpl(job, mockJobDetail.outputs)
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const setOutputsSpy = vi.spyOn(
|
||||
nodeOutputStore,
|
||||
'setNodeOutputsByExecutionId'
|
||||
)
|
||||
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
|
||||
fromPartial<JobDetail>({ ...mockJobDetail, outputs: undefined })
|
||||
)
|
||||
|
||||
await task.loadWorkflow(mockApp)
|
||||
|
||||
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
|
||||
expect(setOutputsSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should skip workflow output loading when no outputs exist', async () => {
|
||||
const job = createHistoryJob('test-job-id')
|
||||
const task = new TaskItemImpl(job, fromAny<TaskOutput, unknown>(null))
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const setOutputsSpy = vi.spyOn(
|
||||
nodeOutputStore,
|
||||
'setNodeOutputsByExecutionId'
|
||||
)
|
||||
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
|
||||
fromPartial<JobDetail>({ ...mockJobDetail, outputs: undefined })
|
||||
)
|
||||
|
||||
await task.loadWorkflow(mockApp)
|
||||
|
||||
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
|
||||
expect(setOutputsSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip invalid node execution ids while loading outputs', async () => {
|
||||
const job = createHistoryJob('test-job-id')
|
||||
const outputs = fromAny<TaskOutput, unknown>({
|
||||
'': { images: [{ filename: 'skip.png', subfolder: '', type: 'output' }] },
|
||||
'1': { images: [{ filename: 'keep.png', subfolder: '', type: 'output' }] }
|
||||
})
|
||||
const task = new TaskItemImpl(job, outputs)
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const setOutputsSpy = vi.spyOn(
|
||||
nodeOutputStore,
|
||||
'setNodeOutputsByExecutionId'
|
||||
)
|
||||
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
|
||||
fromPartial<JobDetail>({ ...mockJobDetail, outputs: undefined })
|
||||
)
|
||||
|
||||
await task.loadWorkflow(mockApp)
|
||||
|
||||
expect(setOutputsSpy).toHaveBeenCalledOnce()
|
||||
expect(setOutputsSpy).toHaveBeenCalledWith('1', outputs['1'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -6,7 +7,14 @@ import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { TaskOutput } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import {
|
||||
isInstantMode,
|
||||
isInstantRunningMode,
|
||||
ResultItemImpl,
|
||||
TaskItemImpl,
|
||||
useQueuePendingTaskCountStore,
|
||||
useQueueStore
|
||||
} from '@/stores/queueStore'
|
||||
|
||||
// Fixture factory for JobListItem
|
||||
function createJob(
|
||||
@@ -67,6 +75,86 @@ vi.mock('@/scripts/api', () => ({
|
||||
}))
|
||||
|
||||
describe('TaskItemImpl', () => {
|
||||
it('should default missing result URL fields', () => {
|
||||
const output = new ResultItemImpl(
|
||||
fromAny<ConstructorParameters<typeof ResultItemImpl>[0], unknown>({
|
||||
nodeId: 'node-1',
|
||||
mediaType: 'images'
|
||||
})
|
||||
)
|
||||
|
||||
expect(output.filename).toBe('')
|
||||
expect(output.subfolder).toBe('')
|
||||
expect(output.type).toBe('')
|
||||
expect(output.url).toBe('')
|
||||
})
|
||||
|
||||
it('should use the raw URL as preview URL for non-images', () => {
|
||||
const output = new ResultItemImpl({
|
||||
nodeId: 'node-1',
|
||||
mediaType: 'video',
|
||||
filename: 'clip.webm',
|
||||
type: 'output',
|
||||
subfolder: ''
|
||||
})
|
||||
|
||||
expect(output.previewUrl).toBe(output.url)
|
||||
})
|
||||
|
||||
it('should recognize VHS mp4 and unsupported video formats', () => {
|
||||
const webm = new ResultItemImpl({
|
||||
nodeId: 'node-1',
|
||||
mediaType: 'gifs',
|
||||
filename: 'clip',
|
||||
type: 'output',
|
||||
subfolder: '',
|
||||
format: 'video/webm',
|
||||
frame_rate: 24
|
||||
})
|
||||
const mp4 = new ResultItemImpl({
|
||||
nodeId: 'node-1',
|
||||
mediaType: 'gifs',
|
||||
filename: 'clip',
|
||||
type: 'output',
|
||||
subfolder: '',
|
||||
format: 'video/mp4',
|
||||
frame_rate: 24
|
||||
})
|
||||
const avi = new ResultItemImpl({
|
||||
nodeId: 'node-1',
|
||||
mediaType: 'gifs',
|
||||
filename: 'clip',
|
||||
type: 'output',
|
||||
subfolder: '',
|
||||
format: 'video/avi',
|
||||
frame_rate: 24
|
||||
})
|
||||
|
||||
expect(webm.htmlVideoType).toBe('video/webm')
|
||||
expect(mp4.htmlVideoType).toBe('video/mp4')
|
||||
expect(avi.htmlVideoType).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should detect image media type without an image suffix', () => {
|
||||
const image = new ResultItemImpl({
|
||||
nodeId: 'node-1',
|
||||
mediaType: 'images',
|
||||
filename: 'generated',
|
||||
type: 'output',
|
||||
subfolder: ''
|
||||
})
|
||||
const audioFile = new ResultItemImpl({
|
||||
nodeId: 'node-1',
|
||||
mediaType: 'images',
|
||||
filename: 'generated.wav',
|
||||
type: 'output',
|
||||
subfolder: ''
|
||||
})
|
||||
|
||||
expect(image.isImage).toBe(true)
|
||||
expect(audioFile.isImage).toBe(false)
|
||||
})
|
||||
|
||||
it('should exclude animated from flatOutputs', () => {
|
||||
const job = createHistoryJob(0, 'job-id')
|
||||
const taskItem = new TaskItemImpl(job, {
|
||||
@@ -259,6 +347,41 @@ describe('TaskItemImpl', () => {
|
||||
expect(taskItem.executionError).toEqual(errorDetail)
|
||||
})
|
||||
})
|
||||
|
||||
it('should expose queue API task type for running tasks', () => {
|
||||
const task = new TaskItemImpl(createRunningJob(1, 'run-1'))
|
||||
|
||||
expect(task.apiTaskType).toBe('queue')
|
||||
})
|
||||
|
||||
it('should return empty flat outputs when outputs are missing', () => {
|
||||
const task = new TaskItemImpl(
|
||||
createHistoryJob(0, 'job-id'),
|
||||
fromAny<TaskOutput, unknown>(null)
|
||||
)
|
||||
|
||||
expect(task.calculateFlatOutputs()).toEqual([])
|
||||
})
|
||||
|
||||
it('should calculate execution time in seconds', () => {
|
||||
const task = new TaskItemImpl({
|
||||
...createHistoryJob(0, 'job-id'),
|
||||
execution_start_time: 1000,
|
||||
execution_end_time: 3500
|
||||
})
|
||||
|
||||
expect(task.executionStartTimestamp).toBe(1000)
|
||||
expect(task.executionEndTimestamp).toBe(3500)
|
||||
expect(task.executionTime).toBe(2500)
|
||||
expect(task.executionTimeInSeconds).toBe(2.5)
|
||||
})
|
||||
|
||||
it('should return undefined execution seconds without both timestamps', () => {
|
||||
const task = new TaskItemImpl(createHistoryJob(0, 'job-id'))
|
||||
|
||||
expect(task.executionTime).toBeUndefined()
|
||||
expect(task.executionTimeInSeconds).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useQueueStore', () => {
|
||||
@@ -314,6 +437,23 @@ describe('useQueueStore', () => {
|
||||
expect(store.pendingTasks[1].jobId).toBe('pend-1')
|
||||
})
|
||||
|
||||
it('should register workflow ids for active jobs', async () => {
|
||||
const executionStore = useExecutionStore()
|
||||
const registerSpy = vi.spyOn(
|
||||
executionStore,
|
||||
'registerJobWorkflowIdMapping'
|
||||
)
|
||||
mockGetQueue.mockResolvedValue({
|
||||
Running: [{ ...createRunningJob(1, 'run-1'), workflow_id: 'wf-1' }],
|
||||
Pending: []
|
||||
})
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
|
||||
await store.update()
|
||||
|
||||
expect(registerSpy).toHaveBeenCalledWith('run-1', 'wf-1')
|
||||
})
|
||||
|
||||
it('should load history tasks from API', async () => {
|
||||
const historyJob1 = createHistoryJob(5, 'hist-1')
|
||||
const historyJob2 = createHistoryJob(4, 'hist-2')
|
||||
@@ -1115,3 +1255,43 @@ describe('useQueueStore', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useQueuePendingTaskCountStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('updates from status websocket messages', () => {
|
||||
const store = useQueuePendingTaskCountStore()
|
||||
|
||||
store.update(
|
||||
fromAny<CustomEvent, unknown>({
|
||||
detail: { exec_info: { queue_remaining: 3 } }
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.count).toBe(3)
|
||||
})
|
||||
|
||||
it('falls back to zero when status details are missing', () => {
|
||||
const store = useQueuePendingTaskCountStore()
|
||||
store.count = 3
|
||||
|
||||
store.update(fromAny<CustomEvent, unknown>({}))
|
||||
|
||||
expect(store.count).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('queue mode helpers', () => {
|
||||
it('detect instant queue modes', () => {
|
||||
expect(isInstantMode('instant-idle')).toBe(true)
|
||||
expect(isInstantMode('instant-running')).toBe(true)
|
||||
expect(isInstantMode('change')).toBe(false)
|
||||
})
|
||||
|
||||
it('detect instant running mode', () => {
|
||||
expect(isInstantRunningMode('instant-running')).toBe(true)
|
||||
expect(isInstantRunningMode('instant-idle')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user