Files
screenci__screenci/cli.dev.spec.ts
T
Olli Paloviita 679c9db2bf Auto-mint the editor token from the org secret; queue edits while the bridge is offline
screenci edit no longer needs a hand-created SCREENCI_EDIT_TOKEN: the
CLI exchanges the org secret for a machine-scoped editor token minted
by the server (idempotent per machine, revocable on the Secrets page).

Edits made in the web editor while no bridge is connected queue as
deferred codegen requests; the next screenci edit run drains them into
the script before listening. Export skips Studio-held videos instead of
waiting on renders that will never start, and reports when everything
is held. Docs updated to match.
2026-08-18 10:53:42 +03:00

870 lines
30 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import type {
DevCodegenRequest,
DevListenConfig,
DevListenDeps,
DevTrigger,
} from './src/devListen'
import {
DEV_TOKEN_HEADER,
DevAuthError,
ONE_SHOT_TRIGGER_MESSAGE,
deregisterDevListener,
drainDevCodegenRequests,
pollDevListener,
registerDevListener,
reportDevTrigger,
runDevListenLoop,
} from './src/devListen'
const config: DevListenConfig = {
apiUrl: 'http://localhost:8787',
credential: { header: 'X-ScreenCI-Secret', value: 'org-secret' },
devToken: 'dev-token',
projectName: 'demo',
machineName: 'laptop',
pollIntervalMs: 1,
errorBackoffMs: 1,
runHeartbeatMs: 1000,
}
const trigger: DevTrigger = {
triggerId: 'trg_1',
videoName: 'Intro video',
language: 'fi',
requestedByName: 'Olli',
}
const codegenRequest: DevCodegenRequest = {
requestId: 'cgr_1',
videoName: 'Intro video',
editId: 'delay1',
editJson: '{"kind":"paramEdit"}',
requiresRecord: true,
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
function makeDeps(overrides: Partial<DevListenDeps> = {}): DevListenDeps & {
fetchMock: ReturnType<typeof vi.fn>
} {
const fetchMock = vi.fn(async () => jsonResponse({ ok: true }))
return {
fetchFn: fetchMock as unknown as typeof fetch,
fetchMock,
sleep: vi.fn(async () => {}),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
runRecord: vi.fn(async () => {}),
setIntervalFn: vi.fn(() => () => {}),
...overrides,
}
}
describe('registerDevListener', () => {
it('sends both credentials and the machine name', async () => {
const deps = makeDeps()
deps.fetchMock.mockResolvedValueOnce(jsonResponse({ listenerId: 'lst_1' }))
const result = await registerDevListener(config, deps)
expect(result).toEqual({ listenerId: 'lst_1' })
const [url, init] = deps.fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('http://localhost:8787/cli/dev/register')
const headers = init.headers as Record<string, string>
expect(headers['X-ScreenCI-Secret']).toBe('org-secret')
expect(headers[DEV_TOKEN_HEADER]).toBe('dev-token')
expect(JSON.parse(init.body as string)).toEqual({
projectName: 'demo',
machineName: 'laptop',
})
})
it('throws DevAuthError on a 401', async () => {
const deps = makeDeps()
deps.fetchMock.mockResolvedValueOnce(
jsonResponse({ error: 'Invalid editor token' }, 401)
)
await expect(registerDevListener(config, deps)).rejects.toBeInstanceOf(
DevAuthError
)
})
})
describe('pollDevListener', () => {
it('returns the claimed trigger and codegen requests', async () => {
const deps = makeDeps()
deps.fetchMock.mockResolvedValueOnce(
jsonResponse({ trigger, codegenRequests: [codegenRequest] })
)
await expect(pollDevListener(config, deps, 'lst_1')).resolves.toEqual({
trigger,
codegenRequests: [codegenRequest],
})
})
it('returns an empty result when nothing is pending', async () => {
const deps = makeDeps()
deps.fetchMock.mockResolvedValueOnce(jsonResponse({ trigger: null }))
await expect(pollDevListener(config, deps, 'lst_1')).resolves.toEqual({
trigger: null,
codegenRequests: [],
})
})
it('treats an empty 2xx body as no trigger instead of throwing', async () => {
const deps = makeDeps()
// The /cli/dev/* proxy (or an idle keep-alive) can return an empty body;
// res.json() on it throws "Unexpected end of JSON input".
deps.fetchMock.mockResolvedValueOnce(new Response('', { status: 200 }))
await expect(pollDevListener(config, deps, 'lst_1')).resolves.toEqual({
trigger: null,
codegenRequests: [],
})
})
})
describe('drainDevCodegenRequests', () => {
it('applies queued requests across polls until a poll comes back empty', async () => {
const applied: string[] = []
const deps = makeDeps({
applyCodegen: vi.fn(async (request: DevCodegenRequest) => {
applied.push(request.requestId)
}),
})
deps.fetchMock.mockImplementation(async (url: string) => {
if (String(url).endsWith('/cli/dev/poll')) {
// First poll returns two requests, second returns one, third is empty.
const call = deps.fetchMock.mock.calls.filter(([u]) =>
String(u).endsWith('/cli/dev/poll')
).length
if (call === 1) {
return jsonResponse({
trigger: null,
codegenRequests: [
{ ...codegenRequest, requestId: 'cgr_1' },
{ ...codegenRequest, requestId: 'cgr_2' },
],
})
}
if (call === 2) {
return jsonResponse({
trigger: null,
codegenRequests: [{ ...codegenRequest, requestId: 'cgr_3' }],
})
}
return jsonResponse({ trigger: null, codegenRequests: [] })
}
return jsonResponse({ ok: true })
})
const result = await drainDevCodegenRequests(config, deps, 'lst_1')
expect(result).toEqual({ handled: 3 })
expect(applied).toEqual(['cgr_1', 'cgr_2', 'cgr_3'])
})
it('returns zero handled when nothing is queued', async () => {
const deps = makeDeps({ applyCodegen: vi.fn(async () => {}) })
deps.fetchMock.mockResolvedValue(
jsonResponse({ trigger: null, codegenRequests: [] })
)
await expect(
drainDevCodegenRequests(config, deps, 'lst_1')
).resolves.toEqual({ handled: 0 })
})
it('fails a claimed trigger with the one-shot pointer message', async () => {
const deps = makeDeps({ applyCodegen: vi.fn(async () => {}) })
deps.fetchMock.mockImplementation(async (url: string) => {
if (String(url).endsWith('/cli/dev/poll')) {
const call = deps.fetchMock.mock.calls.filter(([u]) =>
String(u).endsWith('/cli/dev/poll')
).length
if (call === 1) {
return jsonResponse({ trigger, codegenRequests: [] })
}
return jsonResponse({ trigger: null, codegenRequests: [] })
}
return jsonResponse({ ok: true })
})
await drainDevCodegenRequests(config, deps, 'lst_1')
const reportCall = deps.fetchMock.mock.calls.find(([u]) =>
String(u).endsWith('/cli/dev/report')
)
expect(reportCall).toBeDefined()
const body = JSON.parse((reportCall?.[1] as RequestInit).body as string)
expect(body).toMatchObject({
triggerId: 'trg_1',
state: 'failed',
errorMessage: ONE_SHOT_TRIGGER_MESSAGE,
})
})
})
describe('runDevListenLoop', () => {
it('claims a trigger, reports running, records, and reports done', async () => {
const controller = { stopped: false }
const runRecord = vi.fn(async () => {})
const deps = makeDeps({ runRecord })
deps.fetchMock.mockImplementation(
async (url: string, init: RequestInit) => {
if (url.endsWith('/cli/dev/poll')) {
// First poll returns the trigger, later polls stop the loop.
if (deps.fetchMock.mock.calls.length === 1) {
return jsonResponse({ trigger })
}
controller.stopped = true
return jsonResponse({ trigger: null })
}
expect(url.endsWith('/cli/dev/report')).toBe(true)
void init
return jsonResponse({ ok: true })
}
)
await runDevListenLoop(config, deps, 'lst_1', controller)
expect(runRecord).toHaveBeenCalledWith(trigger, expect.any(AbortSignal))
const reports = deps.fetchMock.mock.calls
.filter(([url]) => (url as string).endsWith('/cli/dev/report'))
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
expect(reports.map((r) => r.state)).toEqual(['running', 'done'])
expect(reports[1].triggerId).toBe('trg_1')
})
it('passes a previewOnly trigger through to the record runner', async () => {
const controller = { stopped: false }
const runRecord = vi.fn(async () => {})
const deps = makeDeps({ runRecord })
const previewTrigger: DevTrigger = { ...trigger, previewOnly: true }
deps.fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/cli/dev/poll')) {
if (deps.fetchMock.mock.calls.length === 1) {
return jsonResponse({ trigger: previewTrigger })
}
controller.stopped = true
return jsonResponse({ trigger: null })
}
return jsonResponse({ ok: true })
})
await runDevListenLoop(config, deps, 'lst_1', controller)
expect(runRecord).toHaveBeenCalledWith(
expect.objectContaining({ previewOnly: true }),
expect.any(AbortSignal)
)
})
it('applies codegen requests and reports them applied', async () => {
const controller = { stopped: false }
const applyCodegen = vi.fn(async () => {})
const deps = makeDeps({ applyCodegen })
deps.fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/cli/dev/poll')) {
if (deps.fetchMock.mock.calls.length === 1) {
return jsonResponse({
trigger: null,
codegenRequests: [codegenRequest],
})
}
controller.stopped = true
return jsonResponse({ trigger: null })
}
return jsonResponse({ ok: true })
})
await runDevListenLoop(config, deps, 'lst_1', controller)
expect(applyCodegen).toHaveBeenCalledWith(codegenRequest)
const reports = deps.fetchMock.mock.calls
.filter(([url]) => (url as string).endsWith('/cli/dev/report-codegen'))
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
expect(reports).toEqual([
expect.objectContaining({
requestId: 'cgr_1',
state: 'applied',
listenerId: 'lst_1',
}),
])
})
it('logs a render-time edit as needing no re-record, a record-affecting one without the note', async () => {
const controller = { stopped: false }
const applyCodegen = vi.fn(async () => {})
const deps = makeDeps({ applyCodegen })
const renderOptionsRequest: DevCodegenRequest = {
requestId: 'cgr_2',
videoName: 'Intro video',
editId: 'options:renderOptions',
editJson: '{"type":"optionsEdit"}',
requiresRecord: false,
}
deps.fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/cli/dev/poll')) {
if (deps.fetchMock.mock.calls.length === 1) {
return jsonResponse({
trigger: null,
codegenRequests: [codegenRequest, renderOptionsRequest],
})
}
controller.stopped = true
return jsonResponse({ trigger: null })
}
return jsonResponse({ ok: true })
})
await runDevListenLoop(config, deps, 'lst_1', controller)
const infoLines = (deps.logger.info as ReturnType<typeof vi.fn>).mock.calls
.map(([line]) => line as string)
.filter((line) => line.startsWith('Applied'))
expect(infoLines).toHaveLength(2)
expect(infoLines[0]).not.toContain('no re-record needed')
expect(infoLines[1]).toContain(
'Applies at render time, no re-record needed.'
)
})
it('reports a stale-key edit as orphaned (soft skip), not failed', async () => {
const controller = { stopped: false }
const applyCodegen = vi.fn(async () => ({ outcome: 'orphaned' as const }))
const deps = makeDeps({ applyCodegen })
deps.fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/cli/dev/poll')) {
if (deps.fetchMock.mock.calls.length === 1) {
return jsonResponse({
trigger: null,
codegenRequests: [codegenRequest],
})
}
controller.stopped = true
return jsonResponse({ trigger: null })
}
return jsonResponse({ ok: true })
})
await runDevListenLoop(config, deps, 'lst_1', controller)
const reports = deps.fetchMock.mock.calls
.filter(([url]) => (url as string).endsWith('/cli/dev/report-codegen'))
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
expect(reports).toEqual([
expect.objectContaining({ requestId: 'cgr_1', state: 'orphaned' }),
])
})
it('logs attribution when a deferred edit carries queuedBy', async () => {
const controller = { stopped: false }
const applyCodegen = vi.fn(async () => {})
const deps = makeDeps({ applyCodegen })
const deferred: DevCodegenRequest = {
...codegenRequest,
queuedBy: 'Marketer Mo',
}
deps.fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/cli/dev/poll')) {
if (deps.fetchMock.mock.calls.length === 1) {
return jsonResponse({ trigger: null, codegenRequests: [deferred] })
}
controller.stopped = true
return jsonResponse({ trigger: null })
}
return jsonResponse({ ok: true })
})
await runDevListenLoop(config, deps, 'lst_1', controller)
expect(deps.logger.info).toHaveBeenCalledWith(
expect.stringContaining('(queued by Marketer Mo)')
)
})
it('reports codegen failed with the error message when the apply throws', async () => {
const controller = { stopped: false }
const applyCodegen = vi.fn(async () => {
throw new Error('editId not found in source')
})
const deps = makeDeps({ applyCodegen })
deps.fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/cli/dev/poll')) {
if (deps.fetchMock.mock.calls.length === 1) {
return jsonResponse({ codegenRequests: [codegenRequest] })
}
controller.stopped = true
return jsonResponse({})
}
return jsonResponse({ ok: true })
})
await runDevListenLoop(config, deps, 'lst_1', controller)
const reports = deps.fetchMock.mock.calls
.filter(([url]) => (url as string).endsWith('/cli/dev/report-codegen'))
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
expect(reports).toEqual([
expect.objectContaining({
state: 'failed',
errorMessage: 'editId not found in source',
}),
])
})
it('serves codegen requests while a record is running', async () => {
const controller = { stopped: false }
let releaseRecord = () => {}
const recordGate = new Promise<void>((resolve) => {
releaseRecord = resolve
})
const runRecord = vi.fn(async () => recordGate)
const applyCodegen = vi.fn(async () => {})
const deps = makeDeps({ runRecord, applyCodegen })
let polls = 0
deps.fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/cli/dev/poll')) {
polls += 1
if (polls === 1) return jsonResponse({ trigger })
if (polls === 2) {
// The record is still running; the codegen must be served anyway.
return jsonResponse({ codegenRequests: [codegenRequest] })
}
// Stop only once the codegen was served mid-record.
if (applyCodegen.mock.calls.length > 0) {
expect(runRecord).toHaveBeenCalled()
controller.stopped = true
releaseRecord()
}
return jsonResponse({})
}
return jsonResponse({ ok: true })
})
await runDevListenLoop(config, deps, 'lst_1', controller)
expect(applyCodegen).toHaveBeenCalledWith(codegenRequest)
const triggerReports = deps.fetchMock.mock.calls
.filter(([url]) => (url as string).endsWith('/cli/dev/report'))
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
expect(triggerReports.map((r) => r.state)).toEqual(['running', 'done'])
})
it('kills a young record when a new trigger arrives and runs the new one', async () => {
const controller = { stopped: false }
let clock = 0
const secondTrigger: DevTrigger = { ...trigger, triggerId: 'trg_2' }
const runRecord = vi.fn(
(t: DevTrigger, signal?: AbortSignal) =>
new Promise<void>((resolve, reject) => {
if (t.triggerId === 'trg_2') {
resolve()
return
}
// First record: runs until killed.
signal?.addEventListener('abort', () =>
reject(new Error('Record aborted'))
)
})
)
const deps = makeDeps({ runRecord, now: () => clock })
const seenReports: Array<{ triggerId: string; state: string }> = []
let polls = 0
deps.fetchMock.mockImplementation(
async (url: string, init?: RequestInit) => {
if (url.endsWith('/cli/dev/report')) {
seenReports.push(JSON.parse((init as RequestInit).body as string))
}
if (url.endsWith('/cli/dev/poll')) {
polls += 1
if (polls === 1) return jsonResponse({ trigger })
if (polls === 2) {
clock = 5_000 // Inside the 10s kill window.
return jsonResponse({ trigger: secondTrigger })
}
// Stop only once the replacement record has reported done.
if (
seenReports.some(
(r) => r.triggerId === 'trg_2' && r.state === 'done'
)
) {
controller.stopped = true
}
return jsonResponse({})
}
return jsonResponse({ ok: true })
}
)
await runDevListenLoop(config, deps, 'lst_1', controller)
expect(runRecord).toHaveBeenCalledTimes(2)
const reports = deps.fetchMock.mock.calls
.filter(([url]) => (url as string).endsWith('/cli/dev/report'))
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
const first = reports.filter((r) => r.triggerId === 'trg_1')
const second = reports.filter((r) => r.triggerId === 'trg_2')
expect(first.map((r) => r.state)).toEqual(['running', 'failed'])
expect(first[1].errorMessage).toContain('Superseded')
expect(second.map((r) => r.state)).toEqual(['running', 'done'])
})
it('queues behind an old record instead of killing it, latest trigger wins', async () => {
const controller = { stopped: false }
let clock = 0
let releaseFirst = () => {}
const firstGate = new Promise<void>((resolve) => {
releaseFirst = resolve
})
const secondTrigger: DevTrigger = { ...trigger, triggerId: 'trg_2' }
const thirdTrigger: DevTrigger = { ...trigger, triggerId: 'trg_3' }
const runRecord = vi.fn((t: DevTrigger) =>
t.triggerId === 'trg_1' ? firstGate : Promise.resolve()
)
const deps = makeDeps({ runRecord, now: () => clock })
const seenReports: Array<{ triggerId: string; state: string }> = []
let polls = 0
deps.fetchMock.mockImplementation(
async (url: string, init?: RequestInit) => {
if (url.endsWith('/cli/dev/report')) {
seenReports.push(JSON.parse((init as RequestInit).body as string))
}
if (url.endsWith('/cli/dev/poll')) {
polls += 1
if (polls === 1) return jsonResponse({ trigger })
if (polls === 2) {
clock = 60_000 // Past the kill window: must queue, not kill.
return jsonResponse({ trigger: secondTrigger })
}
if (polls === 3) {
// A newer trigger replaces the queued one (latest wins).
return jsonResponse({ trigger: thirdTrigger })
}
if (polls === 4) {
releaseFirst()
return jsonResponse({})
}
// Stop only once the queued trigger has reported done.
if (
seenReports.some(
(r) => r.triggerId === 'trg_3' && r.state === 'done'
)
) {
controller.stopped = true
}
return jsonResponse({})
}
return jsonResponse({ ok: true })
}
)
await runDevListenLoop(config, deps, 'lst_1', controller)
// trg_1 completes, trg_2 was replaced in the queue, trg_3 runs after.
const recorded = runRecord.mock.calls.map(
(call) => (call[0] as DevTrigger).triggerId
)
expect(recorded).toEqual(['trg_1', 'trg_3'])
const reports = deps.fetchMock.mock.calls
.filter(([url]) => (url as string).endsWith('/cli/dev/report'))
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
const byTrigger = (id: string) =>
reports.filter((r) => r.triggerId === id).map((r) => r.state)
expect(byTrigger('trg_1')).toEqual(['running', 'done'])
expect(byTrigger('trg_2')).toEqual(['failed'])
expect(byTrigger('trg_3')).toEqual(['running', 'done'])
})
it('polls faster inside the activity window after receiving work', async () => {
const controller = { stopped: false }
let clock = 0
const sleep = vi.fn(async () => {})
const deps = makeDeps({ sleep, now: () => clock })
const fastConfig: DevListenConfig = {
...config,
pollIntervalMs: 2_500,
fastPollIntervalMs: 750,
fastPollWindowMs: 60_000,
}
let polls = 0
deps.fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/cli/dev/poll')) {
polls += 1
if (polls === 1) return jsonResponse({}) // idle: slow poll
if (polls === 2)
return jsonResponse({ codegenRequests: [codegenRequest] })
if (polls === 3) {
clock = 120_000 // Window elapsed: back to slow polling.
return jsonResponse({})
}
controller.stopped = true
return jsonResponse({})
}
return jsonResponse({ ok: true })
})
await runDevListenLoop(fastConfig, deps, 'lst_1', controller)
const delays = sleep.mock.calls.map((call) => call[0] as number)
expect(delays[0]).toBe(2_500) // idle
expect(delays[1]).toBe(750) // right after codegen activity
expect(delays[2]).toBe(2_500) // window elapsed
})
it('reports failed with the error message when the record throws', async () => {
const controller = { stopped: false }
const runRecord = vi.fn(async () => {
throw new Error('Playwright exited with code 1')
})
const deps = makeDeps({ runRecord })
deps.fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/cli/dev/poll')) {
if (deps.fetchMock.mock.calls.length === 1) {
return jsonResponse({ trigger })
}
controller.stopped = true
return jsonResponse({ trigger: null })
}
return jsonResponse({ ok: true })
})
await runDevListenLoop(config, deps, 'lst_1', controller)
const reports = deps.fetchMock.mock.calls
.filter(([url]) => (url as string).endsWith('/cli/dev/report'))
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
expect(reports.map((r) => r.state)).toEqual(['running', 'failed'])
expect(reports[1].errorMessage).toBe('Playwright exited with code 1')
})
it('keeps polling with a backoff after a network error', async () => {
const controller = { stopped: false }
const sleep = vi.fn(async () => {})
const deps = makeDeps({ sleep })
deps.fetchMock.mockImplementation(async () => {
if (deps.fetchMock.mock.calls.length === 1) {
throw new Error('ECONNREFUSED')
}
controller.stopped = true
return jsonResponse({ trigger: null })
})
await runDevListenLoop(config, deps, 'lst_1', controller)
expect(deps.logger.warn).toHaveBeenCalledWith(
expect.stringContaining('ECONNREFUSED')
)
expect(sleep).toHaveBeenCalledWith(config.errorBackoffMs)
expect(deps.fetchMock.mock.calls.length).toBe(2)
})
it('stops and propagates DevAuthError when the token is revoked', async () => {
const controller = { stopped: false }
const deps = makeDeps()
deps.fetchMock.mockResolvedValue(
jsonResponse({ error: 'Invalid editor token' }, 401)
)
await expect(
runDevListenLoop(config, deps, 'lst_1', controller)
).rejects.toBeInstanceOf(DevAuthError)
})
it('runs a local record request without touching the trigger channel', async () => {
const controller = { stopped: false }
const runLocalRecord = vi.fn(async () => {})
let request: { videoNames: string[] } | null = {
videoNames: ['Intro video'],
}
const takeLocalRequest = vi.fn(() => {
const taken = request
request = null
return taken
})
const deps = makeDeps({ runLocalRecord, takeLocalRequest })
deps.fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/cli/dev/poll')) {
if (runLocalRecord.mock.calls.length > 0) controller.stopped = true
return jsonResponse({})
}
return jsonResponse({ ok: true })
})
await runDevListenLoop(config, deps, 'lst_1', controller)
expect(runLocalRecord).toHaveBeenCalledWith(
['Intro video'],
expect.any(AbortSignal)
)
const triggerReports = deps.fetchMock.mock.calls.filter(([url]) =>
(url as string).endsWith('/cli/dev/report')
)
expect(triggerReports).toHaveLength(0)
})
it('merges queued local requests into one union re-record', async () => {
const controller = { stopped: false }
let clock = 0
const recorded: string[][] = []
let releaseFirst = () => {}
const runLocalRecord = vi.fn((videoNames: string[]) => {
recorded.push(videoNames)
if (recorded.length === 1) {
return new Promise<void>((resolve) => {
releaseFirst = resolve
})
}
return Promise.resolve()
})
const requests: Array<{ videoNames: string[] }> = [
{ videoNames: ['A'] },
{ videoNames: ['B'] },
{ videoNames: ['C', 'B'] },
]
const takeLocalRequest = vi.fn(() => requests.shift() ?? null)
const deps = makeDeps({
runLocalRecord,
takeLocalRequest,
now: () => clock,
})
deps.fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/cli/dev/poll')) {
// Keep the first record outside the kill window so newcomers queue.
clock = 60_000
if (recorded.length >= 1 && requests.length === 0) {
releaseFirst()
}
if (recorded.length >= 2) controller.stopped = true
return jsonResponse({})
}
return jsonResponse({ ok: true })
})
await runDevListenLoop(config, deps, 'lst_1', controller)
expect(recorded[0]).toEqual(['A'])
expect(recorded[1]).toEqual(['B', 'C'])
})
it('kills a young trigger record when a local request arrives', async () => {
const controller = { stopped: false }
let clock = 0
const runRecord = vi.fn(
(_t: DevTrigger, signal?: AbortSignal) =>
new Promise<void>((_resolve, reject) => {
// Like the real runner, honor a signal aborted before the spawn.
if (signal?.aborted) {
reject(new Error('Record aborted'))
return
}
signal?.addEventListener('abort', () =>
reject(new Error('Record aborted'))
)
})
)
const runLocalRecord = vi.fn(async () => {})
const requests: Array<{ videoNames: string[] } | null> = [
null,
{ videoNames: ['Intro video'] },
]
const takeLocalRequest = vi.fn(() =>
requests.length > 0 ? (requests.shift() ?? null) : null
)
const deps = makeDeps({
runRecord,
runLocalRecord,
takeLocalRequest,
now: () => clock,
})
let polls = 0
deps.fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/cli/dev/poll')) {
polls += 1
if (polls === 1) return jsonResponse({ trigger })
clock = 5_000 // Inside the 10s kill window.
if (runLocalRecord.mock.calls.length > 0) controller.stopped = true
return jsonResponse({})
}
return jsonResponse({ ok: true })
})
await runDevListenLoop(config, deps, 'lst_1', controller)
expect(runLocalRecord).toHaveBeenCalledWith(
['Intro video'],
expect.any(AbortSignal)
)
// The superseded trigger reported failed.
const reports = deps.fetchMock.mock.calls
.filter(([url]) => (url as string).endsWith('/cli/dev/report'))
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
expect(reports.some((r) => r.state === 'failed')).toBe(true)
})
it('aborts an active local record on shutdown', async () => {
const controller = { stopped: false }
let aborted = false
const runLocalRecord = vi.fn(
(_names: string[], signal?: AbortSignal) =>
new Promise<void>((_resolve, reject) => {
signal?.addEventListener('abort', () => {
aborted = true
reject(new Error('Record aborted'))
})
})
)
let request: { videoNames: string[] } | null = { videoNames: ['A'] }
const takeLocalRequest = vi.fn(() => {
const taken = request
request = null
return taken
})
const deps = makeDeps({ runLocalRecord, takeLocalRequest })
deps.fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/cli/dev/poll')) {
if (runLocalRecord.mock.calls.length > 0) controller.stopped = true
return jsonResponse({})
}
return jsonResponse({ ok: true })
})
await runDevListenLoop(config, deps, 'lst_1', controller)
expect(aborted).toBe(true)
})
})
describe('reportDevTrigger and deregisterDevListener', () => {
it('omits errorMessage unless provided', async () => {
const deps = makeDeps()
await reportDevTrigger(config, deps, 'lst_1', 'trg_1', 'done')
const body = JSON.parse(
(deps.fetchMock.mock.calls[0] as [string, RequestInit])[1].body as string
)
expect(body).toEqual({
projectName: 'demo',
listenerId: 'lst_1',
triggerId: 'trg_1',
state: 'done',
})
})
it('deregisters with the listener id', async () => {
const deps = makeDeps()
await deregisterDevListener(config, deps, 'lst_1')
const [url, init] = deps.fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('http://localhost:8787/cli/dev/deregister')
expect(JSON.parse(init.body as string).listenerId).toBe('lst_1')
})
})