fix: make zPreviewOutput accept text-only job outputs (#9724)

## 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)
This commit is contained in:
Luke Mino-Altherr
2026-03-10 18:18:54 -07:00
committed by GitHub
parent dc3e455993
commit 9ecb100d11
4 changed files with 108 additions and 16 deletions

View File

@@ -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', () => {

View File

@@ -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.

View File

@@ -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', () => {

View File

@@ -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')