2026-07-08 14:27:32 +03:00
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
|
import type {
|
2026-07-11 21:52:29 +03:00
|
|
|
DevCodegenRequest,
|
2026-07-08 14:27:32 +03:00
|
|
|
DevListenConfig,
|
|
|
|
|
DevListenDeps,
|
|
|
|
|
DevTrigger,
|
|
|
|
|
} from './src/devListen'
|
|
|
|
|
import {
|
|
|
|
|
DEV_TOKEN_HEADER,
|
|
|
|
|
DevAuthError,
|
2026-08-18 10:53:42 +03:00
|
|
|
ONE_SHOT_TRIGGER_MESSAGE,
|
2026-07-08 14:27:32 +03:00
|
|
|
deregisterDevListener,
|
2026-08-18 10:53:42 +03:00
|
|
|
drainDevCodegenRequests,
|
2026-07-08 14:27:32 +03:00
|
|
|
pollDevListener,
|
|
|
|
|
registerDevListener,
|
|
|
|
|
reportDevTrigger,
|
|
|
|
|
runDevListenLoop,
|
|
|
|
|
} from './src/devListen'
|
|
|
|
|
|
|
|
|
|
const config: DevListenConfig = {
|
|
|
|
|
apiUrl: 'http://localhost:8787',
|
2026-08-13 12:18:18 +03:00
|
|
|
credential: { header: 'X-ScreenCI-Secret', value: 'org-secret' },
|
2026-07-08 14:27:32 +03:00
|
|
|
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',
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 21:52:29 +03:00
|
|
|
const codegenRequest: DevCodegenRequest = {
|
|
|
|
|
requestId: 'cgr_1',
|
|
|
|
|
videoName: 'Intro video',
|
|
|
|
|
editId: 'delay1',
|
|
|
|
|
editJson: '{"kind":"paramEdit"}',
|
|
|
|
|
requiresRecord: true,
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 14:27:32 +03:00
|
|
|
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()
|
2026-07-22 00:33:31 +03:00
|
|
|
deps.fetchMock.mockResolvedValueOnce(jsonResponse({ listenerId: 'lst_1' }))
|
2026-07-08 14:27:32 +03:00
|
|
|
|
|
|
|
|
const result = await registerDevListener(config, deps)
|
|
|
|
|
|
2026-07-22 00:33:31 +03:00
|
|
|
expect(result).toEqual({ listenerId: 'lst_1' })
|
2026-07-08 14:27:32 +03:00
|
|
|
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(
|
2026-07-12 16:39:20 +03:00
|
|
|
jsonResponse({ error: 'Invalid editor token' }, 401)
|
2026-07-08 14:27:32 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await expect(registerDevListener(config, deps)).rejects.toBeInstanceOf(
|
|
|
|
|
DevAuthError
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('pollDevListener', () => {
|
2026-07-11 21:52:29 +03:00
|
|
|
it('returns the claimed trigger and codegen requests', async () => {
|
2026-07-08 14:27:32 +03:00
|
|
|
const deps = makeDeps()
|
2026-07-11 21:52:29 +03:00
|
|
|
deps.fetchMock.mockResolvedValueOnce(
|
|
|
|
|
jsonResponse({ trigger, codegenRequests: [codegenRequest] })
|
2026-07-08 14:27:32 +03:00
|
|
|
)
|
2026-07-11 21:52:29 +03:00
|
|
|
|
|
|
|
|
await expect(pollDevListener(config, deps, 'lst_1')).resolves.toEqual({
|
|
|
|
|
trigger,
|
|
|
|
|
codegenRequests: [codegenRequest],
|
|
|
|
|
})
|
2026-07-08 14:27:32 +03:00
|
|
|
})
|
|
|
|
|
|
2026-07-11 21:52:29 +03:00
|
|
|
it('returns an empty result when nothing is pending', async () => {
|
2026-07-08 14:27:32 +03:00
|
|
|
const deps = makeDeps()
|
|
|
|
|
deps.fetchMock.mockResolvedValueOnce(jsonResponse({ trigger: null }))
|
|
|
|
|
|
2026-07-11 21:52:29 +03:00
|
|
|
await expect(pollDevListener(config, deps, 'lst_1')).resolves.toEqual({
|
|
|
|
|
trigger: null,
|
|
|
|
|
codegenRequests: [],
|
|
|
|
|
})
|
2026-07-08 14:27:32 +03:00
|
|
|
})
|
2026-07-08 19:28:13 +03:00
|
|
|
|
|
|
|
|
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 }))
|
|
|
|
|
|
2026-07-11 21:52:29 +03:00
|
|
|
await expect(pollDevListener(config, deps, 'lst_1')).resolves.toEqual({
|
|
|
|
|
trigger: null,
|
|
|
|
|
codegenRequests: [],
|
|
|
|
|
})
|
2026-07-08 19:28:13 +03:00
|
|
|
})
|
2026-07-08 14:27:32 +03:00
|
|
|
})
|
|
|
|
|
|
2026-08-18 10:53:42 +03:00
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-08 14:27:32 +03:00
|
|
|
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)
|
|
|
|
|
|
2026-07-11 21:52:29 +03:00
|
|
|
expect(runRecord).toHaveBeenCalledWith(trigger, expect.any(AbortSignal))
|
2026-07-08 14:27:32 +03:00
|
|
|
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')
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-09 17:37:55 +03:00
|
|
|
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(
|
2026-07-11 21:52:29 +03:00
|
|
|
expect.objectContaining({ previewOnly: true }),
|
|
|
|
|
expect.any(AbortSignal)
|
2026-07-09 17:37:55 +03:00
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-11 21:52:29 +03:00
|
|
|
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',
|
|
|
|
|
}),
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-12 19:59:23 +03:00
|
|
|
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.'
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-13 14:04:09 +03:00
|
|
|
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' }),
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-13 01:54:50 +03:00
|
|
|
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)')
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-11 21:52:29 +03:00
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-08 14:27:32 +03:00
|
|
|
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(
|
2026-07-12 16:39:20 +03:00
|
|
|
jsonResponse({ error: 'Invalid editor token' }, 401)
|
2026-07-08 14:27:32 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runDevListenLoop(config, deps, 'lst_1', controller)
|
|
|
|
|
).rejects.toBeInstanceOf(DevAuthError)
|
|
|
|
|
})
|
2026-07-12 00:04:38 +03:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
})
|
2026-07-08 14:27:32 +03:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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')
|
|
|
|
|
})
|
|
|
|
|
})
|