Compare commits

...

2 Commits

Author SHA1 Message Date
dante01yoon
78ff1de512 fix: FE-844 drop text field from zOutputs runtime schema 2026-05-25 12:51:52 +09:00
dante01yoon
7b7c8ec1ce test: FE-844 add failing test for text:[null] in fetchJobDetail 2026-05-25 12:49:05 +09:00
2 changed files with 44 additions and 3 deletions

View File

@@ -318,6 +318,38 @@ describe('fetchJobs', () => {
expect(result).toBeUndefined()
})
it('FE-844: tolerates text:[null] from subgraph empty text outputs', async () => {
// Cloud serializes empty text outputs of subgraph promoted nodes as
// `text: [null]` (node IDs use `parent:child` form). The previous
// schema declared `text` as `string | string[]`, so the union failed
// on the null element and the entire job-detail response was dropped,
// collapsing expanded folder view to the cover preview only.
// parseTaskOutput already filters `text` via METADATA_KEYS, so the
// runtime never needed this field in zOutputs.
const jobDetail = {
...createMockJob('job1', 'completed'),
workflow: { extra_data: { extra_pnginfo: { workflow: {} } } },
outputs: {
'1': {
images: [{ filename: 'cover.png', subfolder: '', type: 'output' }]
},
'165:160': { text: [null] },
'166:161': { text: [null] }
}
}
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(jobDetail)
})
const result = await fetchJobDetail(mockFetch, 'job1')
expect(result).toBeDefined()
expect(result?.outputs).toBeDefined()
expect(result?.outputs?.['1']).toBeDefined()
expect(result?.outputs?.['165:160']).toBeDefined()
})
})
describe('extractWorkflow', () => {

View File

@@ -25,17 +25,26 @@ export const zResultItem = z.object({
export type ResultItem = z.infer<typeof zResultItem>
// Uses .passthrough() because custom nodes can output arbitrary keys.
// See docs/adr/0007-node-execution-output-passthrough-schema.md
//
// `text` is intentionally not declared in the runtime schema. Cloud
// `/api/jobs/{id}` serializes empty subgraph text outputs as `[null]`,
// which a strict `string | string[]` union rejects — collapsing the whole
// job-detail response (FE-844). Downstream parseTaskOutput already filters
// `text` via METADATA_KEYS, so the field has no runtime consumer in the
// asset-resolution path. We retain the static typing for custom node
// `onExecuted` ergonomics via the intersection on NodeExecutionOutput.
const zOutputs = z
.object({
audio: z.array(zResultItem).optional(),
images: z.array(zResultItem).optional(),
video: z.array(zResultItem).optional(),
animated: z.array(z.boolean()).optional(),
text: z.union([z.string(), z.array(z.string())]).optional()
animated: z.array(z.boolean()).optional()
})
.passthrough()
export type NodeExecutionOutput = z.infer<typeof zOutputs>
export type NodeExecutionOutput = z.infer<typeof zOutputs> & {
text?: string | string[]
}
export type NodeOutputWith<T extends Record<string, unknown>> =
NodeExecutionOutput & T