CI / ci (push) Successful in 2m39s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
240 lines
9.3 KiB
TypeScript
240 lines
9.3 KiB
TypeScript
import { act, renderHook } from '@testing-library/react'
|
|
import type { ReactNode } from 'react'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { errorResponse, fakeLocation, jsonResponse, request, stubFetch } from '../test/fetchStub.ts'
|
|
import { ApiProvider, createApiClient, createVerifiedSession, useJob } from './index.ts'
|
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
|
|
|
type Draft = { dinners: string[] }
|
|
|
|
function job(status: string, result: Draft | null, error: string | null = null) {
|
|
return jsonResponse(200, { id: 'j1', kind: 'plan.draft', status, result, error })
|
|
}
|
|
|
|
function renderJob(jobId: string | null) {
|
|
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
|
const wrapper = ({ children }: { children: ReactNode }) => <ApiProvider client={api}>{children}</ApiProvider>
|
|
return renderHook(({ id }) => useJob<Draft>('plan.draft', id), { wrapper, initialProps: { id: jobId } })
|
|
}
|
|
|
|
// advance runs the timers due in ms and lets the polls they start answer.
|
|
async function advance(ms: number) {
|
|
await act(() => vi.advanceTimersByTimeAsync(ms))
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.clearAllTimers()
|
|
vi.useRealTimers()
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
describe('useJob', () => {
|
|
it('exposes the partial result while running, then the terminal result, backing off 500 ms to 2 s', async () => {
|
|
const fetchMock = stubFetch(
|
|
job('queued', null),
|
|
job('running', { dinners: ['Tacos'] }),
|
|
job('running', { dinners: ['Tacos', 'Chili'] }),
|
|
job('running', { dinners: ['Tacos', 'Chili'] }),
|
|
job('succeeded', { dinners: ['Tacos', 'Chili', 'Soup'] }),
|
|
)
|
|
const { result } = renderJob('j1')
|
|
expect(result.current).toMatchObject({ status: undefined, result: null, error: null })
|
|
|
|
await advance(0)
|
|
expect(result.current.status).toBe('queued')
|
|
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/jobs/j1', method: 'GET' })
|
|
|
|
await advance(499)
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
await advance(1)
|
|
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
|
|
|
|
await advance(999)
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
await advance(1)
|
|
expect(result.current.result).toEqual({ dinners: ['Tacos', 'Chili'] })
|
|
|
|
await advance(2000)
|
|
expect(fetchMock).toHaveBeenCalledTimes(4)
|
|
await advance(2000)
|
|
expect(fetchMock).toHaveBeenCalledTimes(5)
|
|
expect(result.current).toMatchObject({
|
|
status: 'succeeded',
|
|
result: { dinners: ['Tacos', 'Chili', 'Soup'] },
|
|
error: null,
|
|
})
|
|
|
|
// Terminal: no timer left, no further polls.
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
await advance(10_000)
|
|
expect(fetchMock).toHaveBeenCalledTimes(5)
|
|
})
|
|
|
|
it('exposes a failed job as its error and stops', async () => {
|
|
const fetchMock = stubFetch(job('running', { dinners: ['Tacos'] }), job('failed', null, 'model refused'))
|
|
const { result } = renderJob('j1')
|
|
|
|
await advance(0)
|
|
await advance(500)
|
|
expect(result.current.status).toBe('failed')
|
|
expect(result.current.error?.message).toBe('model refused')
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('exposes a failed poll as the error and keeps polling to the terminal status', async () => {
|
|
const fetchMock = stubFetch(
|
|
job('running', { dinners: ['Tacos'] }),
|
|
errorResponse(503, 'unavailable', true),
|
|
job('succeeded', { dinners: ['Tacos', 'Chili'] }),
|
|
)
|
|
const { result } = renderJob('j1')
|
|
|
|
await advance(0)
|
|
await advance(500)
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] } })
|
|
expect(result.current.error).toMatchObject({ status: 503, code: 'unavailable' })
|
|
expect(vi.getTimerCount()).toBe(1)
|
|
|
|
// The backoff carries on from the failed poll: the next one is 1 s later.
|
|
await advance(999)
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
await advance(1)
|
|
expect(fetchMock).toHaveBeenCalledTimes(3)
|
|
expect(result.current).toMatchObject({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null })
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
})
|
|
|
|
it.each([
|
|
['an unknown status', { id: 'j1', kind: 'plan.draft', status: 'stuck', result: null, error: null }],
|
|
['another job', { id: 'j2', kind: 'plan.draft', status: 'running', result: null, error: null }],
|
|
['another kind', { id: 'j1', kind: 'suggestion_batch', status: 'running', result: null, error: null }],
|
|
['no result', { id: 'j1', kind: 'plan.draft', status: 'running', error: null }],
|
|
['an error that is not text', { id: 'j1', kind: 'plan.draft', status: 'failed', result: null, error: 500 }],
|
|
['not a job', ['j1']],
|
|
])('exposes an answer with %s as the error and stops, keeping the last status', async (_, body) => {
|
|
const fetchMock = stubFetch(job('running', { dinners: ['Tacos'] }), jsonResponse(200, body))
|
|
const { result } = renderJob('j1')
|
|
|
|
await advance(0)
|
|
await advance(500)
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] } })
|
|
expect(result.current.error?.message).toBe('GET /api/v1/jobs/j1: response is not the plan.draft job j1')
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
await advance(10_000)
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('polls again at once on poll after stopping on an answer that is not the job, then keeps polling', async () => {
|
|
const fetchMock = stubFetch(
|
|
jsonResponse(200, { id: 'j1', kind: 'plan.draft', status: 'stuck', result: null, error: null }),
|
|
job('running', { dinners: ['Tacos'] }),
|
|
job('succeeded', { dinners: ['Tacos', 'Chili'] }),
|
|
)
|
|
const { result } = renderJob('j1')
|
|
await advance(0)
|
|
expect(result.current.error).not.toBeNull()
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
|
|
act(() => result.current.poll())
|
|
await advance(0)
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
|
|
await advance(500)
|
|
expect(fetchMock).toHaveBeenCalledTimes(3)
|
|
expect(result.current).toMatchObject({ status: 'succeeded', error: null })
|
|
})
|
|
|
|
it('polls at once on poll during the backoff after a failed poll, starting the backoff again', async () => {
|
|
const fetchMock = stubFetch(
|
|
job('running', null),
|
|
errorResponse(503, 'unavailable', true),
|
|
errorResponse(503, 'unavailable', true),
|
|
job('running', { dinners: ['Tacos'] }),
|
|
job('succeeded', { dinners: ['Tacos'] }),
|
|
)
|
|
const { result } = renderJob('j1')
|
|
await advance(0)
|
|
await advance(500)
|
|
await advance(1000)
|
|
expect(fetchMock).toHaveBeenCalledTimes(3)
|
|
|
|
// The next poll was due 2 s later; poll asks now.
|
|
act(() => result.current.poll())
|
|
await advance(0)
|
|
expect(fetchMock).toHaveBeenCalledTimes(4)
|
|
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
|
|
expect(vi.getTimerCount()).toBe(1)
|
|
await advance(500)
|
|
expect(fetchMock).toHaveBeenCalledTimes(5)
|
|
expect(result.current.status).toBe('succeeded')
|
|
})
|
|
|
|
it('stops polling on unmount after a failed poll', async () => {
|
|
const fetchMock = stubFetch(errorResponse(503, 'unavailable', true))
|
|
const { result, unmount } = renderJob('j1')
|
|
|
|
await advance(0)
|
|
expect(result.current).toMatchObject({ status: undefined, result: null })
|
|
expect(result.current.error).toMatchObject({ status: 503 })
|
|
expect(vi.getTimerCount()).toBe(1)
|
|
unmount()
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
await advance(10_000)
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('stops polling the old job when the job id changes', async () => {
|
|
const fetchMock = stubFetch(
|
|
job('running', null),
|
|
jsonResponse(200, { id: 'j2', kind: 'plan.draft', status: 'running', result: null, error: null }),
|
|
)
|
|
const { result, rerender } = renderJob('j1')
|
|
|
|
await advance(0)
|
|
rerender({ id: 'j2' })
|
|
expect(result.current).toMatchObject({ status: undefined, result: null, error: null })
|
|
await advance(0)
|
|
expect(request(fetchMock, 1).path).toBe('/api/v1/jobs/j2')
|
|
expect(vi.getTimerCount()).toBe(1)
|
|
})
|
|
|
|
it('stops polling on unmount', async () => {
|
|
const fetchMock = stubFetch(job('running', null))
|
|
const { unmount } = renderJob('j1')
|
|
|
|
await advance(0)
|
|
expect(vi.getTimerCount()).toBe(1)
|
|
unmount()
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
await advance(10_000)
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('ignores a poll that answers after unmount', async () => {
|
|
let answer!: (response: Response) => void
|
|
const fetchMock = stubFetch()
|
|
fetchMock.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)))
|
|
const { result, unmount } = renderJob('j1')
|
|
|
|
unmount()
|
|
await act(async () => answer(await Promise.resolve(job('running', null))))
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
expect(result.current.status).toBeUndefined()
|
|
})
|
|
|
|
it('polls nothing without a job id', async () => {
|
|
const fetchMock = stubFetch()
|
|
const { result } = renderJob(null)
|
|
await advance(5000)
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
expect(result.current).toMatchObject({ status: undefined, result: null, error: null })
|
|
})
|
|
})
|