import { effectScope } from 'vue' import { describe, expect, it, vi } from 'vitest' import { useGeneratedAnalysis } from '../../composables/useGeneratedAnalysis' import { MintApiError } from '../../apiErrors' import { createGeneratedAnalysisTransport, type GeneratedAnalysisTransport, } from '../../composables/useGeneratedAnalysis' import type { PluginJobCenterSource } from '../../composables/useJobsStatusTray' import type { GeneratedAnalysisJob, GeneratedAnalysisManifest, } from '../../types/generated-ui' const manifest: GeneratedAnalysisManifest = { schema_version: '1', plugin: { name: 'Demo', version: '1.0.0', description: 'Demo plugin', routes_prefix: '/demo', }, ui: { title: 'Demo analysis', description: 'Run it' }, jobs: [], } function response(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }) } function eventStream(...frames: string[]): Response { const encoder = new TextEncoder() return new Response(new ReadableStream({ start(controller) { for (const frame of frames) controller.enqueue(encoder.encode(frame)) controller.close() }, }), { headers: { 'Content-Type': 'text/event-stream' }, }) } function deferred(): { promise: Promise resolve: (value: T) => void } { let resolve!: (value: T) => void const promise = new Promise((next) => { resolve = next }) return { promise, resolve } } function memoryStorage(): Storage { const values = new Map() return { get length() { return values.size }, clear: () => values.clear(), getItem: key => values.get(key) ?? null, key: index => [...values.keys()][index] ?? null, removeItem: key => values.delete(key), setItem: (key, value) => values.set(key, value), } } function completedJob(jobId: string): GeneratedAnalysisJob { return { id: jobId, job_id: jobId, kind: 'analysis', name: 'Run analysis', status: 'completed', progress: { current_file: 0, total_files: 0, current_filename: null, percent: 100, message: '', stage: 'completed', }, queue_position: 0, warnings: [], error: null, output_path: null, save_path: null, session_id: 'session-1', folders: [], folder_path: null, user_id: 'user-1', owner_user_id: 'user-1', experiment_id: null, extraction_result_id: null, metadata: { definition_id: 'run', has_result: true }, created_at: '', started_at: '', completed_at: '', definition_id: 'run', can_cancel: false, can_delete: true, result_status: 'available', result_expires_at: null, } } function activeJob( jobId: string, status: 'queued' | 'running' = 'queued', ): GeneratedAnalysisJob { return { ...completedJob(jobId), status, progress: { current_file: 0, total_files: 0, current_filename: null, percent: 0, message: status === 'queued' ? 'Queued' : 'Running', stage: status, }, completed_at: null, can_cancel: true, can_delete: false, result_status: 'none', metadata: { definition_id: 'run', has_result: false }, } } function streamingJobTransport(): ReturnType> { return vi.fn(async (input, init) => { const url = String(input) if (url.endsWith('/generated/manifest')) return response(manifest) if (url.endsWith('/jobs/sessions') && init?.method === 'GET') { return response({ sessions: [] }) } if (url.endsWith('/jobs/sessions') && init?.method === 'POST') { return response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], }) } if (url.endsWith('/jobs/events')) return eventStream() if (url.endsWith('/jobs')) return response({ jobs: [activeJob('job-1')] }) throw new Error(`Unexpected request: ${url}`) }) } describe('useGeneratedAnalysis', () => { it('is directly usable as a job center source', () => { const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport: vi.fn(), storage: memoryStorage(), stream: false, }))! const source: PluginJobCenterSource = generated expect(source.jobs).toBe(generated.jobs) scope.stop() }) it('should report an immediate initialization failure without an unhandled rejection', async () => { const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport: vi.fn() .mockRejectedValue(new TypeError('offline')), storage: memoryStorage(), immediate: true, stream: false, }))! try { await vi.waitFor(() => expect(generated.status.value).toBe('error')) expect(generated.error.value).toBe('offline') } finally { scope.stop() } }) it('should initialize with page-local session state when browser storage is unavailable', async () => { const unavailableStorage = { get length(): number { throw new DOMException('Blocked', 'SecurityError') }, clear: () => { throw new DOMException('Blocked', 'SecurityError') }, getItem: () => { throw new DOMException('Blocked', 'SecurityError') }, key: () => { throw new DOMException('Blocked', 'SecurityError') }, removeItem: () => { throw new DOMException('Blocked', 'SecurityError') }, setItem: () => { throw new DOMException('Blocked', 'SecurityError') }, } satisfies Storage const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [] })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: unavailableStorage, createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() expect(generated.session.value?.id).toBe('session-1') } finally { scope.stop() } }) it('should add the current bearer token to JSON and event-stream requests', async () => { const fetcher = vi.fn().mockResolvedValue(response({ ok: true })) const transport = createGeneratedAnalysisTransport({ fetcher, getAccessToken: () => 'viewer-token', }) await transport('/api/demo/generated/manifest', { headers: { Accept: 'application/json' }, }) await transport('/api/demo/jobs/events', { headers: { Accept: 'text/event-stream' }, }) expect(fetcher.mock.calls.map(call => { const headers = new Headers(call[1]?.headers) return [headers.get('Accept'), headers.get('Authorization')] })).toEqual([ ['application/json', 'Bearer viewer-token'], ['text/event-stream', 'Bearer viewer-token'], ]) }) it('should consume job updates from an authenticated fetch stream without EventSource', async () => { const storage = memoryStorage() const runningJob = activeJob('job-1', 'running') const completedJob = { ...runningJob, status: 'completed', completed_at: '', can_cancel: false, can_delete: true, result_status: 'available', metadata: { definition_id: 'run', has_result: true }, } const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [runningJob] })) .mockResolvedValueOnce(eventStream( `event: snapshot\ndata: ${JSON.stringify({ jobs: [runningJob] })}\n\n`, `event: terminal\ndata: ${JSON.stringify(completedJob)}\n\n`, )) .mockResolvedValueOnce(response({ kind: 'text', text: 'done' })) const eventSource = vi.fn(() => { throw new Error('Generated job streams must not use EventSource') }) vi.stubGlobal('EventSource', eventSource) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage, createId: () => 'tab-1', streamReconnectDelay: 60_000, }))! try { await generated.initialize() await vi.waitFor(() => expect(generated.jobs.value[0]?.status).toBe('completed')) expect(transport.mock.calls.some(call => String(call[0]).endsWith('/result'))).toBe(false) await generated.loadResult(generated.jobs.value[0]!) const streamCall = transport.mock.calls.find(call => String(call[0]).endsWith('/jobs/events')) const resultCall = transport.mock.calls.find(call => String(call[0]).endsWith('/jobs/job-1/result')) expect({ eventSourceCalls: eventSource.mock.calls.length, accept: new Headers(streamCall?.[1]?.headers).get('Accept'), signal: streamCall?.[1]?.signal instanceof AbortSignal, resultMethod: resultCall?.[1]?.method, result: generated.latestResult.value, }).toEqual({ eventSourceCalls: 0, accept: 'text/event-stream', signal: true, resultMethod: 'GET', result: { kind: 'text', text: 'done' }, }) } finally { scope.stop() vi.unstubAllGlobals() } }) it('should poll active jobs while a closed stream waits to reconnect', async () => { vi.useFakeTimers() const transport = streamingJobTransport() const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', pollInterval: 5, streamReconnectDelay: 1_000, }))! try { await generated.initialize() await vi.advanceTimersByTimeAsync(5) expect(transport.mock.calls.filter(([input]) => String(input).endsWith('/jobs'))).toHaveLength(2) } finally { generated.dispose() scope.stop() vi.useRealTimers() } }) it('should not poll or reconnect a paused job stream', async () => { vi.useFakeTimers() const transport = streamingJobTransport() const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', pollInterval: 5, streamReconnectDelay: 10, }))! try { await generated.initialize() await vi.advanceTimersByTimeAsync(0) generated.pause() await vi.advanceTimersByTimeAsync(20) expect({ jobLists: transport.mock.calls.filter(([input]) => String(input).endsWith('/jobs')).length, streams: transport.mock.calls.filter(([input]) => String(input).endsWith('/jobs/events')).length, }).toEqual({ jobLists: 1, streams: 1 }) } finally { generated.dispose() scope.stop() vi.useRealTimers() } }) it('should not poll or reconnect a disposed job stream', async () => { vi.useFakeTimers() const transport = streamingJobTransport() const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', pollInterval: 5, streamReconnectDelay: 10, }))! await generated.initialize() await vi.advanceTimersByTimeAsync(0) generated.dispose() await vi.advanceTimersByTimeAsync(20) expect({ jobLists: transport.mock.calls.filter(([input]) => String(input).endsWith('/jobs')).length, streams: transport.mock.calls.filter(([input]) => String(input).endsWith('/jobs/events')).length, }).toEqual({ jobLists: 1, streams: 1 }) scope.stop() vi.useRealTimers() }) it('does not let a late submit response regress a terminal stream update', async () => { const terminal = completedJob('fast-service-job') const lateQueued = activeJob('fast-service-job') const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [] })) .mockResolvedValueOnce(eventStream( `event: terminal\ndata: ${JSON.stringify(terminal)}\n\n`, )) .mockResolvedValueOnce(response(lateQueued)) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', streamReconnectDelay: 60_000, }))! try { await generated.initialize() await vi.waitFor(() => expect(generated.jobs.value[0]?.status).toBe('completed')) const submitted = await generated.submit('run', {}) expect({ submitted: submitted.status, stored: generated.jobs.value[0]?.status, }).toEqual({ submitted: 'completed', stored: 'completed' }) } finally { scope.stop() } }) it('creates one server session for the current sessionStorage tab and exposes results', async () => { const storage = memoryStorage() const fetcher = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [] })) .mockResolvedValueOnce(response(activeJob('job-1'))) .mockResolvedValueOnce(response({ jobs: [completedJob('job-1')], })) .mockResolvedValueOnce(response({ kind: 'text', text: 'done' })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport: fetcher, storage, createId: () => 'tab-1', pollInterval: 60_000, stream: false, }))! await generated.initialize() await generated.submit('run', { threshold: 2.5 }) await generated.refreshJobs() await generated.loadResult(generated.jobs.value[0]!) scope.stop() expect({ session: generated.session.value?.id, result: generated.latestResult.value, resultRequest: fetcher.mock.calls[6]?.[0], storedTab: storage.getItem('mint:generated:/api/demo:tab'), submitted: JSON.parse(String(fetcher.mock.calls[4]?.[1]?.body)), }).toEqual({ session: 'session-1', result: { kind: 'text', text: 'done' }, resultRequest: '/api/demo/jobs/job-1/result', storedTab: 'tab-1', submitted: { definition_id: 'run', inputs: { threshold: 2.5 }, session_id: 'session-1', client_request_id: expect.any(String), }, }) }) it('should stage a selected path before submitting its opaque token', async () => { const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [] })) .mockResolvedValueOnce(response({ token: 'upload-token', kind: 'file', name: 'data.csv', size: 4 })) .mockResolvedValueOnce(response(activeJob('job-1'))) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() await generated.submit('run', { source: { kind: 'path', files: [new File(['data'], 'data.csv')], relativePaths: ['data.csv'], }, }) const upload = transport.mock.calls[4] const form = upload?.[1]?.body as FormData const submit = JSON.parse(String(transport.mock.calls[5]?.[1]?.body)) expect({ uploadUrl: upload?.[0], uploadMethod: upload?.[1]?.method, uploadSessionHeader: new Headers(upload?.[1]?.headers).get('X-Mint-Job-Session'), uploadDefinitionHeader: new Headers(upload?.[1]?.headers).get('X-Mint-Job-Definition'), sessionId: form.get('session_id'), relativePaths: form.getAll('relative_paths'), submittedSource: submit.inputs.source, }).toEqual({ uploadUrl: '/api/demo/jobs/uploads', uploadMethod: 'POST', uploadSessionHeader: 'session-1', uploadDefinitionHeader: 'run', sessionId: 'session-1', relativePaths: ['data.csv'], submittedSource: { token: 'upload-token' }, }) } finally { scope.stop() } }) it('should stage an artifact path by id only when submitting', async () => { const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [] })) .mockResolvedValueOnce(response({ token: 'artifact-token', kind: 'file', name: 'matrix.csv', size: 4 })) .mockResolvedValueOnce(response(activeJob('job-1'))) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() await generated.submit('run', { source: { kind: 'artifact-path', experimentId: 42, artifactId: 7, name: 'matrix.csv', }, }) const stage = transport.mock.calls[4] const submit = JSON.parse(String(transport.mock.calls[5]?.[1]?.body)) expect({ stageUrl: stage?.[0], stageMethod: stage?.[1]?.method, stageSessionHeader: new Headers(stage?.[1]?.headers).get('X-Mint-Job-Session'), stageDefinitionHeader: new Headers(stage?.[1]?.headers).get('X-Mint-Job-Definition'), stageBody: JSON.parse(String(stage?.[1]?.body)), submittedSource: submit.inputs.source, }).toEqual({ stageUrl: '/api/demo/jobs/artifacts', stageMethod: 'POST', stageSessionHeader: 'session-1', stageDefinitionHeader: 'run', stageBody: { experiment_id: 42, artifact_id: 7, session_id: 'session-1' }, submittedSource: { token: 'artifact-token' }, }) } finally { scope.stop() } }) it('uploads, inspects, and seals staged job parts through the canonical runtime', async () => { const stagingSummary = { job_id: 'staged/job', sealed: false, part_count: 1, total_bytes: 3, } const part = { key: 'sample one/chunk#1', size: 3, sha256: 'a'.repeat(64), metadata: { sampleIndex: 1 }, } const staging = { ...stagingSummary, parts: [part], offset: 0, limit: 10, next_offset: null, has_more: false, } const sealedJob = { ...activeJob('staged/job'), definition_id: 'client-xic', metadata: { definition_id: 'client-xic', has_result: false }, } const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [] })) .mockResolvedValueOnce(response({ part, staging: stagingSummary })) .mockResolvedValueOnce(response(staging)) .mockResolvedValueOnce(response(sealedJob)) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() const uploaded = await generated.uploadStagedPart({ job: 'staged/job', key: 'sample one/chunk#1', file: new Blob(['xic']), metadata: { sampleIndex: 1 }, expectedSize: 3, expectedSha256: 'a'.repeat(64), }) const inspected = await generated.getJobStaging<{ sampleIndex: number }>( 'staged/job', { offset: 0, limit: 10 }, ) const sealed = await generated.sealJob('staged/job') const upload = transport.mock.calls[4] const body = upload?.[1]?.body as FormData expect({ uploadUrl: upload?.[0], uploadMethod: upload?.[1]?.method, file: body.get('file'), metadata: body.get('metadata'), expectedSize: body.get('expected_size'), expectedSha256: body.get('expected_sha256'), uploadedMetadata: uploaded.part.metadata, inspectedMetadata: inspected.parts[0]?.metadata, stagingUrl: transport.mock.calls[5]?.[0], sealRequest: transport.mock.calls[6], sealedDefinition: sealed.definition_id, jobs: generated.jobs.value.map(job => job.job_id), }).toEqual({ uploadUrl: '/api/demo/jobs/staged%2Fjob/parts/sample%20one/chunk%231', uploadMethod: 'PUT', file: expect.any(Blob), metadata: '{"sampleIndex":1}', expectedSize: '3', expectedSha256: 'a'.repeat(64), uploadedMetadata: { sampleIndex: 1 }, inspectedMetadata: { sampleIndex: 1 }, stagingUrl: '/api/demo/jobs/staged%2Fjob/staging?offset=0&limit=10', sealRequest: [ '/api/demo/jobs/staged%2Fjob/seal', { method: 'POST', headers: { Accept: 'application/json' } }, ], sealedDefinition: 'client-xic', jobs: ['staged/job'], }) } finally { scope.stop() } }) it('keeps a forward seal response after an awaiting-input snapshot bumps its revision', async () => { const awaiting = { ...activeJob('seal-race'), status: 'awaiting_input' as const, progress: { ...activeJob('seal-race').progress, message: 'Awaiting input', stage: 'awaiting_input', }, } const queued = activeJob('seal-race') const sealRequest = deferred() const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [awaiting] })) .mockReturnValueOnce(sealRequest.promise) .mockResolvedValueOnce(response({ jobs: [awaiting] })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() const sealing = generated.sealJob('seal-race') await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(5)) await generated.refreshJobs() sealRequest.resolve(response(queued)) await expect(sealing).resolves.toMatchObject({ status: 'queued' }) expect(generated.jobs.value[0]?.status).toBe('queued') } finally { scope.stop() } }) it('reuses a live server session after a tab reload', async () => { const storage = memoryStorage() storage.setItem('mint:generated:/api/demo:tab', 'tab-1') storage.setItem('mint:generated:/api/demo:session', 'session-1') const fetcher = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [{ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], }], })) .mockResolvedValueOnce(response({ jobs: [] })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport: fetcher, storage, createId: () => 'new-tab', stream: false, }))! await generated.initialize() scope.stop() expect(fetcher.mock.calls.map(call => [call[0], call[1]?.method])).toEqual([ ['/api/demo/generated/manifest', 'GET'], ['/api/demo/jobs/sessions', 'GET'], ['/api/demo/jobs', 'GET'], ]) }) it.each([ { detail: 'Session not found' }, { code: 'resource.not_found', message: 'Session not found', request_id: 'session-request' }, ])('should recreate an expired session and retry one job with the same request id (%j)', async (errorBody) => { const storage = memoryStorage() const createId = vi.fn() .mockReturnValueOnce('tab-1') .mockReturnValueOnce('request-1') const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [] })) .mockResolvedValueOnce(response(errorBody, 404)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-2', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ ...activeJob('job-1'), session_id: 'session-2' })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage, createId, stream: false, }))! try { await generated.initialize() const job = await generated.submit('run', { threshold: 2.5 }) const jobBodies = transport.mock.calls .filter(call => String(call[0]).endsWith('/jobs') && call[1]?.method === 'POST') .map(call => JSON.parse(String(call[1]?.body))) expect({ jobId: job.job_id, sessionId: generated.session.value?.id, storedSessionId: storage.getItem('mint:generated:/api/demo:session'), jobBodies, }).toEqual({ jobId: 'job-1', sessionId: 'session-2', storedSessionId: 'session-2', jobBodies: [ { definition_id: 'run', inputs: { threshold: 2.5 }, session_id: 'session-1', client_request_id: 'request-1', }, { definition_id: 'run', inputs: { threshold: 2.5 }, session_id: 'session-2', client_request_id: 'request-1', }, ], }) } finally { scope.stop() } }) it.each([ { label: 'another 404', status: 404, detail: 'Unknown job definition' }, { label: 'a server error', status: 500, detail: 'Internal server error' }, ])('should not retry submission for $label', async ({ status, detail }) => { const storage = memoryStorage() const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [] })) .mockResolvedValueOnce(response({ detail }, status)) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage, createId: () => 'request-1', stream: false, }))! let caught: unknown try { await generated.initialize() await generated.submit('run', {}) } catch (error) { caught = error } finally { scope.stop() } expect(caught).toBeInstanceOf(MintApiError) expect({ status: (caught as { status?: number } | undefined)?.status, message: caught instanceof Error ? caught.message : null, jobPosts: transport.mock.calls.filter(call => ( String(call[0]).endsWith('/jobs') && call[1]?.method === 'POST' )).length, sessionRequests: transport.mock.calls.filter(call => String(call[0]).endsWith('/sessions')).length, }).toEqual({ status, message: detail, jobPosts: 1, sessionRequests: 2, }) }) it('should keep polling active jobs when one refresh request fails', async () => { vi.useFakeTimers() const storage = memoryStorage() const runningJob = activeJob('job-1', 'running') const fetcher = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: ['job-1'], })) .mockResolvedValueOnce(response({ jobs: [runningJob] })) .mockRejectedValueOnce(new TypeError('temporary network failure')) .mockResolvedValueOnce(response({ jobs: [completedJob('job-1')] })) .mockResolvedValueOnce(response({ kind: 'text', text: 'done' })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport: fetcher, storage, createId: () => 'tab-1', pollInterval: 10, stream: false, }))! try { await generated.initialize() await vi.advanceTimersByTimeAsync(20) expect({ status: generated.jobs.value[0]?.status, error: generated.error.value, }).toEqual({ status: 'completed', error: null }) } finally { scope.stop() vi.useRealTimers() } }) it('should deduplicate result loads and keep the newest result when older requests finish later', async () => { const storage = memoryStorage() const oldResult = deferred() const newResult = deferred() const oldJob = completedJob('job-old') const newJob = { ...oldJob, id: 'job-new', job_id: 'job-new', } const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [] })) .mockResolvedValueOnce(response({ jobs: [oldJob] })) .mockReturnValueOnce(oldResult.promise) .mockResolvedValueOnce(response({ jobs: [newJob, oldJob] })) .mockReturnValueOnce(newResult.promise) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage, createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() await generated.refreshJobs() const oldLoad = generated.loadResult(oldJob) const duplicateOldLoad = generated.loadResult(oldJob) await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(6)) await generated.refreshJobs() const newLoad = generated.loadResult(newJob) await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(8)) newResult.resolve(response({ kind: 'text', text: 'new' })) await newLoad oldResult.resolve(response({ kind: 'text', text: 'old' })) await oldLoad await duplicateOldLoad expect({ latest: generated.latestResult.value, resultRequests: transport.mock.calls .map(call => String(call[0])) .filter(url => url.endsWith('/result')), }).toEqual({ latest: { kind: 'text', text: 'new' }, resultRequests: [ '/api/demo/jobs/job-old/result', '/api/demo/jobs/job-new/result', ], }) } finally { scope.stop() } }) it('should refresh a cached managed download before its capability expires', async () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-07-21T10:00:00Z')) const completed = completedJob('managed-job') const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [completed] })) .mockResolvedValueOnce(response({ kind: 'artifact', id: 'artifact-1', name: 'result.bin', media_type: 'application/octet-stream', size: 6, managed: true, download_url: '/api/demo/jobs/managed-job/result/file/token-1', download_expires_at: '2026-07-21T10:05:00Z', })) .mockResolvedValueOnce(response({ kind: 'artifact', id: 'artifact-1', name: 'result.bin', media_type: 'application/octet-stream', size: 6, managed: true, download_url: '/api/demo/jobs/managed-job/result/file/token-2', download_expires_at: '2026-07-21T10:09:30Z', })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() await generated.loadResult(completed) await vi.advanceTimersByTimeAsync(270_000) expect({ result: generated.resultForJob('managed-job'), resultRequests: transport.mock.calls .filter(call => String(call[0]).endsWith('/result')).length, }).toMatchObject({ result: { download_url: '/api/demo/jobs/managed-job/result/file/token-2', }, resultRequests: 2, }) } finally { scope.stop() vi.useRealTimers() } }) it('should stop renewing a managed download after the result expires', async () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-07-21T10:00:00Z')) const completed = completedJob('managed-job') const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [completed] })) .mockResolvedValueOnce(response({ kind: 'artifact', id: 'artifact-1', name: 'result.bin', media_type: 'application/octet-stream', size: 6, managed: true, download_url: '/api/demo/jobs/managed-job/result/file/token-1', download_expires_at: '2026-07-21T10:05:00Z', })) .mockResolvedValueOnce(response({ detail: 'Job result expired' }, 410)) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() await generated.loadResult(completed) await vi.advanceTimersByTimeAsync(270_000) await vi.advanceTimersByTimeAsync(60_000) expect({ result: generated.resultForJob('managed-job'), resultStatus: generated.jobs.value[0]?.result_status, resultRequests: transport.mock.calls .filter(call => String(call[0]).endsWith('/result')).length, }).toEqual({ result: null, resultStatus: 'expired', resultRequests: 2, }) } finally { scope.stop() vi.useRealTimers() } }) it('should wait for a near-result-TTL capability to expire instead of tight-looping', async () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-07-21T10:00:00Z')) const completed = completedJob('managed-job') const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [completed] })) .mockResolvedValueOnce(response({ kind: 'artifact', id: 'artifact-1', name: 'result.bin', media_type: 'application/octet-stream', size: 6, managed: true, download_url: '/api/demo/jobs/managed-job/result/file/token-1', download_expires_at: '2026-07-21T10:00:20Z', })) .mockResolvedValueOnce(response({ detail: 'Job result expired' }, 410)) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() await generated.loadResult(completed) await vi.advanceTimersByTimeAsync(19_999) expect(transport.mock.calls.filter(call => String(call[0]).endsWith('/result'))).toHaveLength(1) await vi.advanceTimersByTimeAsync(1) expect(transport.mock.calls.filter(call => String(call[0]).endsWith('/result'))).toHaveLength(2) } finally { scope.stop() vi.useRealTimers() } }) it('should pause managed download renewal and refresh it after resume', async () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-07-21T10:00:00Z')) const completed = completedJob('managed-job') const managedResult = (token: string, expires: string) => ({ kind: 'artifact' as const, id: 'artifact-1', name: 'result.bin', media_type: 'application/octet-stream', size: 6, managed: true, download_url: `/api/demo/jobs/managed-job/result/file/${token}`, download_expires_at: expires, }) const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [completed] })) .mockResolvedValueOnce(response(managedResult('token-1', '2026-07-21T10:05:00Z'))) .mockResolvedValueOnce(response(managedResult('token-2', '2026-07-21T10:10:01Z'))) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() await generated.loadResult(completed) generated.pause() await vi.advanceTimersByTimeAsync(301_000) expect(transport.mock.calls.filter(call => String(call[0]).endsWith('/result'))).toHaveLength(1) generated.resume() await vi.advanceTimersByTimeAsync(0) expect(generated.resultForJob('managed-job')).toMatchObject({ download_url: '/api/demo/jobs/managed-job/result/file/token-2', }) } finally { scope.stop() vi.useRealTimers() } }) it('should retry a transient result failure and surface the loading error', async () => { vi.useFakeTimers() const storage = memoryStorage() const completed = completedJob('job-1') const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [completed] })) .mockRejectedValueOnce(new TypeError('temporary result failure')) .mockResolvedValueOnce(response({ kind: 'text', text: 'recovered' })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage, createId: () => 'tab-1', pollInterval: 10, stream: false, }))! try { await generated.initialize() await generated.loadResult(completed) expect(generated.error.value).toBe('temporary result failure') await vi.advanceTimersByTimeAsync(10) expect({ result: generated.resultForJob('job-1'), error: generated.error.value, resultRequests: transport.mock.calls.filter(call => String(call[0]).endsWith('/result')).length, }).toEqual({ result: { kind: 'text', text: 'recovered' }, error: null, resultRequests: 2, }) } finally { scope.stop() vi.useRealTimers() } }) it('should load a visible completed result from another session', async () => { const storage = memoryStorage() const sharedJob = { ...completedJob('shared-job'), session_id: 'another-session', } const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [sharedJob] })) .mockResolvedValueOnce(response({ kind: 'text', text: 'shared' })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage, createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() const result = await generated.loadResult(sharedJob) expect(result).toEqual({ kind: 'text', text: 'shared' }) } finally { scope.stop() } }) it('should clear exhausted retry state after a job leaves the visible list', async () => { vi.useFakeTimers() const storage = memoryStorage() const completed = completedJob('retry-job') const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [completed] })) .mockRejectedValueOnce(new TypeError('failure-1')) .mockRejectedValueOnce(new TypeError('failure-2')) .mockRejectedValueOnce(new TypeError('failure-3')) .mockRejectedValueOnce(new TypeError('failure-4')) .mockResolvedValueOnce(response({ jobs: [] })) .mockResolvedValueOnce(response({ jobs: [completed] })) .mockRejectedValueOnce(new TypeError('failure-after-return')) .mockResolvedValueOnce(response({ kind: 'text', text: 'recovered' })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage, createId: () => 'tab-1', pollInterval: 10, stream: false, }))! try { await generated.initialize() await generated.loadResult(completed) await vi.advanceTimersByTimeAsync(70) await generated.refreshJobs() await generated.refreshJobs() await generated.loadResult(completed) await vi.advanceTimersByTimeAsync(10) expect(generated.resultForJob('retry-job')).toEqual({ kind: 'text', text: 'recovered', }) } finally { scope.stop() vi.useRealTimers() } }) it('should retain at most thirty-two lazily loaded results', async () => { const storage = memoryStorage() const completedJobs = Array.from( { length: 33 }, (_, index) => completedJob(`job-${index}`), ) const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: completedJobs })) .mockImplementation(async input => response({ kind: 'text', text: String(input) })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage, createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() for (const job of completedJobs) await generated.loadResult(job) expect({ evicted: generated.resultForJob('job-0'), retained: generated.resultForJob('job-32'), }).toEqual({ evicted: null, retained: { kind: 'text', text: '/api/demo/jobs/job-32/result', }, }) } finally { scope.stop() } }) it('waits for a terminal refresh and supports immediate terminal results', async () => { const queued = activeJob('wait-job') const running = activeJob('wait-job', 'running') const completed = completedJob('wait-job') const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [queued] })) .mockResolvedValueOnce(response(running)) .mockResolvedValueOnce(response({ jobs: [completed] })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', pollInterval: 60_000, stream: false, }))! try { await generated.initialize() const waiting = generated.waitForJob('wait-job') await vi.waitFor(() => expect(generated.jobs.value[0]?.status).toBe('running')) await generated.refreshJobs() await expect(waiting).resolves.toMatchObject({ job_id: 'wait-job', status: 'completed', }) await expect(generated.waitForJob(completed)).resolves.toEqual(completed) expect(transport).toHaveBeenCalledTimes(6) } finally { scope.stop() } }) it('can reject failed jobs and pre-aborted waits without requesting the job', async () => { const failed = { ...activeJob('failed-job'), status: 'failed' as const, error: 'analysis exploded', completed_at: '2026-07-21T00:00:00Z', can_cancel: false, can_delete: true, } const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [failed] })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() await expect(generated.waitForJob(failed)).resolves.toMatchObject({ status: 'failed' }) await expect(generated.waitForJob(failed, { rejectOnFailure: true })) .rejects.toThrow('analysis exploded') const controller = new AbortController() controller.abort() await expect(generated.waitForJob(failed, { signal: controller.signal })) .rejects.toMatchObject({ name: 'AbortError' }) expect(transport).toHaveBeenCalledTimes(4) } finally { scope.stop() } }) it('removes an aborted waiter before a late job response arrives', async () => { const queued = activeJob('abort-job') const jobRequest = deferred() const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [queued] })) .mockReturnValueOnce(jobRequest.promise) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', pollInterval: 60_000, stream: false, }))! try { await generated.initialize() const controller = new AbortController() const waiting = generated.waitForJob('abort-job', { signal: controller.signal }) expect(transport.mock.calls[4]?.[1]?.signal).toBe(controller.signal) controller.abort() await expect(waiting).rejects.toMatchObject({ name: 'AbortError' }) jobRequest.resolve(response(completedJob('abort-job'))) await vi.waitFor(() => expect(generated.jobs.value[0]?.status).toBe('completed')) } finally { scope.stop() } }) it('keeps a known active waiter through a transient lookup failure', async () => { const queued = activeJob('retry-wait-job') const completed = completedJob('retry-wait-job') const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [queued] })) .mockResolvedValueOnce(response({ detail: 'temporarily unavailable' }, 503)) .mockResolvedValueOnce(response({ jobs: [completed] })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', pollInterval: 60_000, stream: false, }))! try { await generated.initialize() const waiting = generated.waitForJob('retry-wait-job') await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(5)) await generated.refreshJobs() await expect(waiting).resolves.toMatchObject({ job_id: 'retry-wait-job', status: 'completed', }) } finally { scope.stop() } }) it('rejects an active wait immediately while background updates are paused', async () => { const queued = activeJob('paused-wait-job') const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [queued] })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() generated.pause() await expect(generated.waitForJob('paused-wait-job')).rejects.toThrow( 'Plugin jobs runtime is paused', ) expect(transport).toHaveBeenCalledTimes(4) } finally { scope.stop() } }) it('rejects pending waiters when their job is deleted or the runtime is disposed', async () => { const queued = activeJob('delete-wait-job') const disposeJobRequest = deferred() const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [queued] })) .mockResolvedValueOnce(response(queued)) .mockResolvedValueOnce(response({ status: 'deleted' })) .mockReturnValueOnce(disposeJobRequest.promise) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', pollInterval: 60_000, stream: false, }))! await generated.initialize() const deletedWait = generated.waitForJob('delete-wait-job') await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(5)) await generated.deleteJob('delete-wait-job') await expect(deletedWait).rejects.toThrow('was deleted') const disposeWait = generated.waitForJob(activeJob('dispose-wait-job')) scope.stop() await expect(disposeWait).rejects.toThrow('runtime is disposed') disposeJobRequest.resolve(response(activeJob('dispose-wait-job'))) }) it('coalesces initialization and refreshes, then disposes idempotently', async () => { const manifestRequest = deferred() const refreshRequest = deferred() const transport = vi.fn() .mockReturnValueOnce(manifestRequest.promise) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [] })) .mockReturnValueOnce(refreshRequest.promise) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', stream: false, }))! const firstInitialize = generated.initialize() const secondInitialize = generated.ready() expect(firstInitialize).toBe(secondInitialize) manifestRequest.resolve(response(manifest)) await firstInitialize const firstRefresh = generated.refreshJobs() const secondRefresh = generated.refresh() expect(firstRefresh).toBe(secondRefresh) refreshRequest.resolve(response({ jobs: [completedJob('coalesced')] })) await firstRefresh generated.dispose() generated.dispose() scope.stop() expect({ status: generated.status.value, requests: transport.mock.calls.length, filtered: generated.jobsFor('run').map(job => job.job_id), }).toEqual({ status: 'disposed', requests: 5, filtered: ['coalesced'], }) await expect(generated.initialize()).rejects.toThrow('disposed') }) it('does not let a late list response overwrite a newer canonical job update', async () => { const staleRefresh = deferred() const queued = activeJob('live-job') const completed = completedJob('live-job') const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [queued] })) .mockReturnValueOnce(staleRefresh.promise) .mockResolvedValueOnce(response(completed)) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() const refresh = generated.refreshJobs() await generated.getJob('live-job') staleRefresh.resolve(response({ jobs: [queued] })) await refresh expect(generated.jobs.value[0]?.status).toBe('completed') } finally { scope.stop() } }) it('should keep polling when a stale list response overlaps an active job lookup', async () => { vi.useFakeTimers() const queued = activeJob('live-job') const running = activeJob('live-job', 'running') const completed = completedJob('live-job') const jobRequest = deferred() const staleRefresh = deferred() const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [queued] })) .mockReturnValueOnce(jobRequest.promise) .mockReturnValueOnce(staleRefresh.promise) .mockResolvedValueOnce(response({ jobs: [completed] })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', pollInterval: 10, stream: false, }))! let waiting: Promise | undefined try { await generated.initialize() waiting = generated.waitForJob('live-job') void waiting.catch(() => undefined) vi.advanceTimersByTime(10) await vi.waitFor(() => { if (transport.mock.calls.length !== 6) throw new Error('Concurrent job requests did not start') }) jobRequest.resolve(response(running)) await vi.waitFor(() => { if (generated.jobs.value[0]?.status !== 'running') throw new Error('Job lookup did not resolve') }) staleRefresh.resolve(response({ jobs: [running] })) await vi.advanceTimersByTimeAsync(10) await vi.waitFor(() => { if (transport.mock.calls.length !== 7) throw new Error('Next list poll was not scheduled') }) await expect(waiting).resolves.toMatchObject({ job_id: 'live-job', status: 'completed', }) } finally { scope.stop() await waiting?.catch(() => undefined) vi.useRealTimers() } }) it('does not regress a terminal job when a stale snapshot arrives without a concurrent update', async () => { const completed = completedJob('terminal-job') const staleQueued = activeJob('terminal-job') const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [completed] })) .mockResolvedValueOnce(response({ jobs: [staleQueued] })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() await generated.refreshJobs() expect(generated.jobs.value[0]?.status).toBe('completed') } finally { scope.stop() } }) it('initializes and lists jobs only once when refresh is the first operation', async () => { const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [completedJob('initial-refresh')] })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', stream: false, }))! try { const firstRefresh = generated.refresh() const secondRefresh = generated.refresh() expect(firstRefresh).toBe(secondRefresh) const jobs = await firstRefresh expect(jobs.map(job => job.job_id)).toEqual(['initial-refresh']) expect(transport).toHaveBeenCalledTimes(4) expect(transport.mock.calls.filter(([url]) => url === '/api/demo/jobs')).toHaveLength(1) } finally { scope.stop() } }) it('cancels one active job through the generated runtime', async () => { const running = { ...activeJob('cancel-me', 'running'), } const cancelled = { ...running, status: 'cancelled' as const, completed_at: '2026-07-21T00:00:00Z', can_cancel: false, can_delete: true, } const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [running] })) .mockResolvedValueOnce(response(cancelled)) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() const updated = await generated.cancelJob('cancel-me') expect({ updated, jobs: generated.jobs.value, request: transport.mock.calls[4]?.slice(0, 2), }).toEqual({ updated: cancelled, jobs: [cancelled], request: [ '/api/demo/jobs/cancel-me/cancel', { method: 'POST', headers: { Accept: 'application/json' } }, ], }) } finally { scope.stop() } }) it('canonicalizes legacy job payloads and fetches one job without refreshing the list', async () => { const { definition_id: _activeDefinitionId, can_cancel: _activeCanCancel, can_delete: _activeCanDelete, result_status: _activeResultStatus, result_expires_at: _activeResultExpiresAt, ...legacyActive } = activeJob('legacy-active', 'running') legacyActive.progress = { ...legacyActive.progress, percent: 140 } const { definition_id: _completedDefinitionId, can_cancel: _completedCanCancel, can_delete: _completedCanDelete, result_status: _completedResultStatus, result_expires_at: _completedResultExpiresAt, ...legacyCompleted } = completedJob('legacy-completed') const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [legacyActive] })) .mockResolvedValueOnce(response(legacyCompleted)) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() const fetched = await generated.getJob('legacy-completed') expect(generated.jobs.value[1]).toMatchObject({ job_id: 'legacy-active', definition_id: 'run', can_cancel: true, can_delete: false, result_status: 'none', progress: { percent: 100 }, }) expect(fetched).toMatchObject({ job_id: 'legacy-completed', definition_id: 'run', can_cancel: false, can_delete: true, result_status: 'available', }) expect(generated.jobs.value[0]).toBe(fetched) expect(transport.mock.calls[4]?.slice(0, 2)).toEqual([ '/api/demo/jobs/legacy-completed', { method: 'GET', headers: { Accept: 'application/json' } }, ]) } finally { scope.stop() } }) it('deletes one finished job through the generated runtime', async () => { const completed = completedJob('delete-me') const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [completed] })) .mockResolvedValueOnce(response({ status: 'deleted' })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() const deleted = await generated.deleteJob(completed) expect({ deleted, jobs: generated.jobs.value, request: transport.mock.calls[4]?.slice(0, 2), }).toEqual({ deleted: true, jobs: [], request: ['/api/demo/jobs/delete-me', { method: 'DELETE', headers: { Accept: 'application/json' } }], }) } finally { scope.stop() } }) it('clears finished jobs for one definition without clearing other definitions', async () => { const runJob = completedJob('clear-run') const otherJob = { ...completedJob('keep-other'), definition_id: 'other', metadata: { definition_id: 'other', has_result: true }, } const transport = vi.fn() .mockResolvedValueOnce(response(manifest)) .mockResolvedValueOnce(response({ sessions: [] })) .mockResolvedValueOnce(response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [], })) .mockResolvedValueOnce(response({ jobs: [runJob, otherJob] })) .mockResolvedValueOnce(response({ deleted: 1 })) .mockResolvedValueOnce(response({ jobs: [otherJob] })) const scope = effectScope() const generated = scope.run(() => useGeneratedAnalysis({ baseUrl: '/api/demo', transport, storage: memoryStorage(), createId: () => 'tab-1', stream: false, }))! try { await generated.initialize() const deleted = await generated.clearFinishedJobs('run') expect(deleted).toBe(1) expect(generated.jobs.value.map(job => job.job_id)).toEqual(['keep-other']) expect(transport.mock.calls[4]?.slice(0, 2)).toEqual([ '/api/demo/jobs?definition_id=run', { method: 'DELETE', headers: { Accept: 'application/json' } }, ]) } finally { scope.stop() } }) })