mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-04 21:22:07 +00:00
## Summary Improves the handling of the job tracking where the websocket vs http response are processed in a non-deterministic order ## Changes - **What**: Update onJobStart watch and guard to watch both the activeJobId and the path mapping to ensure both trigger a start, but do not double start ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10244-fix-App-mode-handle-socket-response-race-when-tracking-jobs-3276d73d365081f2862bd6cdcce3aac7) by [Unito](https://www.unito.io)
1408 lines
45 KiB
TypeScript
1408 lines
45 KiB
TypeScript
import { createPinia, setActivePinia } from 'pinia'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { ref } from 'vue'
|
|
|
|
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
|
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
|
import { ResultItemImpl } from '@/stores/queueStore'
|
|
|
|
const activeJobIdRef = ref<string | null>(null)
|
|
const previewsRef = ref<Record<string, { url: string; nodeId?: string }>>({})
|
|
const isAppModeRef = ref(true)
|
|
const activeWorkflowPathRef = ref<string>('workflows/test-workflow.json')
|
|
const jobIdToWorkflowPathRef = ref(new Map<string, string>())
|
|
const selectedOutputsRef = ref<string[]>([])
|
|
|
|
const { apiTarget } = vi.hoisted(() => ({
|
|
apiTarget: new EventTarget()
|
|
}))
|
|
|
|
vi.mock('@/composables/useAppMode', () => ({
|
|
useAppMode: () => ({
|
|
isAppMode: isAppModeRef
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/stores/appModeStore', () => ({
|
|
useAppModeStore: () => ({
|
|
get selectedOutputs() {
|
|
return selectedOutputsRef.value
|
|
}
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/stores/executionStore', () => ({
|
|
useExecutionStore: () => ({
|
|
get activeJobId() {
|
|
return activeJobIdRef.value
|
|
},
|
|
get jobIdToSessionWorkflowPath() {
|
|
return jobIdToWorkflowPathRef.value
|
|
}
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
|
useWorkflowStore: () => ({
|
|
get activeWorkflow() {
|
|
return { path: activeWorkflowPathRef.value }
|
|
}
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/stores/jobPreviewStore', () => ({
|
|
useJobPreviewStore: () => ({
|
|
get nodePreviewsByPromptId() {
|
|
return previewsRef.value
|
|
}
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/scripts/api', () => ({
|
|
api: Object.assign(apiTarget, {
|
|
apiURL: (path: string) => path
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/renderer/extensions/linearMode/flattenNodeOutput', () => ({
|
|
flattenNodeOutput: ([nodeId, output]: [
|
|
string | number,
|
|
Record<string, unknown>
|
|
]) => {
|
|
if (!output.images) return []
|
|
return (output.images as Array<Record<string, string>>).map(
|
|
(img) =>
|
|
new ResultItemImpl({
|
|
...img,
|
|
nodeId: String(nodeId),
|
|
mediaType: 'images'
|
|
})
|
|
)
|
|
}
|
|
}))
|
|
|
|
function setJobWorkflowPath(jobId: string, path: string) {
|
|
const next = new Map(jobIdToWorkflowPathRef.value)
|
|
next.set(jobId, path)
|
|
jobIdToWorkflowPathRef.value = next
|
|
}
|
|
|
|
function makeExecutedDetail(
|
|
promptId: string,
|
|
images: Array<Record<string, string>> = [
|
|
{ filename: 'out.png', subfolder: '', type: 'output' }
|
|
],
|
|
nodeId = '1'
|
|
): ExecutedWsMessage {
|
|
return {
|
|
prompt_id: promptId,
|
|
node: nodeId,
|
|
display_node: nodeId,
|
|
output: { images }
|
|
} as ExecutedWsMessage
|
|
}
|
|
|
|
describe('linearOutputStore', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createPinia())
|
|
activeJobIdRef.value = null
|
|
previewsRef.value = {}
|
|
isAppModeRef.value = true
|
|
activeWorkflowPathRef.value = 'workflows/test-workflow.json'
|
|
jobIdToWorkflowPathRef.value = new Map()
|
|
selectedOutputsRef.value = []
|
|
})
|
|
|
|
it('creates a skeleton item when a job starts', () => {
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
|
|
expect(store.inProgressItems).toHaveLength(1)
|
|
expect(store.inProgressItems[0].state).toBe('skeleton')
|
|
expect(store.inProgressItems[0].jobId).toBe('job-1')
|
|
})
|
|
|
|
it('auto-selects skeleton on first job start when no selection', () => {
|
|
const store = useLinearOutputStore()
|
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
|
store.onJobStart('job-1')
|
|
|
|
expect(store.selectedId).toBe(`slot:${store.inProgressItems[0].id}`)
|
|
})
|
|
|
|
it('transitions to latent on preview', () => {
|
|
vi.useFakeTimers()
|
|
const store = useLinearOutputStore()
|
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
|
store.onJobStart('job-1')
|
|
|
|
const itemId = store.inProgressItems[0].id
|
|
store.onLatentPreview('job-1', 'blob:preview')
|
|
vi.advanceTimersByTime(16)
|
|
|
|
expect(store.inProgressItems[0].state).toBe('latent')
|
|
expect(store.inProgressItems[0].latentPreviewUrl).toBe('blob:preview')
|
|
expect(store.selectedId).toBe(`slot:${itemId}`)
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('ignores latent preview for other jobs', () => {
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
|
|
store.onLatentPreview('job-other', 'blob:preview')
|
|
|
|
expect(store.inProgressItems[0].state).toBe('skeleton')
|
|
})
|
|
|
|
it('transitions to image on executed event', () => {
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
|
|
|
const imageItems = store.inProgressItems.filter((i) => i.state === 'image')
|
|
expect(imageItems).toHaveLength(1)
|
|
expect(imageItems[0].output).toBeDefined()
|
|
})
|
|
|
|
it('does not create trailing skeleton after executed output', () => {
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
|
|
|
expect(store.inProgressItems).toHaveLength(1)
|
|
expect(store.inProgressItems[0].state).toBe('image')
|
|
})
|
|
|
|
it('handles multi-output executed events', () => {
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
|
|
store.onNodeExecuted(
|
|
'job-1',
|
|
makeExecutedDetail('job-1', [
|
|
{ filename: 'a.png', subfolder: '', type: 'output' },
|
|
{ filename: 'b.png', subfolder: '', type: 'output' }
|
|
])
|
|
)
|
|
|
|
const imageItems = store.inProgressItems.filter((i) => i.state === 'image')
|
|
expect(imageItems).toHaveLength(2)
|
|
})
|
|
|
|
it('removes slots when job ends without image outputs', () => {
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
expect(store.inProgressItems).toHaveLength(1)
|
|
|
|
store.onJobComplete('job-1')
|
|
|
|
expect(store.inProgressItems).toHaveLength(0)
|
|
expect(store.pendingResolve.size).toBe(0)
|
|
})
|
|
|
|
it('adds to pendingResolve when job completes with images', () => {
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
|
|
|
store.onJobComplete('job-1')
|
|
|
|
expect(store.inProgressItems.length).toBeGreaterThan(0)
|
|
expect(store.pendingResolve.has('job-1')).toBe(true)
|
|
})
|
|
|
|
it('removes items when history resolves', () => {
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
|
|
|
store.onJobComplete('job-1')
|
|
expect(store.inProgressItems.length).toBeGreaterThan(0)
|
|
|
|
store.resolveIfReady('job-1', true)
|
|
|
|
expect(store.inProgressItems).toHaveLength(0)
|
|
expect(store.pendingResolve.has('job-1')).toBe(false)
|
|
})
|
|
|
|
it('does not resolve if history has not arrived', () => {
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
|
|
|
store.onJobComplete('job-1')
|
|
|
|
store.resolveIfReady('job-1', false)
|
|
|
|
expect(store.inProgressItems.length).toBeGreaterThan(0)
|
|
expect(store.pendingResolve.has('job-1')).toBe(true)
|
|
})
|
|
|
|
it('does not auto-select when user is browsing history', () => {
|
|
const store = useLinearOutputStore()
|
|
|
|
// User manually selects a history item (browsing)
|
|
store.select('history:asset-2:0')
|
|
|
|
store.onJobStart('job-1')
|
|
|
|
// Should NOT yank to in-progress — user is browsing
|
|
expect(store.selectedId).toBe('history:asset-2:0')
|
|
|
|
store.onLatentPreview('job-1', 'blob:preview')
|
|
|
|
// Still should NOT yank
|
|
expect(store.selectedId).toBe('history:asset-2:0')
|
|
})
|
|
|
|
it('auto-selects on new job when following latest', () => {
|
|
const store = useLinearOutputStore()
|
|
|
|
// selectAsLatest simulates "following the latest output"
|
|
store.selectAsLatest('history:asset-1:0')
|
|
|
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
|
store.onJobStart('job-1')
|
|
|
|
// Following latest → auto-select new skeleton
|
|
expect(store.selectedId?.startsWith('slot:')).toBe(true)
|
|
})
|
|
|
|
it('does not auto-select on new job when browsing history', () => {
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
|
|
// User manually browses to an older output
|
|
store.select('history:asset-1:0')
|
|
|
|
store.onJobStart('job-2')
|
|
|
|
// Should NOT auto-select — user is browsing
|
|
expect(store.selectedId).toBe('history:asset-1:0')
|
|
})
|
|
|
|
it('falls back selection when selected item is removed', () => {
|
|
const store = useLinearOutputStore()
|
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
|
store.onJobStart('job-1')
|
|
const firstId = `slot:${store.inProgressItems[0].id}`
|
|
expect(store.selectedId).toBe(firstId)
|
|
|
|
store.onJobComplete('job-1')
|
|
|
|
// Skeleton removed, no images, should clear selection
|
|
expect(store.selectedId).toBeNull()
|
|
})
|
|
|
|
it('creates skeleton on-demand when latent arrives after execute', () => {
|
|
vi.useFakeTimers()
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
|
|
|
// No skeleton after execute
|
|
expect(
|
|
store.inProgressItems.filter((i) => i.state === 'skeleton')
|
|
).toHaveLength(0)
|
|
|
|
// Next node sends latent preview — skeleton created on demand
|
|
store.onLatentPreview('job-1', 'blob:next')
|
|
vi.advanceTimersByTime(16)
|
|
|
|
expect(store.inProgressItems[0].state).toBe('latent')
|
|
expect(store.inProgressItems[0].latentPreviewUrl).toBe('blob:next')
|
|
expect(store.inProgressItems).toHaveLength(2)
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('handles execute without prior skeleton (no latent preview)', () => {
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
|
|
// First node executes (consumes initial skeleton)
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
|
expect(store.inProgressItems).toHaveLength(1)
|
|
|
|
// Second node executes directly (no latent, no skeleton)
|
|
store.onNodeExecuted(
|
|
'job-1',
|
|
makeExecutedDetail('job-1', [
|
|
{ filename: 'b.png', subfolder: '', type: 'output' }
|
|
])
|
|
)
|
|
|
|
const images = store.inProgressItems.filter((i) => i.state === 'image')
|
|
expect(images).toHaveLength(2)
|
|
})
|
|
|
|
it('does not fall back selection to stale items from other jobs', () => {
|
|
const store = useLinearOutputStore()
|
|
|
|
// Job 1 starts but is never completed (simulates watcher bug)
|
|
store.onJobStart('job-1')
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
|
|
|
// Job 2 starts directly
|
|
store.onJobStart('job-2')
|
|
store.onNodeExecuted('job-2', makeExecutedDetail('job-2'))
|
|
store.onJobComplete('job-2')
|
|
|
|
// Resolve job-2
|
|
store.resolveIfReady('job-2', true)
|
|
|
|
// Should clear to null for history takeover, NOT fall back to job-1
|
|
expect(store.selectedId).toBeNull()
|
|
})
|
|
|
|
it('transitions to latent via previews watcher', async () => {
|
|
vi.useFakeTimers()
|
|
const { nextTick } = await import('vue')
|
|
const store = useLinearOutputStore()
|
|
|
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
|
activeJobIdRef.value = 'job-1'
|
|
await nextTick()
|
|
|
|
expect(store.inProgressItems).toHaveLength(1)
|
|
expect(store.inProgressItems[0].state).toBe('skeleton')
|
|
|
|
// Simulate jobPreviewStore update
|
|
previewsRef.value = {
|
|
'job-1': { url: 'blob:preview-1', nodeId: 'node-1' }
|
|
}
|
|
await nextTick()
|
|
vi.advanceTimersByTime(16)
|
|
|
|
expect(store.inProgressItems[0].state).toBe('latent')
|
|
expect(store.inProgressItems[0].latentPreviewUrl).toBe('blob:preview-1')
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('completes previous job on direct job transition', async () => {
|
|
const { nextTick } = await import('vue')
|
|
const store = useLinearOutputStore()
|
|
|
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
|
activeJobIdRef.value = 'job-1'
|
|
await nextTick()
|
|
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
|
|
|
// Direct transition: job-1 → job-2 (no null in between)
|
|
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
|
|
activeJobIdRef.value = 'job-2'
|
|
await nextTick()
|
|
|
|
// job-1 should have been completed
|
|
expect(store.pendingResolve.has('job-1')).toBe(true)
|
|
// job-2 should have started
|
|
expect(store.inProgressItems.some((i) => i.jobId === 'job-2')).toBe(true)
|
|
})
|
|
|
|
it('two sequential runs: selection clears after each resolve', () => {
|
|
const store = useLinearOutputStore()
|
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
|
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
|
|
|
|
// Run 1: 3 outputs
|
|
store.onJobStart('job-1')
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
|
store.onNodeExecuted(
|
|
'job-1',
|
|
makeExecutedDetail('job-1', [
|
|
{ filename: 'b.png', subfolder: '', type: 'output' }
|
|
])
|
|
)
|
|
store.onNodeExecuted(
|
|
'job-1',
|
|
makeExecutedDetail('job-1', [
|
|
{ filename: 'c.png', subfolder: '', type: 'output' }
|
|
])
|
|
)
|
|
|
|
const run1Images = store.inProgressItems.filter((i) => i.state === 'image')
|
|
expect(run1Images).toHaveLength(3)
|
|
|
|
store.onJobComplete('job-1')
|
|
store.resolveIfReady('job-1', true)
|
|
expect(store.selectedId).toBeNull()
|
|
expect(store.inProgressItems).toHaveLength(0)
|
|
|
|
// Simulate OutputHistory selecting run 1's first output (following latest)
|
|
store.selectAsLatest('history:asset-run1:0')
|
|
|
|
// Run 2: 3 outputs
|
|
store.onJobStart('job-2')
|
|
store.onNodeExecuted('job-2', makeExecutedDetail('job-2'))
|
|
store.onNodeExecuted(
|
|
'job-2',
|
|
makeExecutedDetail('job-2', [
|
|
{ filename: 'e.png', subfolder: '', type: 'output' }
|
|
])
|
|
)
|
|
store.onNodeExecuted(
|
|
'job-2',
|
|
makeExecutedDetail('job-2', [
|
|
{ filename: 'f.png', subfolder: '', type: 'output' }
|
|
])
|
|
)
|
|
|
|
const run2Images = store.inProgressItems.filter((i) => i.state === 'image')
|
|
expect(run2Images).toHaveLength(3)
|
|
// Selection on run 2's latest output, not run 1's
|
|
expect(store.selectedId).toBe(`slot:${run2Images[0].id}`)
|
|
|
|
store.onJobComplete('job-2')
|
|
store.resolveIfReady('job-2', true)
|
|
|
|
// Must be null for history takeover — not a stale item
|
|
expect(store.selectedId).toBeNull()
|
|
expect(store.inProgressItems).toHaveLength(0)
|
|
})
|
|
|
|
it('keeps items visible across multiple resolveIfReady calls until loaded', () => {
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
|
store.onNodeExecuted(
|
|
'job-1',
|
|
makeExecutedDetail('job-1', [
|
|
{ filename: 'b.png', subfolder: '', type: 'output' }
|
|
])
|
|
)
|
|
store.onJobComplete('job-1')
|
|
|
|
// History asset exists but outputs not loaded yet (async)
|
|
store.resolveIfReady('job-1', false)
|
|
expect(store.inProgressItems).toHaveLength(2)
|
|
expect(store.pendingResolve.has('job-1')).toBe(true)
|
|
|
|
// Still not loaded on next check
|
|
store.resolveIfReady('job-1', false)
|
|
expect(store.inProgressItems).toHaveLength(2)
|
|
|
|
// Outputs finally loaded
|
|
store.resolveIfReady('job-1', true)
|
|
expect(store.inProgressItems).toHaveLength(0)
|
|
expect(store.pendingResolve.has('job-1')).toBe(false)
|
|
})
|
|
|
|
it('does not remove in-progress items while history outputs are loading', () => {
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
|
store.onNodeExecuted(
|
|
'job-1',
|
|
makeExecutedDetail('job-1', [
|
|
{ filename: 'b.png', subfolder: '', type: 'output' }
|
|
])
|
|
)
|
|
store.onNodeExecuted(
|
|
'job-1',
|
|
makeExecutedDetail('job-1', [
|
|
{ filename: 'c.png', subfolder: '', type: 'output' }
|
|
])
|
|
)
|
|
store.onJobComplete('job-1')
|
|
|
|
const itemCount = store.inProgressItems.length
|
|
expect(itemCount).toBe(3)
|
|
|
|
// History asset arrived but allOutputs() returns [] (still loading).
|
|
// Caller passes false — items must stay visible to prevent a gap
|
|
// where neither in-progress nor history items are rendered.
|
|
store.resolveIfReady('job-1', false)
|
|
expect(store.inProgressItems).toHaveLength(itemCount)
|
|
|
|
// Once allOutputs() loads, caller passes true — safe to resolve
|
|
store.resolveIfReady('job-1', true)
|
|
expect(store.inProgressItems).toHaveLength(0)
|
|
})
|
|
|
|
it('discards latent previews for already-executed nodes', () => {
|
|
vi.useFakeTimers()
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
|
|
// Node 1 sends latent then executes
|
|
store.onLatentPreview('job-1', 'blob:node1-latent', '1')
|
|
vi.advanceTimersByTime(16)
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1', undefined, '1'))
|
|
|
|
// Stale latent for node 1 arrives after it already executed
|
|
store.onLatentPreview('job-1', 'blob:node1-stale', '1')
|
|
vi.advanceTimersByTime(16)
|
|
|
|
// Should not create a new latent item for the executed node
|
|
expect(
|
|
store.inProgressItems.filter((i) => i.state === 'latent')
|
|
).toHaveLength(0)
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('accepts latent previews for new nodes after prior node executed', () => {
|
|
vi.useFakeTimers()
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
|
|
// Node 1 executes
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1', undefined, '1'))
|
|
|
|
// Node 2 sends latent preview — should be accepted
|
|
store.onLatentPreview('job-1', 'blob:node2-latent', '2')
|
|
vi.advanceTimersByTime(16)
|
|
|
|
expect(
|
|
store.inProgressItems.filter((i) => i.state === 'latent')
|
|
).toHaveLength(1)
|
|
expect(store.inProgressItems[0].latentPreviewUrl).toBe('blob:node2-latent')
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('cancels pending RAF when a node executes', () => {
|
|
vi.useFakeTimers()
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
|
|
// Latent preview scheduled in RAF
|
|
store.onLatentPreview('job-1', 'blob:node1-latent')
|
|
// Node executes before RAF fires — should cancel it
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1', undefined, '1'))
|
|
vi.advanceTimersByTime(16)
|
|
|
|
// Only the image item, no latent
|
|
expect(
|
|
store.inProgressItems.filter((i) => i.state === 'latent')
|
|
).toHaveLength(0)
|
|
expect(
|
|
store.inProgressItems.filter((i) => i.state === 'image')
|
|
).toHaveLength(1)
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('discards latent previews arriving after job completion', () => {
|
|
vi.useFakeTimers()
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
|
|
|
// Latent preview scheduled in RAF before job completes
|
|
store.onLatentPreview('job-1', 'blob:late')
|
|
store.onJobComplete('job-1')
|
|
|
|
// RAF fires after completion — should be cancelled
|
|
vi.advanceTimersByTime(16)
|
|
|
|
// No new latent items should have been created
|
|
expect(
|
|
store.inProgressItems.filter((i) => i.state === 'latent')
|
|
).toHaveLength(0)
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('discards latent previews for completed job after RAF', () => {
|
|
vi.useFakeTimers()
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
|
store.onJobComplete('job-1')
|
|
|
|
// Late preview arrives after job already completed
|
|
store.onLatentPreview('job-1', 'blob:very-late')
|
|
vi.advanceTimersByTime(16)
|
|
|
|
expect(
|
|
store.inProgressItems.filter((i) => i.state === 'latent')
|
|
).toHaveLength(0)
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('ignores executed events for other jobs', () => {
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
|
|
store.onNodeExecuted('job-other', makeExecutedDetail('job-other'))
|
|
|
|
expect(store.inProgressItems.every((i) => i.state === 'skeleton')).toBe(
|
|
true
|
|
)
|
|
})
|
|
|
|
it('preserves in-progress items when leaving app mode', async () => {
|
|
const { nextTick } = await import('vue')
|
|
const store = useLinearOutputStore()
|
|
|
|
store.onJobStart('job-1')
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
|
store.select('slot:some-id')
|
|
expect(store.inProgressItems.length).toBeGreaterThan(0)
|
|
|
|
isAppModeRef.value = false
|
|
await nextTick()
|
|
|
|
expect(store.inProgressItems.length).toBeGreaterThan(0)
|
|
expect(store.selectedId).toBe('slot:some-id')
|
|
})
|
|
|
|
it('completes stale tracked job when re-entering app mode', async () => {
|
|
const { nextTick } = await import('vue')
|
|
const store = useLinearOutputStore()
|
|
|
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
|
activeJobIdRef.value = 'job-1'
|
|
await nextTick()
|
|
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
|
|
|
// Switch away — job finishes while we're gone
|
|
isAppModeRef.value = false
|
|
await nextTick()
|
|
activeJobIdRef.value = null
|
|
await nextTick()
|
|
|
|
// Switch back — store should reconcile the stale tracked job
|
|
isAppModeRef.value = true
|
|
await nextTick()
|
|
|
|
expect(store.pendingResolve.has('job-1')).toBe(true)
|
|
})
|
|
|
|
it('recovers latent preview when re-entering app mode', async () => {
|
|
vi.useFakeTimers()
|
|
const { nextTick } = await import('vue')
|
|
const store = useLinearOutputStore()
|
|
|
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
|
activeJobIdRef.value = 'job-1'
|
|
await nextTick()
|
|
|
|
// First node executes, consuming the skeleton
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
|
expect(store.inProgressItems[0].state).toBe('image')
|
|
|
|
// Switch away — latent preview arrives for next node while gone
|
|
isAppModeRef.value = false
|
|
await nextTick()
|
|
previewsRef.value = {
|
|
'job-1': { url: 'blob:preview-while-away', nodeId: 'node-2' }
|
|
}
|
|
await nextTick()
|
|
|
|
// Switch back — should recover the latent preview
|
|
isAppModeRef.value = true
|
|
await nextTick()
|
|
vi.advanceTimersByTime(16)
|
|
|
|
const latentItems = store.inProgressItems.filter(
|
|
(i) => i.state === 'latent'
|
|
)
|
|
expect(latentItems).toHaveLength(1)
|
|
expect(latentItems[0].latentPreviewUrl).toBe('blob:preview-while-away')
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('does not show in-progress items from another workflow', () => {
|
|
const store = useLinearOutputStore()
|
|
|
|
// Job-1 submitted from workflow-a
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
setJobWorkflowPath('job-1', 'workflows/app-a.json')
|
|
store.onJobStart('job-1')
|
|
|
|
// User switches to workflow-b: job-1 should NOT appear
|
|
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
|
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
|
|
|
|
// Back on workflow-a: job-1 should appear
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
expect(store.activeWorkflowInProgressItems).toHaveLength(1)
|
|
expect(store.activeWorkflowInProgressItems[0].jobId).toBe('job-1')
|
|
})
|
|
|
|
it('uses executionStore path map for workflow scoping', () => {
|
|
const store = useLinearOutputStore()
|
|
|
|
// Simulate storeJob populating executionStore.jobIdToSessionWorkflowPath
|
|
setJobWorkflowPath('job-1', 'workflows/app-a.json')
|
|
setJobWorkflowPath('job-2', 'workflows/app-a.json')
|
|
|
|
// User switches to workflow-b before execution starts
|
|
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
|
|
|
store.onJobStart('job-1')
|
|
store.onJobStart('job-2')
|
|
|
|
// On workflow-b: neither job should appear
|
|
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
|
|
|
|
// On workflow-a: both jobs should appear
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
expect(store.activeWorkflowInProgressItems).toHaveLength(2)
|
|
})
|
|
|
|
it('scopes in-progress items per workflow with concurrent jobs', () => {
|
|
vi.useFakeTimers()
|
|
const store = useLinearOutputStore()
|
|
|
|
// Job-1 on workflow-a (dog)
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
setJobWorkflowPath('job-1', 'workflows/app-a.json')
|
|
store.onJobStart('job-1')
|
|
store.onLatentPreview('job-1', 'blob:dog')
|
|
vi.advanceTimersByTime(16)
|
|
|
|
// User switches to workflow-b, runs job-2
|
|
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
|
setJobWorkflowPath('job-2', 'workflows/app-b.json')
|
|
|
|
// Job-1 finishes, job-2 starts
|
|
store.onJobComplete('job-1')
|
|
store.onJobStart('job-2')
|
|
store.onLatentPreview('job-2', 'blob:landscape')
|
|
vi.advanceTimersByTime(16)
|
|
|
|
// On workflow-b: should only see job-2 (landscape), NOT job-1 (dog)
|
|
const items = store.activeWorkflowInProgressItems
|
|
expect(items).toHaveLength(1)
|
|
expect(items[0].jobId).toBe('job-2')
|
|
expect(items[0].latentPreviewUrl).toBe('blob:landscape')
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('skips output items for nodes not in selectedOutputs', () => {
|
|
selectedOutputsRef.value = ['2']
|
|
const store = useLinearOutputStore()
|
|
store.onJobStart('job-1')
|
|
|
|
// Node 1 executes — not in selectedOutputs, should be skipped
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1', undefined, '1'))
|
|
|
|
// Skeleton should still be there (not consumed by non-output node)
|
|
expect(
|
|
store.inProgressItems.filter((i) => i.state === 'image')
|
|
).toHaveLength(0)
|
|
|
|
// Node 2 executes — in selectedOutputs, should create image item
|
|
store.onNodeExecuted(
|
|
'job-1',
|
|
makeExecutedDetail(
|
|
'job-1',
|
|
[{ filename: 'out.png', subfolder: '', type: 'output' }],
|
|
'2'
|
|
)
|
|
)
|
|
|
|
const imageItems = store.inProgressItems.filter((i) => i.state === 'image')
|
|
expect(imageItems).toHaveLength(1)
|
|
expect(imageItems[0].output?.nodeId).toBe('2')
|
|
})
|
|
|
|
it('does not auto-select for jobs belonging to another workflow', () => {
|
|
const store = useLinearOutputStore()
|
|
|
|
// User is on workflow-b, following latest
|
|
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
|
store.selectAsLatest('history:asset-b:0')
|
|
|
|
// Job from workflow-a starts
|
|
setJobWorkflowPath('job-1', 'workflows/app-a.json')
|
|
store.onJobStart('job-1')
|
|
|
|
// Should NOT yank selection to the other workflow's slot
|
|
expect(store.selectedId).toBe('history:asset-b:0')
|
|
})
|
|
|
|
it('auto-selects for jobs belonging to the active workflow', () => {
|
|
const store = useLinearOutputStore()
|
|
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
store.selectAsLatest('history:asset-a:0')
|
|
|
|
setJobWorkflowPath('job-1', 'workflows/app-a.json')
|
|
store.onJobStart('job-1')
|
|
|
|
// Should auto-select since job matches active workflow
|
|
expect(store.selectedId?.startsWith('slot:')).toBe(true)
|
|
})
|
|
|
|
it('ignores execution events when not in app mode', async () => {
|
|
const { nextTick } = await import('vue')
|
|
const store = useLinearOutputStore()
|
|
|
|
isAppModeRef.value = false
|
|
await nextTick()
|
|
|
|
// Watcher-driven job start should be ignored
|
|
activeJobIdRef.value = 'job-1'
|
|
await nextTick()
|
|
|
|
expect(store.inProgressItems).toHaveLength(0)
|
|
})
|
|
|
|
describe('workflow switching during generation', () => {
|
|
async function setup() {
|
|
vi.useFakeTimers()
|
|
const { nextTick } = await import('vue')
|
|
const store = useLinearOutputStore()
|
|
return { store, nextTick }
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('preserves images and latent previews across tab switch', async () => {
|
|
const { store, nextTick } = await setup()
|
|
|
|
// Workflow A: start job, produce 1 image + 1 latent
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
|
activeJobIdRef.value = 'job-a'
|
|
await nextTick()
|
|
|
|
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
|
store.onLatentPreview('job-a', 'blob:node2-latent', '2')
|
|
vi.advanceTimersByTime(16)
|
|
|
|
const imagesBefore = store.inProgressItems.filter(
|
|
(i) => i.state === 'image'
|
|
)
|
|
const latentsBefore = store.inProgressItems.filter(
|
|
(i) => i.state === 'latent'
|
|
)
|
|
expect(imagesBefore).toHaveLength(1)
|
|
expect(latentsBefore).toHaveLength(1)
|
|
|
|
// Switch to workflow B (graph mode)
|
|
activeWorkflowPathRef.value = 'workflows/graph-b.json'
|
|
isAppModeRef.value = false
|
|
await nextTick()
|
|
|
|
// Items still in store (not reset)
|
|
expect(store.inProgressItems.filter((i) => i.state === 'image')).toEqual(
|
|
imagesBefore
|
|
)
|
|
expect(store.inProgressItems.filter((i) => i.state === 'latent')).toEqual(
|
|
latentsBefore
|
|
)
|
|
|
|
// Switch back to workflow A
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
isAppModeRef.value = true
|
|
await nextTick()
|
|
vi.advanceTimersByTime(16)
|
|
|
|
// Items visible via activeWorkflowInProgressItems
|
|
expect(store.activeWorkflowInProgressItems).toHaveLength(2)
|
|
expect(
|
|
store.activeWorkflowInProgressItems.some((i) => i.state === 'image')
|
|
).toBe(true)
|
|
})
|
|
|
|
it('captures outputs produced while viewing another tab', async () => {
|
|
const { store, nextTick } = await setup()
|
|
|
|
// Workflow A: start job, produce 1 image
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
|
activeJobIdRef.value = 'job-a'
|
|
await nextTick()
|
|
|
|
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
|
expect(
|
|
store.inProgressItems.filter((i) => i.state === 'image')
|
|
).toHaveLength(1)
|
|
|
|
// Switch away
|
|
activeWorkflowPathRef.value = 'workflows/graph-b.json'
|
|
isAppModeRef.value = false
|
|
await nextTick()
|
|
|
|
// While away: node 2 executes (event missed — listener removed)
|
|
// Node 3 starts sending latent previews (watcher guarded)
|
|
previewsRef.value = {
|
|
'job-a': { url: 'blob:node3-latent', nodeId: '3' }
|
|
}
|
|
await nextTick()
|
|
|
|
// Switch back
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
isAppModeRef.value = true
|
|
await nextTick()
|
|
vi.advanceTimersByTime(16)
|
|
|
|
// Original image preserved + latent preview recovered
|
|
expect(
|
|
store.inProgressItems.filter((i) => i.state === 'image')
|
|
).toHaveLength(1)
|
|
expect(
|
|
store.inProgressItems.filter((i) => i.state === 'latent')
|
|
).toHaveLength(1)
|
|
expect(store.inProgressItems[0].latentPreviewUrl).toBe(
|
|
'blob:node3-latent'
|
|
)
|
|
})
|
|
|
|
it('scopes items to correct workflow after switching back', async () => {
|
|
const { store, nextTick } = await setup()
|
|
|
|
// Workflow A: start job
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
|
activeJobIdRef.value = 'job-a'
|
|
await nextTick()
|
|
|
|
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
|
|
|
// Switch to workflow B
|
|
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
|
|
|
// Workflow A items should NOT appear for workflow B
|
|
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
|
|
|
|
// Switch back to workflow A
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
|
|
// Workflow A items should reappear
|
|
expect(store.activeWorkflowInProgressItems).toHaveLength(1)
|
|
expect(store.activeWorkflowInProgressItems[0].jobId).toBe('job-a')
|
|
expect(store.activeWorkflowInProgressItems[0].state).toBe('image')
|
|
})
|
|
|
|
it('completes job A while away and starts tracking job B on return', async () => {
|
|
const { store, nextTick } = await setup()
|
|
|
|
// Workflow A: start and partially generate
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
|
activeJobIdRef.value = 'job-a'
|
|
await nextTick()
|
|
|
|
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
|
|
|
// Switch to workflow B (graph mode)
|
|
activeWorkflowPathRef.value = 'workflows/graph-b.json'
|
|
isAppModeRef.value = false
|
|
await nextTick()
|
|
|
|
// While away: job A finishes, job B starts
|
|
setJobWorkflowPath('job-b', 'workflows/app-a.json')
|
|
activeJobIdRef.value = 'job-b'
|
|
await nextTick()
|
|
|
|
// Switch back to workflow A
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
isAppModeRef.value = true
|
|
await nextTick()
|
|
|
|
// Job A should have been completed (pending resolve)
|
|
expect(store.pendingResolve.has('job-a')).toBe(true)
|
|
|
|
// Job B should have been started
|
|
expect(store.inProgressItems.some((i) => i.jobId === 'job-b')).toBe(true)
|
|
})
|
|
|
|
it('handles job finishing while away with no new job', async () => {
|
|
const { store, nextTick } = await setup()
|
|
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
|
activeJobIdRef.value = 'job-a'
|
|
await nextTick()
|
|
|
|
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
|
|
|
// Switch away
|
|
isAppModeRef.value = false
|
|
await nextTick()
|
|
|
|
// Job finishes, no new job
|
|
activeJobIdRef.value = null
|
|
await nextTick()
|
|
|
|
// Switch back
|
|
isAppModeRef.value = true
|
|
await nextTick()
|
|
|
|
// Job A completed, images pending resolve
|
|
expect(store.pendingResolve.has('job-a')).toBe(true)
|
|
// No new skeleton should have been created (no active job)
|
|
expect(
|
|
store.inProgressItems.filter((i) => i.state === 'skeleton')
|
|
).toHaveLength(0)
|
|
})
|
|
|
|
it('does not leak workflow A items into workflow B view', async () => {
|
|
const { store, nextTick } = await setup()
|
|
|
|
// Workflow A: two images
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
|
activeJobIdRef.value = 'job-a'
|
|
await nextTick()
|
|
|
|
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
|
store.onNodeExecuted(
|
|
'job-a',
|
|
makeExecutedDetail(
|
|
'job-a',
|
|
[{ filename: 'b.png', subfolder: '', type: 'output' }],
|
|
'2'
|
|
)
|
|
)
|
|
|
|
// Items exist in the global list
|
|
expect(
|
|
store.inProgressItems.filter((i) => i.state === 'image')
|
|
).toHaveLength(2)
|
|
|
|
// Switch to workflow B (also app mode)
|
|
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
|
|
|
// Workflow B should see nothing from job-a
|
|
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
|
|
|
|
// Global list still has them
|
|
expect(
|
|
store.inProgressItems.filter((i) => i.state === 'image')
|
|
).toHaveLength(2)
|
|
})
|
|
|
|
it('cleans up stale tracked job when leaving app mode after job finishes', async () => {
|
|
const { store, nextTick } = await setup()
|
|
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
|
activeJobIdRef.value = 'job-a'
|
|
await nextTick()
|
|
|
|
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
|
|
|
// Job finishes while still in app mode
|
|
store.onJobComplete('job-a')
|
|
expect(store.pendingResolve.has('job-a')).toBe(true)
|
|
|
|
// Now switch away — pendingResolve items stay (still-running case
|
|
// doesn't apply, but items are kept for history absorption)
|
|
activeJobIdRef.value = null
|
|
isAppModeRef.value = false
|
|
await nextTick()
|
|
|
|
// Items preserved (pendingResolve is not cleared on exit)
|
|
expect(store.inProgressItems.some((i) => i.jobId === 'job-a')).toBe(true)
|
|
})
|
|
|
|
it('evicts prior pendingResolve entries when a new job completes', () => {
|
|
vi.useFakeTimers()
|
|
const store = useLinearOutputStore()
|
|
|
|
// Job 1: produce image, complete
|
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
|
store.onJobStart('job-1')
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
|
store.onJobComplete('job-1')
|
|
|
|
expect(store.pendingResolve.has('job-1')).toBe(true)
|
|
expect(store.inProgressItems.some((i) => i.jobId === 'job-1')).toBe(true)
|
|
|
|
// Job 2: produce image, complete — should evict job-1
|
|
store.onJobStart('job-2')
|
|
store.onNodeExecuted('job-2', makeExecutedDetail('job-2'))
|
|
store.onJobComplete('job-2')
|
|
|
|
expect(store.pendingResolve.has('job-1')).toBe(false)
|
|
expect(store.inProgressItems.some((i) => i.jobId === 'job-1')).toBe(false)
|
|
// Job 2 is now pending resolve
|
|
expect(store.pendingResolve.has('job-2')).toBe(true)
|
|
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('cleans up finished tracked job on exit when job ended while in app mode', async () => {
|
|
const { store, nextTick } = await setup()
|
|
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
|
activeJobIdRef.value = 'job-a'
|
|
await nextTick()
|
|
|
|
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
|
|
|
// Job ends, new job starts — all while in app mode
|
|
setJobWorkflowPath('job-b', 'workflows/app-a.json')
|
|
activeJobIdRef.value = 'job-b'
|
|
await nextTick()
|
|
|
|
// job-a completed via activeJobId watcher, now pending resolve
|
|
expect(store.pendingResolve.has('job-a')).toBe(true)
|
|
|
|
// Switch away — tracked job is now job-b which is still active, so
|
|
// the else-branch does NOT complete it (it's still running)
|
|
isAppModeRef.value = false
|
|
await nextTick()
|
|
|
|
// job-b items preserved (still running)
|
|
expect(store.inProgressItems.some((i) => i.jobId === 'job-b')).toBe(true)
|
|
})
|
|
|
|
it('does not leak items across many job cycles', () => {
|
|
vi.useFakeTimers()
|
|
const store = useLinearOutputStore()
|
|
|
|
for (let i = 1; i <= 5; i++) {
|
|
const jobId = `job-${i}`
|
|
setJobWorkflowPath(jobId, 'workflows/test-workflow.json')
|
|
store.onJobStart(jobId)
|
|
store.onNodeExecuted(jobId, makeExecutedDetail(jobId))
|
|
store.onJobComplete(jobId)
|
|
}
|
|
|
|
// Only the last job should have items (pending resolve).
|
|
// All prior jobs were evicted by subsequent onJobComplete calls.
|
|
expect(store.pendingResolve.size).toBe(1)
|
|
expect(store.pendingResolve.has('job-5')).toBe(true)
|
|
expect(store.inProgressItems.every((i) => i.jobId === 'job-5')).toBe(true)
|
|
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('does not adopt another workflow job when switching back', async () => {
|
|
const { store, nextTick } = await setup()
|
|
|
|
// Tab A: queue "cat"
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
setJobWorkflowPath('job-cat', 'workflows/app-a.json')
|
|
activeJobIdRef.value = 'job-cat'
|
|
await nextTick()
|
|
|
|
store.onNodeExecuted(
|
|
'job-cat',
|
|
makeExecutedDetail('job-cat', undefined, '1')
|
|
)
|
|
expect(store.activeWorkflowInProgressItems).toHaveLength(1)
|
|
expect(store.activeWorkflowInProgressItems[0].jobId).toBe('job-cat')
|
|
|
|
// Switch to tab B (different workflow, also app mode): queue "dog"
|
|
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
|
isAppModeRef.value = false
|
|
await nextTick()
|
|
isAppModeRef.value = true
|
|
await nextTick()
|
|
|
|
setJobWorkflowPath('job-dog', 'workflows/app-b.json')
|
|
activeJobIdRef.value = 'job-dog'
|
|
await nextTick()
|
|
|
|
// Tab B should see dog, not cat
|
|
expect(
|
|
store.activeWorkflowInProgressItems.every((i) => i.jobId === 'job-dog')
|
|
).toBe(true)
|
|
|
|
// Switch back to tab A
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
isAppModeRef.value = false
|
|
await nextTick()
|
|
isAppModeRef.value = true
|
|
await nextTick()
|
|
|
|
// Dog's executed events arrive while viewing tab A (listener is
|
|
// active and activeJobId is still job-dog).
|
|
const event = new CustomEvent('executed', {
|
|
detail: makeExecutedDetail(
|
|
'job-dog',
|
|
[{ filename: 'dog.png', subfolder: '', type: 'output' }],
|
|
'1'
|
|
)
|
|
})
|
|
apiTarget.dispatchEvent(event)
|
|
|
|
// Dog's latent preview also arrives
|
|
previewsRef.value = {
|
|
'job-dog': { url: 'blob:dog-latent', nodeId: '2' }
|
|
}
|
|
await nextTick()
|
|
vi.advanceTimersByTime(16)
|
|
|
|
// Tab A must NOT show dog — only cat
|
|
const tabAItems = store.activeWorkflowInProgressItems
|
|
expect(tabAItems.every((i) => i.jobId === 'job-cat')).toBe(true)
|
|
expect(tabAItems.some((i) => i.jobId === 'job-dog')).toBe(false)
|
|
|
|
// Selection must not have been yanked to a dog item
|
|
expect(store.selectedId?.includes('job-dog') ?? false).toBe(false)
|
|
|
|
// Dog should still exist globally (scoped to tab B)
|
|
expect(store.inProgressItems.some((i) => i.jobId === 'job-dog')).toBe(
|
|
true
|
|
)
|
|
})
|
|
|
|
it('does not create skeleton for next job from another workflow', async () => {
|
|
const { store, nextTick } = await setup()
|
|
|
|
// Run dog on dog tab
|
|
activeWorkflowPathRef.value = 'workflows/dog.json'
|
|
setJobWorkflowPath('job-dog', 'workflows/dog.json')
|
|
activeJobIdRef.value = 'job-dog'
|
|
await nextTick()
|
|
|
|
// Swap to cat tab, queue cat (dog still running)
|
|
activeWorkflowPathRef.value = 'workflows/cat.json'
|
|
isAppModeRef.value = false
|
|
await nextTick()
|
|
isAppModeRef.value = true
|
|
await nextTick()
|
|
setJobWorkflowPath('job-cat', 'workflows/cat.json')
|
|
|
|
// Swap back to dog tab
|
|
activeWorkflowPathRef.value = 'workflows/dog.json'
|
|
isAppModeRef.value = false
|
|
await nextTick()
|
|
isAppModeRef.value = true
|
|
await nextTick()
|
|
|
|
// Dog finishes, cat starts (activeJobId transitions on dog tab)
|
|
activeJobIdRef.value = 'job-cat'
|
|
await nextTick()
|
|
|
|
// Dog tab must NOT show cat's skeleton
|
|
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
|
|
// No skeleton for cat should have been created at all
|
|
expect(
|
|
store.inProgressItems.some(
|
|
(i) => i.jobId === 'job-cat' && i.state === 'skeleton'
|
|
)
|
|
).toBe(false)
|
|
})
|
|
|
|
it('processes new executed events after switching back', async () => {
|
|
const { store, nextTick } = await setup()
|
|
|
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
|
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
|
activeJobIdRef.value = 'job-a'
|
|
await nextTick()
|
|
|
|
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
|
|
|
// Switch away and back
|
|
isAppModeRef.value = false
|
|
await nextTick()
|
|
isAppModeRef.value = true
|
|
await nextTick()
|
|
|
|
// Fire an executed event via the API — listener should be re-attached
|
|
const event = new CustomEvent('executed', {
|
|
detail: makeExecutedDetail(
|
|
'job-a',
|
|
[{ filename: 'c.png', subfolder: '', type: 'output' }],
|
|
'3'
|
|
)
|
|
})
|
|
apiTarget.dispatchEvent(event)
|
|
|
|
expect(
|
|
store.inProgressItems.filter((i) => i.state === 'image')
|
|
).toHaveLength(2)
|
|
})
|
|
})
|
|
|
|
describe('deferred path mapping (WebSocket/HTTP race)', () => {
|
|
it('starts tracking when path mapping arrives after activeJobId', async () => {
|
|
const { nextTick } = await import('vue')
|
|
const store = useLinearOutputStore()
|
|
|
|
// activeJobId set before path mapping exists (WebSocket race)
|
|
activeJobIdRef.value = 'job-1'
|
|
await nextTick()
|
|
|
|
// No skeleton yet — path mapping is missing
|
|
expect(store.inProgressItems).toHaveLength(0)
|
|
|
|
// Path mapping arrives (HTTP response from queuePrompt)
|
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
|
await nextTick()
|
|
|
|
// Now onJobStart should have fired
|
|
expect(store.inProgressItems).toHaveLength(1)
|
|
expect(store.inProgressItems[0].state).toBe('skeleton')
|
|
expect(store.inProgressItems[0].jobId).toBe('job-1')
|
|
})
|
|
|
|
it('processes executed events after deferred start', async () => {
|
|
const { nextTick } = await import('vue')
|
|
const store = useLinearOutputStore()
|
|
|
|
activeJobIdRef.value = 'job-1'
|
|
await nextTick()
|
|
|
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
|
await nextTick()
|
|
|
|
// Executed event arrives — should create an image item
|
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
|
|
|
const imageItems = store.inProgressItems.filter(
|
|
(i) => i.state === 'image'
|
|
)
|
|
expect(imageItems).toHaveLength(1)
|
|
})
|
|
|
|
it('does not double-start if path mapping is already available', async () => {
|
|
const { nextTick } = await import('vue')
|
|
const store = useLinearOutputStore()
|
|
|
|
// Path mapping set before activeJobId (normal case, no race)
|
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
|
activeJobIdRef.value = 'job-1'
|
|
await nextTick()
|
|
|
|
expect(store.inProgressItems).toHaveLength(1)
|
|
|
|
// Trigger path mapping update again — should not create a second skeleton
|
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
|
await nextTick()
|
|
|
|
expect(store.inProgressItems).toHaveLength(1)
|
|
})
|
|
|
|
it('ignores deferred mapping if activeJobId changed', async () => {
|
|
const { nextTick } = await import('vue')
|
|
const store = useLinearOutputStore()
|
|
|
|
activeJobIdRef.value = 'job-1'
|
|
await nextTick()
|
|
|
|
// Job changes before path mapping arrives
|
|
activeJobIdRef.value = null
|
|
await nextTick()
|
|
|
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
|
await nextTick()
|
|
|
|
// Should not have started job-1
|
|
expect(store.inProgressItems).toHaveLength(0)
|
|
})
|
|
|
|
it('ignores deferred mapping for a different workflow', async () => {
|
|
const { nextTick } = await import('vue')
|
|
const store = useLinearOutputStore()
|
|
|
|
activeJobIdRef.value = 'job-1'
|
|
await nextTick()
|
|
|
|
// Path maps to a different workflow than the active one
|
|
setJobWorkflowPath('job-1', 'workflows/other-workflow.json')
|
|
await nextTick()
|
|
|
|
expect(store.inProgressItems).toHaveLength(0)
|
|
})
|
|
})
|
|
})
|