mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
13 Commits
pr5-list-v
...
dproy/test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d3e24096b | ||
|
|
2de65d7e17 | ||
|
|
164d7aac4d | ||
|
|
76d453aaa3 | ||
|
|
9c65b47a64 | ||
|
|
c4c1c8121c | ||
|
|
58c076dd84 | ||
|
|
581b319b05 | ||
|
|
141615b911 | ||
|
|
31fac20a03 | ||
|
|
5e9abf2c41 | ||
|
|
309cbd4dc4 | ||
|
|
ad2ffdcd85 |
@@ -34,17 +34,23 @@ const getContentType = (filename: string, fileType: OutputFileType) => {
|
||||
}
|
||||
|
||||
const setQueueIndex = (task: TaskItem) => {
|
||||
task.prompt[0] = TaskHistory.queueIndex++
|
||||
task.prompt.priority = TaskHistory.queueIndex++
|
||||
}
|
||||
|
||||
const setPromptId = (task: TaskItem) => {
|
||||
task.prompt[1] = uuidv4()
|
||||
if (!task.prompt.prompt_id || task.prompt.prompt_id === 'prompt-id') {
|
||||
task.prompt.prompt_id = uuidv4()
|
||||
}
|
||||
}
|
||||
|
||||
export default class TaskHistory {
|
||||
static queueIndex = 0
|
||||
static readonly defaultTask: Readonly<HistoryTaskItem> = {
|
||||
prompt: [0, 'prompt-id', {}, { client_id: uuidv4() }, []],
|
||||
prompt: {
|
||||
priority: 0,
|
||||
prompt_id: 'prompt-id',
|
||||
extra_data: { client_id: uuidv4() }
|
||||
},
|
||||
outputs: {},
|
||||
status: {
|
||||
status_str: 'success',
|
||||
@@ -66,16 +72,43 @@ export default class TaskHistory {
|
||||
)
|
||||
|
||||
private async handleGetHistory(route: Route) {
|
||||
const url = route.request().url()
|
||||
|
||||
// Handle history_v2/:prompt_id endpoint
|
||||
const promptIdMatch = url.match(/history_v2\/([^?]+)/)
|
||||
if (promptIdMatch) {
|
||||
const promptId = promptIdMatch[1]
|
||||
const task = this.tasks.find((t) => t.prompt.prompt_id === promptId)
|
||||
const response: Record<string, any> = {}
|
||||
if (task) {
|
||||
response[promptId] = task
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
// Handle history_v2 list endpoint
|
||||
// Convert HistoryTaskItem to RawHistoryItem format expected by API
|
||||
const rawHistoryItems = this.tasks.map((task) => ({
|
||||
prompt_id: task.prompt.prompt_id,
|
||||
prompt: task.prompt,
|
||||
status: task.status,
|
||||
outputs: task.outputs,
|
||||
...(task.meta && { meta: task.meta })
|
||||
}))
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.tasks)
|
||||
body: JSON.stringify({ history: rawHistoryItems })
|
||||
})
|
||||
}
|
||||
|
||||
private async handleGetView(route: Route) {
|
||||
const fileName = getFilenameParam(route.request())
|
||||
if (!this.outputContentTypes.has(fileName)) route.continue()
|
||||
if (!this.outputContentTypes.has(fileName)) return route.continue()
|
||||
|
||||
const asset = this.loadAsset(fileName)
|
||||
return route.fulfill({
|
||||
@@ -91,7 +124,7 @@ export default class TaskHistory {
|
||||
|
||||
async setupRoutes() {
|
||||
return this.comfyPage.page.route(
|
||||
/.*\/api\/(view|history)(\?.*)?$/,
|
||||
/.*\/api\/(view|history_v2)(\/[^?]*)?(\?.*)?$/,
|
||||
async (route) => {
|
||||
const request = route.request()
|
||||
const method = request.method()
|
||||
|
||||
131
browser_tests/tests/historyApi.spec.ts
Normal file
131
browser_tests/tests/historyApi.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('History API v2', () => {
|
||||
const TEST_PROMPT_ID = 'test-prompt-id'
|
||||
const TEST_CLIENT_ID = 'test-client'
|
||||
|
||||
test('Can fetch history with new v2 format', async ({ comfyPage }) => {
|
||||
// Set up mocked history with tasks
|
||||
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
|
||||
|
||||
// Verify history_v2 API response format
|
||||
const result = await comfyPage.page.evaluate(async () => {
|
||||
try {
|
||||
const response = await window['app'].api.getHistory()
|
||||
return { success: true, data: response }
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch history:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toHaveProperty('History')
|
||||
expect(Array.isArray(result.data.History)).toBe(true)
|
||||
expect(result.data.History.length).toBeGreaterThan(0)
|
||||
|
||||
const historyItem = result.data.History[0]
|
||||
|
||||
// Verify the new prompt structure (object instead of array)
|
||||
expect(historyItem.prompt).toHaveProperty('priority')
|
||||
expect(historyItem.prompt).toHaveProperty('prompt_id')
|
||||
expect(historyItem.prompt).toHaveProperty('extra_data')
|
||||
expect(typeof historyItem.prompt.priority).toBe('number')
|
||||
expect(typeof historyItem.prompt.prompt_id).toBe('string')
|
||||
expect(historyItem.prompt.extra_data).toHaveProperty('client_id')
|
||||
})
|
||||
|
||||
test('Can load workflow from history using history_v2 endpoint', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Simple mock workflow for testing
|
||||
const mockWorkflow = {
|
||||
version: 0.4,
|
||||
nodes: [{ id: 1, type: 'TestNode', pos: [100, 100], size: [200, 100] }],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {}
|
||||
}
|
||||
|
||||
// Set up history with workflow data
|
||||
await comfyPage
|
||||
.setupHistory()
|
||||
.withTask(['example.webp'], 'images', {
|
||||
prompt: {
|
||||
priority: 0,
|
||||
prompt_id: TEST_PROMPT_ID,
|
||||
extra_data: {
|
||||
client_id: TEST_CLIENT_ID,
|
||||
extra_pnginfo: { workflow: mockWorkflow }
|
||||
}
|
||||
}
|
||||
})
|
||||
.setupRoutes()
|
||||
|
||||
// Load initial workflow to clear canvas
|
||||
await comfyPage.loadWorkflow('simple_slider')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Load workflow from history
|
||||
const loadResult = await comfyPage.page.evaluate(async (promptId) => {
|
||||
try {
|
||||
const workflow =
|
||||
await window['app'].api.getWorkflowFromHistory(promptId)
|
||||
if (workflow) {
|
||||
await window['app'].loadGraphData(workflow)
|
||||
return { success: true }
|
||||
}
|
||||
return { success: false, error: 'No workflow found' }
|
||||
} catch (error) {
|
||||
console.error('Failed to load workflow from history:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}, TEST_PROMPT_ID)
|
||||
|
||||
expect(loadResult.success).toBe(true)
|
||||
|
||||
// Verify workflow loaded correctly
|
||||
await comfyPage.nextFrame()
|
||||
const nodeInfo = await comfyPage.page.evaluate(() => {
|
||||
try {
|
||||
const graph = window['app'].graph
|
||||
return {
|
||||
success: true,
|
||||
nodeCount: graph.nodes?.length || 0,
|
||||
firstNodeType: graph.nodes?.[0]?.type || null
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
expect(nodeInfo.success).toBe(true)
|
||||
expect(nodeInfo.nodeCount).toBe(1)
|
||||
expect(nodeInfo.firstNodeType).toBe('TestNode')
|
||||
})
|
||||
|
||||
test('Handles missing workflow data gracefully', async ({ comfyPage }) => {
|
||||
// Set up empty history routes
|
||||
await comfyPage.setupHistory().setupRoutes()
|
||||
|
||||
// Test loading from history with invalid prompt_id
|
||||
const result = await comfyPage.page.evaluate(async () => {
|
||||
try {
|
||||
const workflow =
|
||||
await window['app'].api.getWorkflowFromHistory('invalid-id')
|
||||
return { success: true, workflow }
|
||||
} catch (error) {
|
||||
console.error('Expected error for invalid prompt_id:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Should handle gracefully without throwing
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.workflow).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -187,12 +187,14 @@ test.describe('Workflows sidebar', () => {
|
||||
|
||||
test('Can save workflow as with same name', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow5.json')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow5.json'
|
||||
])
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('workflow5.json')
|
||||
await comfyPage.confirmDialog.click('overwrite')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow5.json'
|
||||
])
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<script setup lang="ts">
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
// Global variable from vite build defined in global.d.ts
|
||||
// eslint-disable-next-line no-undef
|
||||
const isStaging = !__USE_PROD_CONFIG__
|
||||
import { isProductionEnvironment } from '@/config/environment'
|
||||
|
||||
const isStaging = !isProductionEnvironment()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -106,8 +106,8 @@ import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import { ComfyNode } from '@/schemas/comfyWorkflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import {
|
||||
ResultItemImpl,
|
||||
@@ -126,6 +126,7 @@ const toast = useToast()
|
||||
const queueStore = useQueueStore()
|
||||
const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Expanded view: show all outputs in a flat list.
|
||||
@@ -208,8 +209,16 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
{
|
||||
label: t('g.loadWorkflow'),
|
||||
icon: 'pi pi-file-export',
|
||||
command: () => menuTargetTask.value?.loadWorkflow(app),
|
||||
disabled: !menuTargetTask.value?.workflow
|
||||
command: () => {
|
||||
if (menuTargetTask.value) {
|
||||
void workflowService.loadTaskWorkflow(menuTargetTask.value)
|
||||
}
|
||||
},
|
||||
disabled: !(
|
||||
menuTargetTask.value?.workflow ||
|
||||
(menuTargetTask.value?.isHistory &&
|
||||
menuTargetTask.value?.prompt.prompt_id)
|
||||
)
|
||||
},
|
||||
{
|
||||
label: t('g.goToNode'),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { IWidget } from '@/lib/litegraph/src/litegraph'
|
||||
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
const MAX_RETRIES = 5
|
||||
const TIMEOUT = 4096
|
||||
@@ -58,10 +59,21 @@ const fetchData = async (
|
||||
controller: AbortController
|
||||
) => {
|
||||
const { route, response_key, query_params, timeout = TIMEOUT } = config
|
||||
|
||||
// Get auth header from Firebase
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const authHeader = await authStore.getAuthHeader()
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (authHeader) {
|
||||
Object.assign(headers, authHeader)
|
||||
}
|
||||
|
||||
const res = await axios.get(route, {
|
||||
params: query_params,
|
||||
signal: controller.signal,
|
||||
timeout
|
||||
timeout,
|
||||
headers
|
||||
})
|
||||
return response_key ? res.data[response_key] : res.data
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export const COMFY_API_BASE_URL = __USE_PROD_CONFIG__
|
||||
import { isProductionEnvironment } from './environment'
|
||||
|
||||
export const COMFY_API_BASE_URL = isProductionEnvironment()
|
||||
? 'https://api.comfy.org'
|
||||
: 'https://stagingapi.comfy.org'
|
||||
|
||||
export const COMFY_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
|
||||
export const COMFY_PLATFORM_BASE_URL = isProductionEnvironment()
|
||||
? 'https://platform.comfy.org'
|
||||
: 'https://stagingplatform.comfy.org'
|
||||
|
||||
18
src/config/environment.ts
Normal file
18
src/config/environment.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Runtime environment configuration that determines if we're in production or staging
|
||||
* based on the hostname. Replaces the build-time __USE_PROD_CONFIG__ constant.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Checks if the application is running in production environment
|
||||
* @returns true if hostname is cloud.comfy.org (production), false otherwise (staging)
|
||||
*/
|
||||
export function isProductionEnvironment(): boolean {
|
||||
// In SSR/Node.js environments or during build, use the environment variable
|
||||
if (typeof window === 'undefined') {
|
||||
return process.env.USE_PROD_CONFIG === 'true'
|
||||
}
|
||||
|
||||
// In browser, check the hostname
|
||||
return window.location.hostname === 'cloud.comfy.org'
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FirebaseOptions } from 'firebase/app'
|
||||
|
||||
import { isProductionEnvironment } from './environment'
|
||||
|
||||
const DEV_CONFIG: FirebaseOptions = {
|
||||
apiKey: 'AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE',
|
||||
authDomain: 'dreamboothy-dev.firebaseapp.com',
|
||||
@@ -23,6 +25,6 @@ const PROD_CONFIG: FirebaseOptions = {
|
||||
}
|
||||
|
||||
// To test with prod config while using dev server, set USE_PROD_CONFIG=true in .env
|
||||
export const FIREBASE_CONFIG: FirebaseOptions = __USE_PROD_CONFIG__
|
||||
export const FIREBASE_CONFIG: FirebaseOptions = isProductionEnvironment()
|
||||
? PROD_CONFIG
|
||||
: DEV_CONFIG
|
||||
|
||||
@@ -36,11 +36,8 @@ Sentry.init({
|
||||
dsn: __SENTRY_DSN__,
|
||||
enabled: __SENTRY_ENABLED__,
|
||||
release: __COMFYUI_FRONTEND_VERSION__,
|
||||
integrations: [],
|
||||
autoSessionTracking: false,
|
||||
defaultIntegrations: false,
|
||||
normalizeDepth: 8,
|
||||
tracesSampleRate: 0
|
||||
tracesSampleRate: 1.0
|
||||
})
|
||||
app.directive('tooltip', Tooltip)
|
||||
app
|
||||
|
||||
@@ -6,11 +6,12 @@ import {
|
||||
createWebHistory
|
||||
} from 'vue-router'
|
||||
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
|
||||
|
||||
import { useUserStore } from './stores/userStore'
|
||||
import { isElectron } from './utils/envUtil'
|
||||
|
||||
const isFileProtocol = window.location.protocol === 'file:'
|
||||
const basePath = isElectron() ? '/' : window.location.pathname
|
||||
|
||||
@@ -130,4 +131,41 @@ const router = createRouter({
|
||||
}
|
||||
})
|
||||
|
||||
// Global authentication guard
|
||||
router.beforeEach(async (_to, _from, next) => {
|
||||
const authStore = useFirebaseAuthStore()
|
||||
|
||||
// Wait for Firebase auth to initialize
|
||||
if (!authStore.isInitialized) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const unwatch = authStore.$subscribe((_, state) => {
|
||||
if (state.isInitialized) {
|
||||
unwatch()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Check if user is authenticated (Firebase or API key)
|
||||
const authHeader = await authStore.getAuthHeader()
|
||||
|
||||
if (!authHeader) {
|
||||
// User is not authenticated, show sign-in dialog
|
||||
const dialogService = useDialogService()
|
||||
const loginSuccess = await dialogService.showSignInDialog()
|
||||
|
||||
if (loginSuccess) {
|
||||
// After successful login, proceed to the intended route
|
||||
next()
|
||||
} else {
|
||||
// User cancelled login, stay on current page or redirect to home
|
||||
next(false)
|
||||
}
|
||||
} else {
|
||||
// User is authenticated, proceed
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -112,6 +112,11 @@ const zDisplayComponentWsMessage = z.object({
|
||||
props: z.record(z.string(), z.any()).optional()
|
||||
})
|
||||
|
||||
const zNotificationWsMessage = z.object({
|
||||
value: z.string(),
|
||||
id: z.string().optional()
|
||||
})
|
||||
|
||||
const zTerminalSize = z.object({
|
||||
cols: z.number(),
|
||||
row: z.number()
|
||||
@@ -153,15 +158,9 @@ export type DisplayComponentWsMessage = z.infer<
|
||||
export type NodeProgressState = z.infer<typeof zNodeProgressState>
|
||||
export type ProgressStateWsMessage = z.infer<typeof zProgressStateWsMessage>
|
||||
export type FeatureFlagsWsMessage = z.infer<typeof zFeatureFlagsWsMessage>
|
||||
export type NotificationWsMessage = z.infer<typeof zNotificationWsMessage>
|
||||
// End of ws messages
|
||||
|
||||
const zPromptInputItem = z.object({
|
||||
inputs: z.record(z.string(), z.any()),
|
||||
class_type: zNodeType
|
||||
})
|
||||
|
||||
const zPromptInputs = z.record(zPromptInputItem)
|
||||
|
||||
const zExtraPngInfo = z
|
||||
.object({
|
||||
workflow: zComfyWorkflow
|
||||
@@ -173,7 +172,6 @@ const zExtraData = z.object({
|
||||
extra_pnginfo: zExtraPngInfo.optional(),
|
||||
client_id: z.string()
|
||||
})
|
||||
const zOutputsToExecute = z.array(zNodeId)
|
||||
|
||||
const zExecutionStartMessage = z.tuple([
|
||||
z.literal('execution_start'),
|
||||
@@ -214,13 +212,11 @@ const zStatus = z.object({
|
||||
messages: z.array(zStatusMessage)
|
||||
})
|
||||
|
||||
const zTaskPrompt = z.tuple([
|
||||
zQueueIndex,
|
||||
zPromptId,
|
||||
zPromptInputs,
|
||||
zExtraData,
|
||||
zOutputsToExecute
|
||||
])
|
||||
const zTaskPrompt = z.object({
|
||||
priority: zQueueIndex,
|
||||
prompt_id: zPromptId,
|
||||
extra_data: zExtraData
|
||||
})
|
||||
|
||||
const zRunningTaskItem = z.object({
|
||||
taskType: z.literal('Running'),
|
||||
@@ -256,6 +252,20 @@ const zHistoryTaskItem = z.object({
|
||||
meta: zTaskMeta.optional()
|
||||
})
|
||||
|
||||
// Raw history item from backend (without taskType)
|
||||
const zRawHistoryItem = z.object({
|
||||
prompt_id: zPromptId,
|
||||
prompt: zTaskPrompt,
|
||||
status: zStatus.optional(),
|
||||
outputs: zTaskOutput,
|
||||
meta: zTaskMeta.optional()
|
||||
})
|
||||
|
||||
// New API response format: { history: [{prompt_id: "...", ...}, ...] }
|
||||
const zHistoryResponse = z.object({
|
||||
history: z.array(zRawHistoryItem)
|
||||
})
|
||||
|
||||
const zTaskItem = z.union([
|
||||
zRunningTaskItem,
|
||||
zPendingTaskItem,
|
||||
@@ -278,6 +288,8 @@ export type RunningTaskItem = z.infer<typeof zRunningTaskItem>
|
||||
export type PendingTaskItem = z.infer<typeof zPendingTaskItem>
|
||||
// `/history`
|
||||
export type HistoryTaskItem = z.infer<typeof zHistoryTaskItem>
|
||||
export type RawHistoryItem = z.infer<typeof zRawHistoryItem>
|
||||
export type HistoryResponse = z.infer<typeof zHistoryResponse>
|
||||
export type TaskItem = z.infer<typeof zTaskItem>
|
||||
|
||||
export function validateTaskItem(taskItem: unknown) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios'
|
||||
import { debounce } from 'lodash'
|
||||
|
||||
import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json'
|
||||
import type {
|
||||
@@ -13,9 +14,11 @@ import type {
|
||||
ExecutionSuccessWsMessage,
|
||||
ExtensionsResponse,
|
||||
FeatureFlagsWsMessage,
|
||||
HistoryResponse,
|
||||
HistoryTaskItem,
|
||||
LogsRawResponse,
|
||||
LogsWsMessage,
|
||||
NotificationWsMessage,
|
||||
PendingTaskItem,
|
||||
ProgressStateWsMessage,
|
||||
ProgressTextWsMessage,
|
||||
@@ -26,6 +29,7 @@ import type {
|
||||
StatusWsMessage,
|
||||
StatusWsMessageStatus,
|
||||
SystemStats,
|
||||
TaskPrompt,
|
||||
User,
|
||||
UserDataFullInfo
|
||||
} from '@/schemas/apiSchema'
|
||||
@@ -35,6 +39,8 @@ import type {
|
||||
NodeId
|
||||
} from '@/schemas/comfyWorkflowSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { WorkflowTemplates } from '@/types/workflowTemplateTypes'
|
||||
|
||||
@@ -130,6 +136,7 @@ interface BackendApiCalls {
|
||||
progress_state: ProgressStateWsMessage
|
||||
display_component: DisplayComponentWsMessage
|
||||
feature_flags: FeatureFlagsWsMessage
|
||||
notification: NotificationWsMessage
|
||||
}
|
||||
|
||||
/** Dictionary of all api calls */
|
||||
@@ -272,6 +279,81 @@ export class ComfyApi extends EventTarget {
|
||||
*/
|
||||
serverFeatureFlags: Record<string, unknown> = {}
|
||||
|
||||
/**
|
||||
* Map of notification toasts by ID
|
||||
*/
|
||||
#notificationToasts = new Map<string, any>()
|
||||
|
||||
/**
|
||||
* Map of timers for auto-hiding notifications by ID
|
||||
*/
|
||||
#notificationTimers = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* Handle notification messages (with optional ID for multiple parallel notifications)
|
||||
*/
|
||||
#handleNotification(value: string, id?: string) {
|
||||
try {
|
||||
const toastStore = useToastStore()
|
||||
const notificationId = id || 'default'
|
||||
|
||||
console.log(`Updating notification (${notificationId}):`, value)
|
||||
|
||||
// Get existing toast for this ID
|
||||
const existingToast = this.#notificationToasts.get(notificationId)
|
||||
|
||||
if (existingToast) {
|
||||
// Update existing toast by removing and re-adding with new content
|
||||
console.log(`Updating existing notification toast: ${notificationId}`)
|
||||
toastStore.remove(existingToast)
|
||||
|
||||
// Update the detail text
|
||||
existingToast.detail = value
|
||||
toastStore.add(existingToast)
|
||||
} else {
|
||||
// Create new persistent notification toast
|
||||
console.log(`Creating new notification toast: ${notificationId}`)
|
||||
const newToast = {
|
||||
severity: 'info' as const,
|
||||
summary: 'Notification',
|
||||
detail: value,
|
||||
closable: true
|
||||
// No 'life' property means it won't auto-hide
|
||||
}
|
||||
this.#notificationToasts.set(notificationId, newToast)
|
||||
toastStore.add(newToast)
|
||||
}
|
||||
|
||||
// Clear existing timer for this ID and set new one
|
||||
const existingTimer = this.#notificationTimers.get(notificationId)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
const toast = this.#notificationToasts.get(notificationId)
|
||||
if (toast) {
|
||||
console.log(`Auto-hiding notification toast: ${notificationId}`)
|
||||
toastStore.remove(toast)
|
||||
this.#notificationToasts.delete(notificationId)
|
||||
this.#notificationTimers.delete(notificationId)
|
||||
}
|
||||
}, 3000)
|
||||
|
||||
this.#notificationTimers.set(notificationId, timer)
|
||||
console.log('Toast updated successfully')
|
||||
} catch (error) {
|
||||
console.error('Error handling notification:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced notification handler to avoid rapid toast updates
|
||||
*/
|
||||
#debouncedNotificationHandler = debounce((value: string, id?: string) => {
|
||||
this.#handleNotification(value, id)
|
||||
}, 300) // 300ms debounce delay
|
||||
|
||||
/**
|
||||
* The auth token for the comfy org account if the user is logged in.
|
||||
* This is only used for {@link queuePrompt} now. It is not directly
|
||||
@@ -311,7 +393,27 @@ export class ComfyApi extends EventTarget {
|
||||
return this.api_base + route
|
||||
}
|
||||
|
||||
fetchApi(route: string, options?: RequestInit) {
|
||||
/**
|
||||
* Waits for Firebase auth to be initialized before proceeding
|
||||
*/
|
||||
async #waitForAuthInitialization(): Promise<void> {
|
||||
const authStore = useFirebaseAuthStore()
|
||||
|
||||
if (authStore.isInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const unwatch = authStore.$subscribe((_, state) => {
|
||||
if (state.isInitialized) {
|
||||
unwatch()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async fetchApi(route: string, options?: RequestInit) {
|
||||
if (!options) {
|
||||
options = {}
|
||||
}
|
||||
@@ -322,6 +424,30 @@ export class ComfyApi extends EventTarget {
|
||||
options.cache = 'no-cache'
|
||||
}
|
||||
|
||||
// Wait for Firebase auth to be initialized before making any API request
|
||||
await this.#waitForAuthInitialization()
|
||||
|
||||
// Add Firebase JWT token if user is logged in
|
||||
try {
|
||||
const authHeader = await useFirebaseAuthStore().getAuthHeader()
|
||||
if (authHeader) {
|
||||
if (Array.isArray(options.headers)) {
|
||||
for (const [key, value] of Object.entries(authHeader)) {
|
||||
options.headers.push([key, value])
|
||||
}
|
||||
} else if (options.headers instanceof Headers) {
|
||||
for (const [key, value] of Object.entries(authHeader)) {
|
||||
options.headers.set(key, value)
|
||||
}
|
||||
} else {
|
||||
Object.assign(options.headers, authHeader)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore auth errors to avoid breaking API calls
|
||||
console.warn('Failed to get auth header:', error)
|
||||
}
|
||||
|
||||
if (Array.isArray(options.headers)) {
|
||||
options.headers.push(['Comfy-User', this.user])
|
||||
} else if (options.headers instanceof Headers) {
|
||||
@@ -551,6 +677,16 @@ export class ComfyApi extends EventTarget {
|
||||
this.serverFeatureFlags
|
||||
)
|
||||
break
|
||||
case 'notification':
|
||||
// Display notification in toast with debouncing
|
||||
console.log(
|
||||
'Received notification message:',
|
||||
msg.data.value,
|
||||
msg.data.id ? `(ID: ${msg.data.id})` : ''
|
||||
)
|
||||
this.#debouncedNotificationHandler(msg.data.value, msg.data.id)
|
||||
this.dispatchCustomEvent(msg.type, msg.data)
|
||||
break
|
||||
default:
|
||||
if (this.#registered.has(msg.type)) {
|
||||
// Fallback for custom types - calls super direct.
|
||||
@@ -576,6 +712,35 @@ export class ComfyApi extends EventTarget {
|
||||
this.#createSocket()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test method to simulate a notification message (for development/testing)
|
||||
*/
|
||||
testNotification(message: string = 'Test notification message', id?: string) {
|
||||
console.log(
|
||||
'Testing notification with message:',
|
||||
message,
|
||||
id ? `(ID: ${id})` : ''
|
||||
)
|
||||
const mockEvent = {
|
||||
data: JSON.stringify({
|
||||
type: 'notification',
|
||||
data: { value: message, id }
|
||||
})
|
||||
}
|
||||
|
||||
// Simulate the websocket message handler
|
||||
const msg = JSON.parse(mockEvent.data)
|
||||
if (msg.type === 'notification') {
|
||||
console.log(
|
||||
'Received notification message:',
|
||||
msg.data.value,
|
||||
msg.data.id ? `(ID: ${msg.data.id})` : ''
|
||||
)
|
||||
this.#debouncedNotificationHandler(msg.data.value, msg.data.id)
|
||||
this.dispatchCustomEvent(msg.type, msg.data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of extension urls
|
||||
*/
|
||||
@@ -739,6 +904,28 @@ export class ComfyApi extends EventTarget {
|
||||
return this.getHistory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses queue prompt data from array or object format
|
||||
* @param rawPrompt The raw prompt data from the API
|
||||
* @returns Normalized TaskPrompt object
|
||||
*/
|
||||
private parseQueuePrompt(rawPrompt: any): TaskPrompt {
|
||||
if (Array.isArray(rawPrompt)) {
|
||||
// Queue format: [priority, prompt_id, workflow, outputs]
|
||||
const [priority, prompt_id, workflow] = rawPrompt
|
||||
return {
|
||||
priority,
|
||||
prompt_id,
|
||||
extra_data: workflow?.extra_data || {
|
||||
client_id: '',
|
||||
extra_pnginfo: workflow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rawPrompt as TaskPrompt
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current state of the queue
|
||||
* @returns The currently running and queued items
|
||||
@@ -752,15 +939,17 @@ export class ComfyApi extends EventTarget {
|
||||
const data = await res.json()
|
||||
return {
|
||||
// Running action uses a different endpoint for cancelling
|
||||
Running: data.queue_running.map((prompt: Record<number, any>) => ({
|
||||
Running: data.queue_running.map((prompt: any) => ({
|
||||
taskType: 'Running',
|
||||
prompt,
|
||||
// prompt[1] is the prompt id
|
||||
remove: { name: 'Cancel', cb: () => api.interrupt(prompt[1]) }
|
||||
prompt: this.parseQueuePrompt(prompt),
|
||||
remove: {
|
||||
name: 'Cancel',
|
||||
cb: () => api.interrupt(this.parseQueuePrompt(prompt).prompt_id)
|
||||
}
|
||||
})),
|
||||
Pending: data.queue_pending.map((prompt: Record<number, any>) => ({
|
||||
Pending: data.queue_pending.map((prompt: any) => ({
|
||||
taskType: 'Pending',
|
||||
prompt
|
||||
prompt: this.parseQueuePrompt(prompt)
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -777,13 +966,17 @@ export class ComfyApi extends EventTarget {
|
||||
max_items: number = 200
|
||||
): Promise<{ History: HistoryTaskItem[] }> {
|
||||
try {
|
||||
const res = await this.fetchApi(`/history?max_items=${max_items}`)
|
||||
const json: Promise<HistoryTaskItem[]> = await res.json()
|
||||
const res = await this.fetchApi(`/history_v2?max_items=${max_items}`)
|
||||
const json: HistoryResponse = await res.json()
|
||||
|
||||
// Extract history data from new format: { history: [{prompt_id: "...", ...}, ...] }
|
||||
return {
|
||||
History: Object.values(json).map((item) => ({
|
||||
...item,
|
||||
taskType: 'History'
|
||||
}))
|
||||
History: json.history.map(
|
||||
(item): HistoryTaskItem => ({
|
||||
...item,
|
||||
taskType: 'History'
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -791,6 +984,33 @@ export class ComfyApi extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets workflow data for a specific prompt from history
|
||||
* @param prompt_id The prompt ID to fetch workflow for
|
||||
* @returns Workflow data for the specific prompt
|
||||
*/
|
||||
async getWorkflowFromHistory(
|
||||
prompt_id: string
|
||||
): Promise<ComfyWorkflowJSON | null> {
|
||||
try {
|
||||
const res = await this.fetchApi(`/history_v2/${prompt_id}`)
|
||||
const json = await res.json()
|
||||
|
||||
// The /history_v2/{prompt_id} endpoint returns data for a specific prompt
|
||||
// The response format is: { prompt_id: { prompt: {priority, prompt_id, extra_data}, outputs: {...}, status: {...} } }
|
||||
const historyItem = json[prompt_id]
|
||||
if (!historyItem) return null
|
||||
|
||||
// Extract workflow from the prompt object
|
||||
// prompt.extra_data contains extra_pnginfo.workflow
|
||||
const workflow = historyItem.prompt?.extra_data?.extra_pnginfo?.workflow
|
||||
return workflow || null
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch workflow for prompt ${prompt_id}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets system & device stats
|
||||
* @returns System stats such as python version, OS, per device info
|
||||
|
||||
@@ -323,6 +323,14 @@ export class ComfyApp {
|
||||
return '&rand=' + Math.random()
|
||||
}
|
||||
|
||||
getClientIdParam() {
|
||||
const clientId = window.name
|
||||
if (clientId) {
|
||||
return '&client_id=' + clientId
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
static onClipspaceEditorSave() {
|
||||
if (ComfyApp.clipspace_return_node) {
|
||||
ComfyApp.pasteFromClipspace(ComfyApp.clipspace_return_node)
|
||||
|
||||
@@ -264,15 +264,21 @@ class ComfyList {
|
||||
? item.remove
|
||||
: {
|
||||
name: 'Delete',
|
||||
cb: () => api.deleteItem(this.#type, item.prompt[1])
|
||||
cb: () =>
|
||||
api.deleteItem(
|
||||
this.#type,
|
||||
Array.isArray(item.prompt)
|
||||
? item.prompt[1]
|
||||
: item.prompt.prompt_id
|
||||
)
|
||||
}
|
||||
return $el('div', { textContent: item.prompt[0] + ': ' }, [
|
||||
return $el('div', { textContent: item.prompt.priority + ': ' }, [
|
||||
$el('button', {
|
||||
textContent: 'Load',
|
||||
onclick: async () => {
|
||||
await app.loadGraphData(
|
||||
// @ts-expect-error fixme ts strict error
|
||||
item.prompt[3].extra_pnginfo.workflow,
|
||||
item.prompt.extra_data.extra_pnginfo.workflow,
|
||||
true,
|
||||
false
|
||||
)
|
||||
|
||||
@@ -5,10 +5,12 @@ import { t } from '@/i18n'
|
||||
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SerialisableGraph, Vector2 } from '@/lib/litegraph/src/litegraph'
|
||||
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
|
||||
import { downloadBlob } from '@/scripts/utils'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { TaskItemImpl } from '@/stores/queueStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
@@ -154,6 +156,32 @@ export const useWorkflowService = () => {
|
||||
await app.loadGraphData(blankGraph)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a workflow from a task item (queue/history)
|
||||
* For history items, fetches workflow data from /history_v2/{prompt_id}
|
||||
* @param task The task item to load the workflow from
|
||||
*/
|
||||
const loadTaskWorkflow = async (task: TaskItemImpl) => {
|
||||
let workflowData = task.workflow
|
||||
|
||||
// History items don't include workflow data - fetch from API
|
||||
if (task.isHistory) {
|
||||
const promptId = task.prompt.prompt_id
|
||||
if (promptId) {
|
||||
workflowData = (await api.getWorkflowFromHistory(promptId)) || undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (!workflowData) {
|
||||
return
|
||||
}
|
||||
|
||||
await app.loadGraphData(toRaw(workflowData))
|
||||
if (task.outputs) {
|
||||
app.nodeOutputs = toRaw(task.outputs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the current workflow
|
||||
* This is used to refresh the node definitions update, e.g. when the locale changes.
|
||||
@@ -402,6 +430,7 @@ export const useWorkflowService = () => {
|
||||
saveWorkflow,
|
||||
loadDefaultWorkflow,
|
||||
loadBlankWorkflow,
|
||||
loadTaskWorkflow,
|
||||
reloadCurrentWorkflow,
|
||||
openWorkflow,
|
||||
closeWorkflow,
|
||||
|
||||
@@ -83,6 +83,16 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
currentUser.value = user
|
||||
isInitialized.value = true
|
||||
|
||||
if (user && (window as any).mixpanel) {
|
||||
;(window as any).mixpanel
|
||||
.identify(user.uid)(window as any)
|
||||
.mixpanel.people.set({
|
||||
$email: user.email,
|
||||
$name: user.displayName,
|
||||
$created: user.metadata.creationTime
|
||||
})
|
||||
}
|
||||
|
||||
// Reset balance when auth state changes
|
||||
balance.value = null
|
||||
lastBalanceUpdateTime.value = null
|
||||
|
||||
@@ -90,10 +90,13 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
|
||||
const rand = app.getRandParam()
|
||||
const previewParam = getPreviewParam(node, outputs)
|
||||
const clientIdParam = app.getClientIdParam()
|
||||
|
||||
return outputs.images.map((image) => {
|
||||
const imgUrlPart = new URLSearchParams(image)
|
||||
return api.apiURL(`/view?${imgUrlPart}${previewParam}${rand}`)
|
||||
return api.apiURL(
|
||||
`/view?${imgUrlPart}${previewParam}${rand}${clientIdParam}`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,11 @@ export class ResultItemImpl {
|
||||
params.set('type', this.type)
|
||||
params.set('subfolder', this.subfolder)
|
||||
|
||||
const clientId = window.name
|
||||
if (clientId) {
|
||||
params.set('client_id', clientId)
|
||||
}
|
||||
|
||||
if (this.format) {
|
||||
params.set('format', this.format)
|
||||
}
|
||||
@@ -271,23 +276,15 @@ export class TaskItemImpl {
|
||||
}
|
||||
|
||||
get queueIndex() {
|
||||
return this.prompt[0]
|
||||
return this.prompt.priority
|
||||
}
|
||||
|
||||
get promptId() {
|
||||
return this.prompt[1]
|
||||
}
|
||||
|
||||
get promptInputs() {
|
||||
return this.prompt[2]
|
||||
return this.prompt.prompt_id
|
||||
}
|
||||
|
||||
get extraData() {
|
||||
return this.prompt[3]
|
||||
}
|
||||
|
||||
get outputsToExecute() {
|
||||
return this.prompt[4]
|
||||
return this.prompt.extra_data
|
||||
}
|
||||
|
||||
get extraPngInfo() {
|
||||
@@ -403,13 +400,11 @@ export class TaskItemImpl {
|
||||
(output: ResultItemImpl, i: number) =>
|
||||
new TaskItemImpl(
|
||||
this.taskType,
|
||||
[
|
||||
this.queueIndex,
|
||||
`${this.promptId}-${i}`,
|
||||
this.promptInputs,
|
||||
this.extraData,
|
||||
this.outputsToExecute
|
||||
],
|
||||
{
|
||||
priority: this.queueIndex,
|
||||
prompt_id: `${this.promptId}-${i}`,
|
||||
extra_data: this.extraData
|
||||
},
|
||||
this.status,
|
||||
{
|
||||
[output.nodeId]: {
|
||||
@@ -474,11 +469,11 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
pendingTasks.value = toClassAll(queue.Pending)
|
||||
|
||||
const allIndex = new Set<number>(
|
||||
history.History.map((item: TaskItem) => item.prompt[0])
|
||||
history.History.map((item: TaskItem) => item.prompt.priority)
|
||||
)
|
||||
const newHistoryItems = toClassAll(
|
||||
history.History.filter(
|
||||
(item) => item.prompt[0] > lastHistoryQueueIndex.value
|
||||
(item) => item.prompt.priority > lastHistoryQueueIndex.value
|
||||
)
|
||||
)
|
||||
const existingHistoryItems = historyTasks.value.filter((item) =>
|
||||
@@ -486,7 +481,11 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
)
|
||||
historyTasks.value = [...newHistoryItems, ...existingHistoryItems]
|
||||
.slice(0, maxHistoryItems.value)
|
||||
.sort((a, b) => b.queueIndex - a.queueIndex)
|
||||
.sort((a, b) => {
|
||||
const aTime = a.executionStartTimestamp ?? 0
|
||||
const bTime = b.executionStartTimestamp ?? 0
|
||||
return bTime - aTime
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
248
tests-ui/tests/scripts/api.test.ts
Normal file
248
tests-ui/tests/scripts/api.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
HistoryResponse,
|
||||
RawHistoryItem
|
||||
} from '../../../src/schemas/apiSchema'
|
||||
import type { ComfyWorkflowJSON } from '../../../src/schemas/comfyWorkflowSchema'
|
||||
import { ComfyApi } from '../../../src/scripts/api'
|
||||
|
||||
describe('ComfyApi getHistory', () => {
|
||||
let api: ComfyApi
|
||||
|
||||
beforeEach(() => {
|
||||
api = new ComfyApi()
|
||||
})
|
||||
|
||||
const mockHistoryItem: RawHistoryItem = {
|
||||
prompt_id: 'test_prompt_id',
|
||||
prompt: {
|
||||
priority: 0,
|
||||
prompt_id: 'test_prompt_id',
|
||||
extra_data: {
|
||||
extra_pnginfo: {
|
||||
workflow: {
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4
|
||||
}
|
||||
},
|
||||
client_id: 'test_client_id'
|
||||
}
|
||||
},
|
||||
outputs: {},
|
||||
status: {
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: []
|
||||
}
|
||||
}
|
||||
|
||||
describe('history v2 API format', () => {
|
||||
it('should handle history array format from /history_v2', async () => {
|
||||
const historyResponse: HistoryResponse = {
|
||||
history: [
|
||||
{ ...mockHistoryItem, prompt_id: 'prompt_id_1' },
|
||||
{ ...mockHistoryItem, prompt_id: 'prompt_id_2' }
|
||||
]
|
||||
}
|
||||
|
||||
// Mock fetchApi to return the v2 format
|
||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue(historyResponse)
|
||||
})
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
const result = await api.getHistory(10)
|
||||
|
||||
expect(result.History).toHaveLength(2)
|
||||
expect(result.History[0]).toEqual({
|
||||
...mockHistoryItem,
|
||||
prompt_id: 'prompt_id_1',
|
||||
taskType: 'History'
|
||||
})
|
||||
expect(result.History[1]).toEqual({
|
||||
...mockHistoryItem,
|
||||
prompt_id: 'prompt_id_2',
|
||||
taskType: 'History'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty history array', async () => {
|
||||
const historyResponse: HistoryResponse = {
|
||||
history: []
|
||||
}
|
||||
|
||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue(historyResponse)
|
||||
})
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
const result = await api.getHistory(10)
|
||||
|
||||
expect(result.History).toHaveLength(0)
|
||||
expect(result.History).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should return empty history on error', async () => {
|
||||
const mockFetchApi = vi.fn().mockRejectedValue(new Error('Network error'))
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
const result = await api.getHistory()
|
||||
|
||||
expect(result.History).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('API call parameters', () => {
|
||||
it('should call fetchApi with correct v2 endpoint and parameters', async () => {
|
||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({ history: [] })
|
||||
})
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
await api.getHistory(50)
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/history_v2?max_items=50')
|
||||
})
|
||||
|
||||
it('should use default max_items parameter with v2 endpoint', async () => {
|
||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({ history: [] })
|
||||
})
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
await api.getHistory()
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/history_v2?max_items=200')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ComfyApi getWorkflowFromHistory', () => {
|
||||
let api: ComfyApi
|
||||
|
||||
beforeEach(() => {
|
||||
api = new ComfyApi()
|
||||
})
|
||||
|
||||
const mockWorkflow: ComfyWorkflowJSON = {
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4
|
||||
}
|
||||
|
||||
it('should fetch workflow data for a specific prompt', async () => {
|
||||
const promptId = 'test_prompt_id'
|
||||
const mockResponse = {
|
||||
[promptId]: {
|
||||
prompt: {
|
||||
priority: 0,
|
||||
prompt_id: promptId,
|
||||
extra_data: {
|
||||
extra_pnginfo: {
|
||||
workflow: mockWorkflow
|
||||
}
|
||||
}
|
||||
},
|
||||
outputs: {},
|
||||
status: {
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue(mockResponse)
|
||||
})
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
const result = await api.getWorkflowFromHistory(promptId)
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith(`/history_v2/${promptId}`)
|
||||
expect(result).toEqual(mockWorkflow)
|
||||
})
|
||||
|
||||
it('should return null when prompt_id is not found', async () => {
|
||||
const promptId = 'non_existent_prompt'
|
||||
const mockResponse = {}
|
||||
|
||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue(mockResponse)
|
||||
})
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
const result = await api.getWorkflowFromHistory(promptId)
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith(`/history_v2/${promptId}`)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when workflow data is missing', async () => {
|
||||
const promptId = 'test_prompt_id'
|
||||
const mockResponse = {
|
||||
[promptId]: {
|
||||
prompt: {
|
||||
priority: 0,
|
||||
prompt_id: promptId,
|
||||
extra_data: {}
|
||||
},
|
||||
outputs: {},
|
||||
status: {
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue(mockResponse)
|
||||
})
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
const result = await api.getWorkflowFromHistory(promptId)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
const promptId = 'test_prompt_id'
|
||||
const mockFetchApi = vi.fn().mockRejectedValue(new Error('Network error'))
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
const result = await api.getWorkflowFromHistory(promptId)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle malformed response gracefully', async () => {
|
||||
const promptId = 'test_prompt_id'
|
||||
const mockResponse = {
|
||||
[promptId]: null
|
||||
}
|
||||
|
||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue(mockResponse)
|
||||
})
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
const result = await api.getWorkflowFromHistory(promptId)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -3,10 +3,94 @@ import { describe, expect, it } from 'vitest'
|
||||
import { TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
describe('TaskItemImpl', () => {
|
||||
describe('prompt property accessors', () => {
|
||||
it('should correctly access queueIndex from priority', () => {
|
||||
const taskItem = new TaskItemImpl('Pending', {
|
||||
priority: 5,
|
||||
prompt_id: 'test-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
})
|
||||
|
||||
expect(taskItem.queueIndex).toBe(5)
|
||||
})
|
||||
|
||||
it('should correctly access promptId from prompt_id', () => {
|
||||
const taskItem = new TaskItemImpl('History', {
|
||||
priority: 0,
|
||||
prompt_id: 'unique-prompt-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
})
|
||||
|
||||
expect(taskItem.promptId).toBe('unique-prompt-id')
|
||||
})
|
||||
|
||||
it('should correctly access extraData', () => {
|
||||
const extraData = {
|
||||
client_id: 'client-id',
|
||||
extra_pnginfo: {
|
||||
workflow: {
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4
|
||||
}
|
||||
}
|
||||
}
|
||||
const taskItem = new TaskItemImpl('Running', {
|
||||
priority: 1,
|
||||
prompt_id: 'test-id',
|
||||
extra_data: extraData
|
||||
})
|
||||
|
||||
expect(taskItem.extraData).toEqual(extraData)
|
||||
})
|
||||
|
||||
it('should correctly access workflow from extraPngInfo', () => {
|
||||
const workflow = {
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4
|
||||
}
|
||||
const taskItem = new TaskItemImpl('History', {
|
||||
priority: 0,
|
||||
prompt_id: 'test-id',
|
||||
extra_data: {
|
||||
client_id: 'client-id',
|
||||
extra_pnginfo: { workflow }
|
||||
}
|
||||
})
|
||||
|
||||
expect(taskItem.workflow).toEqual(workflow)
|
||||
})
|
||||
|
||||
it('should return undefined workflow when extraPngInfo is missing', () => {
|
||||
const taskItem = new TaskItemImpl('History', {
|
||||
priority: 0,
|
||||
prompt_id: 'test-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
})
|
||||
|
||||
expect(taskItem.workflow).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove animated property from outputs during construction', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
||||
{
|
||||
priority: 0,
|
||||
prompt_id: 'prompt-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{ status_str: 'success', messages: [], completed: true },
|
||||
{
|
||||
'node-1': {
|
||||
@@ -26,7 +110,11 @@ describe('TaskItemImpl', () => {
|
||||
it('should handle outputs without animated property', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
||||
{
|
||||
priority: 0,
|
||||
prompt_id: 'prompt-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{ status_str: 'success', messages: [], completed: true },
|
||||
{
|
||||
'node-1': {
|
||||
@@ -42,7 +130,11 @@ describe('TaskItemImpl', () => {
|
||||
it('should recognize webm video from core', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
||||
{
|
||||
priority: 0,
|
||||
prompt_id: 'prompt-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{ status_str: 'success', messages: [], completed: true },
|
||||
{
|
||||
'node-1': {
|
||||
@@ -64,7 +156,11 @@ describe('TaskItemImpl', () => {
|
||||
it('should recognize webm video from VHS', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
||||
{
|
||||
priority: 0,
|
||||
prompt_id: 'prompt-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{ status_str: 'success', messages: [], completed: true },
|
||||
{
|
||||
'node-1': {
|
||||
@@ -93,7 +189,11 @@ describe('TaskItemImpl', () => {
|
||||
it('should recognize mp4 video from core', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
||||
{
|
||||
priority: 0,
|
||||
prompt_id: 'prompt-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{ status_str: 'success', messages: [], completed: true },
|
||||
{
|
||||
'node-1': {
|
||||
@@ -128,7 +228,11 @@ describe('TaskItemImpl', () => {
|
||||
it(`should recognize ${extension} audio`, () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
||||
{
|
||||
priority: 0,
|
||||
prompt_id: 'prompt-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{ status_str: 'success', messages: [], completed: true },
|
||||
{
|
||||
'node-1': {
|
||||
@@ -153,4 +257,193 @@ describe('TaskItemImpl', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('execution timestamp properties', () => {
|
||||
it('should extract execution start timestamp from messages', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
{
|
||||
priority: 0,
|
||||
prompt_id: 'test-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: [
|
||||
[
|
||||
'execution_start',
|
||||
{ prompt_id: 'test-id', timestamp: 1234567890 }
|
||||
],
|
||||
[
|
||||
'execution_success',
|
||||
{ prompt_id: 'test-id', timestamp: 1234567900 }
|
||||
]
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
expect(taskItem.executionStartTimestamp).toBe(1234567890)
|
||||
})
|
||||
|
||||
it('should return undefined when no execution_start message exists', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
{
|
||||
priority: 0,
|
||||
prompt_id: 'test-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: [
|
||||
[
|
||||
'execution_success',
|
||||
{ prompt_id: 'test-id', timestamp: 1234567900 }
|
||||
]
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
expect(taskItem.executionStartTimestamp).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined when status has no messages', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
{
|
||||
priority: 0,
|
||||
prompt_id: 'test-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: []
|
||||
}
|
||||
)
|
||||
|
||||
expect(taskItem.executionStartTimestamp).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined when status is undefined', () => {
|
||||
const taskItem = new TaskItemImpl('History', {
|
||||
priority: 0,
|
||||
prompt_id: 'test-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
})
|
||||
|
||||
expect(taskItem.executionStartTimestamp).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sorting by execution start time', () => {
|
||||
it('should sort history tasks by execution start timestamp descending', () => {
|
||||
const task1 = new TaskItemImpl(
|
||||
'History',
|
||||
{
|
||||
priority: 1,
|
||||
prompt_id: 'old-task',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: [
|
||||
['execution_start', { prompt_id: 'old-task', timestamp: 1000 }]
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
const task2 = new TaskItemImpl(
|
||||
'History',
|
||||
{
|
||||
priority: 2,
|
||||
prompt_id: 'new-task',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: [
|
||||
['execution_start', { prompt_id: 'new-task', timestamp: 3000 }]
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
const task3 = new TaskItemImpl(
|
||||
'History',
|
||||
{
|
||||
priority: 3,
|
||||
prompt_id: 'middle-task',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: [
|
||||
['execution_start', { prompt_id: 'middle-task', timestamp: 2000 }]
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
const tasks = [task1, task2, task3]
|
||||
|
||||
// Sort using the same logic as queueStore
|
||||
tasks.sort((a, b) => {
|
||||
const aTime = a.executionStartTimestamp ?? 0
|
||||
const bTime = b.executionStartTimestamp ?? 0
|
||||
return bTime - aTime
|
||||
})
|
||||
|
||||
expect(tasks[0].promptId).toBe('new-task')
|
||||
expect(tasks[1].promptId).toBe('middle-task')
|
||||
expect(tasks[2].promptId).toBe('old-task')
|
||||
})
|
||||
|
||||
it('should place tasks without execution start timestamp at end', () => {
|
||||
const taskWithTime = new TaskItemImpl(
|
||||
'History',
|
||||
{
|
||||
priority: 1,
|
||||
prompt_id: 'with-time',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: [
|
||||
['execution_start', { prompt_id: 'with-time', timestamp: 2000 }]
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
const taskWithoutTime = new TaskItemImpl(
|
||||
'History',
|
||||
{
|
||||
priority: 2,
|
||||
prompt_id: 'without-time',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: []
|
||||
}
|
||||
)
|
||||
|
||||
const tasks = [taskWithoutTime, taskWithTime]
|
||||
|
||||
// Sort using the same logic as queueStore
|
||||
tasks.sort((a, b) => {
|
||||
const aTime = a.executionStartTimestamp ?? 0
|
||||
const bTime = b.executionStartTimestamp ?? 0
|
||||
return bTime - aTime
|
||||
})
|
||||
|
||||
expect(tasks[0].promptId).toBe('with-time')
|
||||
expect(tasks[1].promptId).toBe('without-time')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user