From 9ecb100d111ddaa726d98d5f1c4b2e410daf4362 Mon Sep 17 00:00:00 2001 From: Luke Mino-Altherr Date: Tue, 10 Mar 2026 18:18:54 -0700 Subject: [PATCH] fix: make zPreviewOutput accept text-only job outputs (#9724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes Zod validation crash when the jobs batch contains text-only preview outputs (e.g. from LLM nodes), which caused the entire Assets sidebar to show nothing. ## Changes - **What**: Made `filename`, `subfolder`, and `type` optional in `zPreviewOutput` and added `.passthrough()` for extra fields like `content`. Text-only jobs are safely filtered out downstream by `supportsPreview`. - Added tests in `fetchJobs.test.ts` verifying a mixed batch (image + text-only + no-preview) parses successfully. - Added test in `assetsStore.test.ts` verifying text-only jobs are skipped without breaking sibling image jobs. Improved `TaskItemImpl` mock to realistically handle media types. ## Review Focus - The `zPreviewOutput` schema now uses `.passthrough()` to allow extra fields from new preview types (like `content` on text previews). This is consistent with `zRawJobListItem` and `zExecutionError` which also use `.passthrough()`. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9724-fix-make-zPreviewOutput-accept-text-only-job-outputs-31f6d73d36508119a7aef99f9b765ecd) by [Unito](https://www.unito.io) --- .../remote/comfyui/jobs/fetchJobs.test.ts | 35 ++++++++++++ src/platform/remote/comfyui/jobs/jobTypes.ts | 18 ++++--- src/stores/assetsStore.test.ts | 54 ++++++++++++++++--- src/stores/queueStore.test.ts | 17 ++++++ 4 files changed, 108 insertions(+), 16 deletions(-) diff --git a/src/platform/remote/comfyui/jobs/fetchJobs.test.ts b/src/platform/remote/comfyui/jobs/fetchJobs.test.ts index ec32cb3b38..41b01606e2 100644 --- a/src/platform/remote/comfyui/jobs/fetchJobs.test.ts +++ b/src/platform/remote/comfyui/jobs/fetchJobs.test.ts @@ -150,6 +150,41 @@ describe('fetchJobs', () => { expect(result).toEqual([]) }) + + it('parses batch containing text-only preview outputs', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve( + createMockResponse([ + createMockJob('image-job', 'completed', { + preview_output: { + filename: 'output.png', + subfolder: '', + type: 'output', + nodeId: '1', + mediaType: 'images' + } + }), + createMockJob('text-job', 'completed', { + preview_output: { + content: 'some generated text', + nodeId: '5', + mediaType: 'text' + } + }), + createMockJob('no-preview-job', 'completed') + ]) + ) + }) + + const result = await fetchHistory(mockFetch) + + expect(result).toHaveLength(3) + expect(result[0].id).toBe('image-job') + expect(result[1].id).toBe('text-job') + expect(result[2].id).toBe('no-preview-job') + }) }) describe('fetchQueue', () => { diff --git a/src/platform/remote/comfyui/jobs/jobTypes.ts b/src/platform/remote/comfyui/jobs/jobTypes.ts index 5fb9236238..5704fba491 100644 --- a/src/platform/remote/comfyui/jobs/jobTypes.ts +++ b/src/platform/remote/comfyui/jobs/jobTypes.ts @@ -18,14 +18,16 @@ const zJobStatus = z.enum([ 'cancelled' ]) -const zPreviewOutput = z.object({ - filename: z.string(), - subfolder: z.string(), - type: resultItemType, - nodeId: z.string(), - mediaType: z.string(), - display_name: z.string().optional() -}) +const zPreviewOutput = z + .object({ + filename: z.string().optional(), + subfolder: z.string().optional(), + type: resultItemType.optional(), + nodeId: z.string(), + mediaType: z.string(), + display_name: z.string().optional() + }) + .passthrough() /** * Execution error from Jobs API. diff --git a/src/stores/assetsStore.test.ts b/src/stores/assetsStore.test.ts index 7fbfc102cf..dfce660207 100644 --- a/src/stores/assetsStore.test.ts +++ b/src/stores/assetsStore.test.ts @@ -72,6 +72,8 @@ vi.mock('@/stores/modelToNodeStore', () => ({ })) // Mock TaskItemImpl +const PREVIEWABLE_MEDIA_TYPES = new Set(['images', 'video', 'audio']) + vi.mock('@/stores/queueStore', () => ({ TaskItemImpl: class { public flatOutputs: Array<{ @@ -91,19 +93,28 @@ vi.mock('@/stores/queueStore', () => ({ } | undefined public jobId: string + public outputsCount: number | null constructor(public job: JobListItem) { this.jobId = job.id - this.flatOutputs = [ - { + this.outputsCount = job.outputs_count ?? null + const preview = job.preview_output + const isPreviewable = + !!preview?.filename && PREVIEWABLE_MEDIA_TYPES.has(preview.mediaType) + if (preview && isPreviewable) { + const item = { supportsPreview: true, - filename: 'test.png', - subfolder: '', - type: 'output', - url: 'http://test.com/test.png' + filename: preview.filename!, + subfolder: preview.subfolder ?? '', + type: preview.type ?? 'output', + url: `http://test.com/${preview.filename}` } - ] - this.previewOutput = this.flatOutputs[0] + this.flatOutputs = [item] + this.previewOutput = item + } else { + this.flatOutputs = [] + this.previewOutput = undefined + } } get previewableOutputs() { @@ -200,6 +211,33 @@ describe('assetsStore - Refactored (Option A)', () => { expect(store.historyError).toBe(error) expect(store.historyLoading).toBe(false) }) + + it('should skip text-only jobs without breaking sibling image jobs', async () => { + const mockHistory: JobListItem[] = [ + createMockJobItem(0), + { + id: 'text-only-job', + status: 'completed', + create_time: 2000, + priority: 2000, + preview_output: { + content: 'some generated text', + nodeId: '5', + mediaType: 'text' + } satisfies JobListItem['preview_output'] + }, + createMockJobItem(2) + ] + vi.mocked(api.getHistory).mockResolvedValue(mockHistory) + + await store.updateHistory() + + expect(store.historyAssets).toHaveLength(2) + expect(store.historyAssets.map((a) => a.id)).toEqual([ + 'prompt_0', + 'prompt_2' + ]) + }) }) describe('Pagination', () => { diff --git a/src/stores/queueStore.test.ts b/src/stores/queueStore.test.ts index b0c819a42f..3d0a4ad284 100644 --- a/src/stores/queueStore.test.ts +++ b/src/stores/queueStore.test.ts @@ -191,6 +191,23 @@ describe('TaskItemImpl', () => { }) }) + it('should produce no previewable outputs for text-only preview_output', () => { + const job: JobListItem = { + ...createHistoryJob(0, 'text-job'), + preview_output: { + nodeId: '5', + mediaType: 'text' + } satisfies JobListItem['preview_output'] + } + + const task = new TaskItemImpl(job) + + expect(task.flatOutputs).toHaveLength(1) + expect(task.flatOutputs[0].filename).toBe('') + expect(task.previewableOutputs).toHaveLength(0) + expect(task.previewOutput).toBeUndefined() + }) + describe('error extraction getters', () => { it('errorMessage returns undefined when no execution_error', () => { const job = createHistoryJob(0, 'job-id')