diff --git a/cli.dev.spec.ts b/cli.dev.spec.ts index 3362e70..e8ba3e1 100644 --- a/cli.dev.spec.ts +++ b/cli.dev.spec.ts @@ -1,21 +1,11 @@ import { describe, expect, it, vi } from 'vitest' -import type { - DevCodegenRequest, - DevListenConfig, - DevListenDeps, - DevTrigger, -} from './src/devListen' +import type { DevListenConfig, DevListenDeps } from './src/devListen' import { DEV_TOKEN_HEADER, DevAuthError, - ONE_SHOT_TRIGGER_MESSAGE, deregisterDevListener, - drainDevCodegenRequests, - fetchPendingCodegenCount, - pollDevListener, registerDevListener, - reportDevTrigger, - runDevListenLoop, + reportDevSyncState, } from './src/devListen' const config: DevListenConfig = { @@ -24,24 +14,6 @@ const config: DevListenConfig = { 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 { @@ -60,8 +32,6 @@ function makeDeps(overrides: Partial = {}): DevListenDeps & { fetchMock, sleep: vi.fn(async () => {}), logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - runRecord: vi.fn(async () => {}), - setIntervalFn: vi.fn(() => () => {}), ...overrides, } } @@ -97,973 +67,29 @@ describe('registerDevListener', () => { }) }) -describe('pollDevListener', () => { - it('returns the claimed trigger and codegen requests', async () => { +describe('reportDevSyncState', () => { + it('reports the syncing video names for the listener', 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('fetchPendingCodegenCount', () => { - it('posts the project name and returns the pending count', async () => { - const deps = makeDeps() - deps.fetchMock.mockResolvedValueOnce(jsonResponse({ pending: 3 })) - - await expect(fetchPendingCodegenCount(config, deps)).resolves.toBe(3) - + await reportDevSyncState(config, deps, 'lst_1', ['Intro video']) const [url, init] = deps.fetchMock.mock.calls[0] as [string, RequestInit] - expect(url).toBe('http://localhost:8787/cli/dev/pending-codegen-count') - expect(JSON.parse(init.body as string)).toEqual({ projectName: 'demo' }) - }) - - it('treats a missing count as zero', async () => { - const deps = makeDeps() - deps.fetchMock.mockResolvedValueOnce(jsonResponse({})) - - await expect(fetchPendingCodegenCount(config, deps)).resolves.toBe(0) - }) -}) - -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, failed: 0, skipped: 0 }) - 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, failed: 0, skipped: 0 }) - }) - - it('counts orphaned (skipped) edits separately from synced ones', async () => { - const deps = makeDeps({ - applyCodegen: vi.fn(async () => ({ outcome: 'orphaned' as const })), - }) - 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: null, - codegenRequests: [codegenRequest], - }) - } - return jsonResponse({ trigger: null, codegenRequests: [] }) - } - return jsonResponse({ ok: true }) - }) - - // A skipped edit was never written into the sources, so it must not be - // counted into the "Synced N editor edits" summary. - await expect( - drainDevCodegenRequests(config, deps, 'lst_1') - ).resolves.toEqual({ handled: 0, failed: 0, skipped: 1 }) - }) - - it('counts failed edits separately from handled ones', async () => { - const deps = makeDeps({ - applyCodegen: vi.fn(async () => { - throw new Error('editId not found in source') - }), - }) - 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: null, - codegenRequests: [codegenRequest], - }) - } - return jsonResponse({ trigger: null, codegenRequests: [] }) - } - return jsonResponse({ ok: true }) - }) - - await expect( - drainDevCodegenRequests(config, deps, 'lst_1') - ).resolves.toEqual({ handled: 0, failed: 1, skipped: 0 }) - }) - - it('suppresses applied-edit lines when logAppliedEdits is false', async () => { - const deps = makeDeps({ - applyCodegen: vi.fn(async () => {}), - logAppliedEdits: false, - }) - 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: null, - codegenRequests: [codegenRequest], - }) - } - return jsonResponse({ trigger: null, codegenRequests: [] }) - } - return jsonResponse({ ok: true }) - }) - - const result = await drainDevCodegenRequests(config, deps, 'lst_1') - - expect(result).toEqual({ handled: 1, failed: 0, skipped: 0 }) - expect(deps.logger.info).not.toHaveBeenCalledWith( - expect.stringContaining('Applied') - ) - // The edit is still reported applied to the backend. - const reportCall = deps.fetchMock.mock.calls.find(([u]) => - String(u).endsWith('/cli/dev/report-codegen') - ) - const body = JSON.parse((reportCall?.[1] as RequestInit).body as string) - expect(body).toMatchObject({ requestId: 'cgr_1', state: 'applied' }) - }) - - it('suppresses unknown-video failure logs when the filter rejects the name', async () => { - const deps = makeDeps({ - applyCodegen: vi.fn(async () => { - throw new Error('refused [unknown-video]') - }), - shouldLogUnknownVideo: () => false, - }) - 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: null, - codegenRequests: [codegenRequest], - }) - } - return jsonResponse({ trigger: null, codegenRequests: [] }) - } - return jsonResponse({ ok: true }) - }) - - await drainDevCodegenRequests(config, deps, 'lst_1') - - expect(deps.logger.error).not.toHaveBeenCalled() - // The failure still reaches the backend so the editor marks the edit. - const reportCall = deps.fetchMock.mock.calls.find(([u]) => - String(u).endsWith('/cli/dev/report-codegen') - ) - expect(reportCall).toBeDefined() - const body = JSON.parse((reportCall?.[1] as RequestInit).body as string) - expect(body).toMatchObject({ requestId: 'cgr_1', state: 'failed' }) - }) - - it('logs unknown-video failures when the filter accepts the name', async () => { - const deps = makeDeps({ - applyCodegen: vi.fn(async () => { - throw new Error('refused [unknown-video]') - }), - shouldLogUnknownVideo: (videoName) => videoName === 'Intro video', - }) - 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: null, - codegenRequests: [codegenRequest], - }) - } - return jsonResponse({ trigger: null, codegenRequests: [] }) - } - return jsonResponse({ ok: true }) - }) - - await drainDevCodegenRequests(config, deps, 'lst_1') - - expect(deps.logger.error).toHaveBeenCalledWith( - expect.stringContaining('no video with that name is declared') - ) - }) - - it('still logs other codegen failures without a filter', async () => { - const deps = makeDeps({ - applyCodegen: vi.fn(async () => { - throw new Error('some other apply failure') - }), - shouldLogUnknownVideo: () => false, - }) - 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: null, - codegenRequests: [codegenRequest], - }) - } - return jsonResponse({ trigger: null, codegenRequests: [] }) - } - return jsonResponse({ ok: true }) - }) - - await drainDevCodegenRequests(config, deps, 'lst_1') - - expect(deps.logger.error).toHaveBeenCalledWith( - expect.stringContaining('some other apply failure') - ) - }) - - 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).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((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((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((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((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((_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((_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({ + expect(url).toBe('http://localhost:8787/cli/dev/sync-state') + expect(JSON.parse(init.body as string)).toEqual({ projectName: 'demo', listenerId: 'lst_1', - triggerId: 'trg_1', - state: 'done', + syncingVideoNames: ['Intro video'], }) }) + it('tolerates an empty 2xx body', async () => { + const deps = makeDeps() + deps.fetchMock.mockResolvedValueOnce(new Response('', { status: 200 })) + await expect( + reportDevSyncState(config, deps, 'lst_1', []) + ).resolves.toBeUndefined() + }) +}) + +describe('deregisterDevListener', () => { it('deregisters with the listener id', async () => { const deps = makeDeps() await deregisterDevListener(config, deps, 'lst_1') diff --git a/cli.edit.spec.ts b/cli.edit.spec.ts deleted file mode 100644 index de56a0a..0000000 --- a/cli.edit.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { resolveSingleEditVideo } from './cli' - -const suggest = (name: string) => `screenci edit "${name}"` - -describe('resolveSingleEditVideo', () => { - it('resolves the only video of a project without a pattern', () => { - expect(resolveSingleEditVideo(['Login'], undefined, suggest)).toEqual({ - ok: true, - videoName: 'Login', - }) - }) - - it('resolves a pattern matching exactly one video', () => { - const result = resolveSingleEditVideo(['Login', 'Signup'], 'Sign', suggest) - expect(result).toEqual({ ok: true, videoName: 'Signup' }) - }) - - it('errors with the available titles when nothing matches', () => { - const result = resolveSingleEditVideo(['Login', 'Signup'], 'Nope', suggest) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('No video matches "Nope"') - expect(result.message).toContain('- Login') - expect(result.message).toContain('- Signup') - } - }) - - it('errors listing the matches when the pattern matches several videos', () => { - const result = resolveSingleEditVideo( - ['Login flow', 'Login error', 'Signup'], - 'Login', - suggest - ) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('one video at a time') - expect(result.message).toContain('- Login flow') - expect(result.message).toContain('- Login error') - expect(result.message).not.toContain('- Signup') - expect(result.message).toContain('screenci edit "Login flow"') - } - }) - - it('errors asking to pick when the project has several videos and no pattern', () => { - const result = resolveSingleEditVideo(['A', 'B'], undefined, suggest) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('one video at a time') - expect(result.message).toContain('screenci edit "A"') - } - }) - - it('errors when the project has no videos at all', () => { - const result = resolveSingleEditVideo([], 'Login', suggest) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('No videos found') - } - }) -}) diff --git a/cli.export-command.spec.ts b/cli.export-command.spec.ts index d397b4d..bf3724e 100644 --- a/cli.export-command.spec.ts +++ b/cli.export-command.spec.ts @@ -880,6 +880,7 @@ describe('CLI', () => { recordId: expect.any(String), hadFailures: false, uploadedVideoNames: expect.any(Array), + uploadedVideos: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], @@ -1573,13 +1574,26 @@ describe('CLI', () => { expect(result.uploadedVideoNames).toEqual(['Demo']) }) - it('formats preview URLs', async () => { + it('formats video overview URLs', async () => { const { formatPreviewUrl } = await import('./cli') expect( formatPreviewUrl('https://app.screenci.test', 'project_1', 'video_2') + ).toBe('https://app.screenci.test/project/project_1/video/video_2') + }) + + it('formats single-video export URLs with the run preselected', async () => { + const { formatVideoExportUrl } = await import('./cli') + + expect( + formatVideoExportUrl( + 'https://app.screenci.test', + 'project_1', + 'video_2', + 'rec_3' + ) ).toBe( - 'https://app.screenci.test/project/project_1/video/video_2/preview' + 'https://app.screenci.test/project/project_1/video/video_2?export=rec_3' ) }) @@ -1895,6 +1909,7 @@ describe('CLI', () => { recordId: null, hadFailures: false, uploadedVideoNames: expect.any(Array), + uploadedVideos: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], @@ -1935,6 +1950,7 @@ describe('CLI', () => { recordId: expect.any(String), hadFailures: true, uploadedVideoNames: expect.any(Array), + uploadedVideos: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], @@ -2030,6 +2046,7 @@ describe('CLI', () => { recordId: expect.any(String), hadFailures: false, uploadedVideoNames: expect.any(Array), + uploadedVideos: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], @@ -2136,6 +2153,7 @@ describe('CLI', () => { recordId: expect.any(String), hadFailures: true, uploadedVideoNames: expect.any(Array), + uploadedVideos: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], @@ -2346,6 +2364,7 @@ describe('CLI', () => { recordId: expect.any(String), hadFailures: false, uploadedVideoNames: expect.any(Array), + uploadedVideos: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], @@ -2556,6 +2575,7 @@ describe('CLI', () => { recordId: expect.any(String), hadFailures: true, uploadedVideoNames: expect.any(Array), + uploadedVideos: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], @@ -2613,6 +2633,7 @@ describe('CLI', () => { recordId: null, hadFailures: true, uploadedVideoNames: expect.any(Array), + uploadedVideos: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], @@ -2773,6 +2794,7 @@ describe('CLI', () => { recordId: expect.any(String), hadFailures: false, uploadedVideoNames: expect.any(Array), + uploadedVideos: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], @@ -2853,6 +2875,7 @@ describe('CLI', () => { recordId: expect.any(String), hadFailures: false, uploadedVideoNames: expect.any(Array), + uploadedVideos: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], @@ -2959,6 +2982,8 @@ describe('CLI', () => { recordId: expect.any(String), hadFailures: false, uploadedVideoNames: expect.any(Array), + uploadedVideos: expect.any(Array), + uploadedVideos: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], diff --git a/cli.init.spec.ts b/cli.init.spec.ts index 824da96..a271771 100644 --- a/cli.init.spec.ts +++ b/cli.init.spec.ts @@ -516,7 +516,7 @@ describe('CLI', () => { expect(islandPkg['name']).toBe('my-project') expect(islandPkg['scripts']).toMatchObject({ test: 'screenci test', - edit: 'screenci edit', + preview: 'screenci preview', export: 'screenci export', }) expect(islandPkg['scripts']).not.toHaveProperty('screenci') @@ -914,9 +914,7 @@ describe('CLI', () => { expect(workflowCall?.[1]).toContain( 'run: pnpm exec playwright install --only-shell chromium' ) - expect(workflowCall?.[1]).toContain( - 'pnpm exec screenci preview --no-sync' - ) + expect(workflowCall?.[1]).toContain('pnpm exec screenci preview') // Export stays available as a commented-out alternative. expect(workflowCall?.[1]).toContain( '# pnpm exec screenci export --no-wait' @@ -929,7 +927,7 @@ describe('CLI', () => { // Targeted recordings: optional `grep` input forwarded to record. expect(workflowCall?.[1]).toContain('SCREENCI_GREP: ${{ inputs.grep }}') expect(workflowCall?.[1]).toContain( - 'pnpm exec screenci preview --no-sync --grep "$SCREENCI_GREP"' + 'pnpm exec screenci preview --grep "$SCREENCI_GREP"' ) expect(workflowCall?.[1]).toMatch(/workflow_dispatch:\s*\n\s*inputs:/) }) @@ -1091,9 +1089,9 @@ describe('CLI', () => { expect(workflowCall?.[1]).toContain( 'run: yarn playwright install --only-shell chromium' ) - expect(workflowCall?.[1]).toContain('yarn screenci preview --no-sync') + expect(workflowCall?.[1]).toContain('yarn screenci preview') expect(workflowCall?.[1]).toContain( - 'yarn screenci preview --no-sync --grep "$SCREENCI_GREP"' + 'yarn screenci preview --grep "$SCREENCI_GREP"' ) }) diff --git a/cli.misc.spec.ts b/cli.misc.spec.ts index f66701d..6114cd0 100644 --- a/cli.misc.spec.ts +++ b/cli.misc.spec.ts @@ -577,42 +577,6 @@ describe('CLI', () => { expect(processExitSpy).toHaveBeenCalledWith(1) }) - // --no-sync (CI runners keep their checkout read-only) must parse as a - // known preview option; the command still exits deterministically on the - // expired trial session. - it('accepts --no-sync for the preview command', async () => { - process.argv = ['node', 'cli.js', 'preview', '--no-sync'] - delete process.env.SCREENCI_SECRET - mockFetch.mockImplementation(async (input: string | URL) => { - if (String(input).endsWith('/cli/anon-session-status')) { - return { - ok: true, - status: 200, - json: vi.fn().mockResolvedValue({ status: 'expired' }), - text: vi.fn().mockResolvedValue(''), - } - } - return { - ok: true, - status: 200, - json: vi.fn().mockResolvedValue({}), - text: vi.fn().mockResolvedValue(''), - } - }) - - const { main } = await import('./cli') - - await expect(main()).rejects.toThrow('process.exit called') - - expect(loggerErrorSpy).not.toHaveBeenCalledWith( - expect.stringContaining('unknown option') - ) - expect(loggerErrorSpy).toHaveBeenCalledWith( - expect.stringContaining('trial has expired') - ) - expect(processExitSpy).toHaveBeenCalledWith(1) - }) - it('should show global help with --help', async () => { process.argv = ['node', 'cli.js', '--help'] const stdoutSpy = vi @@ -1199,84 +1163,4 @@ describe('CLI', () => { ) }) }) - - describe('createQueuedEditsDrainer', () => { - const drainerConfig = { - apiUrl: 'http://localhost:8787', - credential: { header: 'X-ScreenCI-Secret', value: 'org-secret' }, - devToken: 'dev-token', - projectName: 'demo', - machineName: 'laptop', - } - - function makeDrainerDeps() { - const fetchMock = vi.fn( - async () => - new Response(JSON.stringify({ codegenRequests: [] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) - return { - fetchMock, - deps: { - fetchFn: fetchMock as unknown as typeof fetch, - sleep: vi.fn(async () => {}), - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - runRecord: vi.fn(async () => {}), - }, - } - } - - it('drains queued edits when sync is enabled', async () => { - const { createQueuedEditsDrainer } = await import('./cli') - const { fetchMock, deps } = makeDrainerDeps() - - const drain = createQueuedEditsDrainer({ - syncEnabled: true, - config: drainerConfig, - deps, - listenerId: 'lst_1', - }) - await drain() - - expect(fetchMock).toHaveBeenCalled() - }) - - // preview --no-sync: CI checkouts stay read-only, so the drainer must not - // even reach the server for queued edits. - it('is a no-op when sync is disabled', async () => { - const { createQueuedEditsDrainer } = await import('./cli') - const { fetchMock, deps } = makeDrainerDeps() - - const drain = createQueuedEditsDrainer({ - syncEnabled: false, - config: drainerConfig, - deps, - listenerId: 'lst_1', - }) - await drain() - - expect(fetchMock).not.toHaveBeenCalled() - expect(deps.logger.warn).not.toHaveBeenCalled() - }) - - it('warns instead of throwing when the drain fails', async () => { - const { createQueuedEditsDrainer } = await import('./cli') - const { fetchMock, deps } = makeDrainerDeps() - fetchMock.mockRejectedValueOnce(new Error('network down')) - - const drain = createQueuedEditsDrainer({ - syncEnabled: true, - config: drainerConfig, - deps, - listenerId: 'lst_1', - }) - await expect(drain()).resolves.toBeUndefined() - - expect(deps.logger.warn).toHaveBeenCalledWith( - expect.stringContaining('Could not sync queued editor edits') - ) - }) - }) }) diff --git a/cli.ts b/cli.ts index 82c0cae..865f4e8 100644 --- a/cli.ts +++ b/cli.ts @@ -70,11 +70,6 @@ import { persistScreenCISecret, } from './src/linkSession.js' import { OVERLAY_CACHE_DIR_NAME } from './src/htmlRasterizer.js' -import { collectEditableFromRecordings } from './src/editableSnapshot.js' -import { - applyCodegenRequest, - requireTypescriptForCodegen, -} from './src/applyCodegen.js' import { createProjectFormatter } from './src/format.js' import { grepMatcher, @@ -82,20 +77,12 @@ import { type DevStartupDeps, type KeptRecording, } from './src/devStartup.js' -import { - buildWatchTargets, - startDevWatcher, - type DevWatchDeps, - type DevWatcherController, -} from './src/devWatch.js' import { entriesFromRecordingData } from './src/editableSnapshot.js' import type { EditableSnapshotEntry } from './src/editableSnapshot.js' import { LAST_DATA_FILE, readKeptRecordingData, - rebaselineKeptSourceHashes, } from './src/recordingFreshness.js' -import { createRebaselineGate } from './src/rebaselineGate.js' import type { RecordingData as KeptRecordingData } from './src/recordingData.js' import { downloadExportOutputs, @@ -132,25 +119,19 @@ import { formatAnonPostRecordNotice, formatAnonTermsNotice, getOrCreateAnonToken, - peekAnonToken, secretCredential, } from './src/anonSession.js' import { type DevListenConfig, type DevListenDeps, - type LocalRecordRequest, DevAuthError, SCREENCI_EDIT_TOKEN_ENV, deregisterDevListener, - drainDevCodegenRequests, registerDevListener, - fetchPendingCodegenCount, reportDevSyncState, - runDevListenLoop, } from './src/devListen.js' import { dedupeAppliedStudioNotices, - formatDrainSummary, formatStudioNoticeLine, } from './src/previewOutput.js' import { exchangeEditToken } from './src/editTokenExchange.js' @@ -365,7 +346,7 @@ function logScreenCISecretGuide(): void { } function getSuggestedScreenciCommand( - command: 'preview' | 'export' | 'test' | 'sync', + command: 'preview' | 'export' | 'test', flags = '' ): string { const suffix = flags ? ` ${flags}` : '' @@ -676,9 +657,20 @@ export function formatPreviewUrl( projectId: string, videoId: string ): string { - // The preview page resolves a default language itself, so we never need to - // guess the language in the printed link. - return `${appUrl}/project/${projectId}/video/${videoId}/preview` + // The video overview page resolves a default language itself, so we never + // need to guess the language in the printed link. + return `${appUrl}/project/${projectId}/video/${videoId}` +} + +export function formatVideoExportUrl( + appUrl: string, + projectId: string, + videoId: string, + recordId: string +): string { + // Points at the video overview page with the export run preselected; the + // page resolves the run to its newest version once the render lands. + return `${appUrl}/project/${projectId}/video/${videoId}?export=${recordId}` } export function formatRecordResultMessage(options: { @@ -2565,6 +2557,8 @@ export async function uploadRecordings( recordId: string | null hadFailures: boolean uploadedVideoNames: string[] + /** One entry per uploaded (base) video, with its server-side id when known. */ + uploadedVideos: Array<{ baseVideoName: string; videoId: string | null }> failedVideoNames: string[] failedVideoMessages: Array<{ videoName: string; message: string }> studioNotices: StudioUploadNotice[] @@ -2584,6 +2578,7 @@ export async function uploadRecordings( recordId: null, hadFailures: false, uploadedVideoNames: [], + uploadedVideos: [], failedVideoNames: [], failedVideoMessages: [], studioNotices: [], @@ -2634,6 +2629,7 @@ export async function uploadRecordings( recordId: null, hadFailures: missingRequestedVideoNames.length > 0, uploadedVideoNames: [], + uploadedVideos: [], failedVideoNames: missingRequestedVideoNames, failedVideoMessages: missingRequestedVideoNames.map((videoName) => ({ videoName, @@ -2656,6 +2652,7 @@ export async function uploadRecordings( recordId: null, hadFailures: true, uploadedVideoNames: [], + uploadedVideos: [], failedVideoNames: filteredCandidates.map( (candidate) => candidate.displayVideoName ), @@ -2717,6 +2714,16 @@ export async function uploadRecordings( .map((result) => result.baseVideoName) ), ] + const uploadedVideos = uploadedVideoNames.map((baseVideoName) => ({ + baseVideoName, + videoId: + results.find( + (result) => + !result.hadFailure && + result.baseVideoName === baseVideoName && + result.videoId !== null + )?.videoId ?? null, + })) const failedVideoNames = results .filter((result) => result.hadFailure) .map((result) => result.videoName) @@ -2752,6 +2759,7 @@ export async function uploadRecordings( recordId, hadFailures, uploadedVideoNames, + uploadedVideos, failedVideoNames, failedVideoMessages, studioNotices, @@ -3297,82 +3305,6 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } -// Runs the record for one web-editor dev trigger: exactly one video (grep by -// its title) in exactly one language, then uploads. Mirrors the `record` -// command's local path, minus its remote/UPLOAD_EXISTING/hint concerns; the -// error message is reported back to the editor instead of the console. -async function runTriggeredRecord( - configPath: string | undefined, - trigger: { videoName: string; language: string; previewOnly?: boolean }, - verbose: boolean, - abortSignal?: AbortSignal -): Promise { - const resolvedConfigPath = resolveScreenCIConfigPathOrExit(configPath) - const screenciConfig = - await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath) - const screenciDir = resolve(dirname(resolvedConfigPath), '.screenci') - const grepArgs = ['--grep', escapeRegExp(trigger.videoName)] - const requestedVideoNames = await collectRequestedRecordVideoNames( - resolvedConfigPath, - grepArgs, - trigger.language - ) - const recordRunLock = await acquireRecordRunLock( - screenciDir, - screenciConfig.projectName - ) - - try { - let playwrightFailure: Error | null = null - try { - await run( - 'record', - grepArgs, - configPath, - verbose, - false, - trigger.language, - abortSignal - ) - } catch (error) { - // A killed (superseded) run uploads nothing: the replacing record is - // already on its way and would race this upload. - if (error instanceof RecordAbortedError) throw error - if (!(error instanceof Error)) throw error - playwrightFailure = error - } - - // A preview-only trigger uploads into the video's preview slot without a - // render. The upload body reads this from the env, mirroring - // SCREENCI_SKIP_RENDER; scope it to this upload and restore it after. - const previousPreviewOnly = process.env['SCREENCI_PREVIEW_ONLY'] - if (trigger.previewOnly === true) { - process.env['SCREENCI_PREVIEW_ONLY'] = '1' - } - try { - await uploadRecordedVideosForConfig( - configPath, - playwrightFailure, - verbose, - requestedVideoNames, - 'none' - ) - } finally { - if (previousPreviewOnly === undefined) { - delete process.env['SCREENCI_PREVIEW_ONLY'] - } else { - process.env['SCREENCI_PREVIEW_ONLY'] = previousPreviewOnly - } - } - - if (playwrightFailure !== null) { - throw playwrightFailure - } - } finally { - await recordRunLock.release() - } -} - // One preview record pass over the videos matching `grepPattern` (all when // undefined): records, then uploads into the preview slots without a render. // Used by the dev startup handshake to bring stale recordings up to date. @@ -3442,9 +3374,6 @@ async function runPreviewRecordPass( if (playwrightFailure !== null) { throw playwrightFailure } - // Fresh footage now covers the sources: record-requiring rewrites are - // captured, so render-time re-baselines are safe again. - codegenRebaselineGate.clearAfterRecord() } finally { await recordRunLock.release() } @@ -3501,7 +3430,7 @@ async function runExportCommand(options: ExportCommandOptions): Promise { // Exporting needs an account: a claimed trial self-upgrades to the real // secret here (resolveUploadCredential persists it into .env), everything // else is refused up front. The anonymous trial is preview-only: point at - // `screenci edit` for the free live preview and at sign-up for exporting. + // `screenci preview` for the free live preview and at sign-up for exporting. let secret = process.env.SCREENCI_SECRET if (!secret) { const envFilePath = await resolveProjectEnvFilePath(resolvedConfigPath) @@ -3795,57 +3724,10 @@ function printExportSummary( } /** - * Resolves the single video a `screenci preview --watch` session manages. The - * live bridge deep-links the web editor for one video, so the pattern must - * match exactly one; zero or many matches exit with the matching/available - * titles listed. A one-shot `screenci preview` has no such limit. - */ -export function resolveSingleEditVideo( - allVideoNames: readonly string[], - grep: string | undefined, - suggestCommand: (name: string) => string -): { ok: true; videoName: string } | { ok: false; message: string } { - const formatList = (names: readonly string[]): string => - names.map((name) => ` - ${name}`).join('\n') - if (allVideoNames.length === 0) { - return { - ok: false, - message: - 'No videos found. Declare one with video(...) in your recordings.', - } - } - const matches = - grep === undefined - ? [...allVideoNames] - : allVideoNames.filter(grepMatcher(grep)) - if (matches.length === 1) { - return { ok: true, videoName: matches[0]! } - } - if (matches.length === 0) { - return { - ok: false, - message: - `No video matches "${grep}". Available videos:\n` + - formatList(allVideoNames), - } - } - const intro = - grep === undefined - ? `screenci preview --watch manages one video at a time. This project has ${matches.length} videos:` - : `screenci preview --watch manages one video at a time. "${grep}" matches ${matches.length} videos:` - return { - ok: false, - message: - `${intro}\n` + - formatList(matches) + - `\nPick one, e.g. ${suggestCommand(matches[0]!)}`, - } -} - -/** - * Best-effort editor deep link for the edited video: resolves projectId and - * videoId from `/cli/info` and prints the studio URL. A video the server does - * not know yet (first upload still pending or failed) prints nothing. + * Best-effort overview-page link for the recorded video: resolves projectId + * and videoId from `/cli/info` and prints the video overview URL. A video the + * server does not know yet (first upload still pending or failed) prints + * nothing. */ async function printEditorLink(params: { apiUrl: string @@ -3879,91 +3761,6 @@ async function printEditorLink(params: { } } -/** - * Builds the codegen-apply dependency shared by the live bridge and one-shot - * syncs: locates the target call site by editId and rewrites the test source - * via static analysis, formatting with the project's Prettier. - */ -/** Session-wide guard: files a record-requiring codegen edit rewrote stay - * stale (excluded from render-time re-baselines) until a record runs. */ -const codegenRebaselineGate = createRebaselineGate() - -function createApplyCodegenDep( - configOption: string | undefined, - onFileWritten: (path: string) => void -): NonNullable { - return async (request) => { - const resolvedConfigPath = resolveScreenCIConfigPathOrExit(configOption) - const screenciDir = resolve(dirname(resolvedConfigPath), '.screenci') - const ts = requireTypescriptForCodegen(loadTypescript, dirname(screenciDir)) - return await applyCodegenRequest(request, { - ts, - formatFile: createProjectFormatter(dirname(resolvedConfigPath), { - warn: (message) => logger.warn(message), - }), - readFile: (path) => { - try { - return readFileSync(path, 'utf8') - } catch { - return null - } - }, - writeFile: (path, content) => { - writeFileSync(path, content) - onFileWritten(path) - }, - editableSnapshot: { - version: 1, - videos: collectEditableFromRecordings(screenciDir), - }, - // Last-resort declaration lookup for a video that was edited before it - // was cleanly recorded (no snapshot source file). Scanned lazily and - // memoized so the common path (snapshot hit) pays nothing. - listRecordingFiles: (() => { - let cached: string[] | null = null - return () => - (cached ??= listScreenciSourceFiles(dirname(resolvedConfigPath))) - })(), - // A record-requiring edit (e.g. the language set) must leave its file - // stale until the next record; the gate keeps a later render-time - // edit's re-baseline from re-hashing that file and swallowing the - // needed record. - onRecordRequiredRewrite: (changedPaths) => - codegenRebaselineGate.noteRecordRequired(changedPaths), - // An applied render-time edit keeps the recorded footage valid, so the - // kept recordings are re-baselined to the rewritten sources: applying a - // web edit must never mark a recording stale or cause a re-record. - onSourcesRewritten: async (changedPaths) => { - const rebaselinable = - codegenRebaselineGate.rebaselinablePaths(changedPaths) - if (rebaselinable.length === 0) return - try { - const videoNames = await rebaselineKeptSourceHashes({ - screenciDir, - changedSourcePaths: rebaselinable, - }) - await updateLastUploadSourceHashes(screenciDir, videoNames) - } catch (err) { - logger.warn( - `Could not re-baseline recordings after the edit: ${err instanceof Error ? err.message : String(err)}` - ) - } - }, - resolveDuplicateEditIds: async (paths) => - (await resolveDuplicateEditIdsInSources(paths, { - screenciDir, - projectDir: dirname(screenciDir), - log: (message) => logger.info(message), - warn: (message) => logger.warn(message), - formatFile: createProjectFormatter(dirname(resolvedConfigPath), { - warn: (message) => logger.warn(message), - }), - onFileWritten, - })) > 0, - }) - } -} - /** * Stamps missing editIds into the sources from the kept recordings, outside * a live edit session. Run after `screenci test` and before `screenci @@ -4034,220 +3831,19 @@ export async function stampEditIdsForProject( return plan.stamped.length } -/** - * Best-effort one-shot drain of queued browser edits into the sources, - * without recording anything. Run by `screenci sync` and at the start of - * `test`/`export` so code and editor state converge at every CLI touchpoint. - * - * Deliberately silent when the project has no usable credentials: it never - * creates an anonymous session or prompts, and network failures only warn - * (never block the host command). - */ -/** - * Resolves the dev-channel config (project, credentials, editor token) for a - * one-shot touchpoint without side effects beyond minting a missing editor - * token from the org secret. Null when the project is not connected yet (no - * config, no credentials): callers skip silently. - */ -async function resolveQuickDevConfig( - options: { config?: string } = {}, - depsOverride: Partial & { machineName?: string } = {} -): Promise { - let projectName: string - let configPath: string - try { - const loaded = await loadScreenCIConfigAndEnv(options.config) - projectName = loaded.screenciConfig.projectName - configPath = loaded.resolvedConfigPath - } catch { - return null - } - const apiUrl = getDevBackendUrl() - const screenciDir = resolve(dirname(configPath), '.screenci') - - // Resolve credentials without side effects: an org secret (minting this - // machine's editor token when missing), or an anon session that already - // exists on disk. Anything else: skip silently. - let credential: CliCredential - let devToken: string - const secretFromEnv = process.env.SCREENCI_SECRET - if (secretFromEnv) { - let editorToken = process.env[SCREENCI_EDIT_TOKEN_ENV] - if (!editorToken) { - const exchanged = await exchangeEditToken({ - apiUrl, - secret: secretFromEnv, - machineName: depsOverride.machineName ?? hostname(), - ...(depsOverride.fetchFn ? { fetchFn: depsOverride.fetchFn } : {}), - }) - if (!exchanged.ok) return null - editorToken = exchanged.editToken - const envFilePath = await resolveProjectEnvFilePath(configPath) - await persistScreenCIEditToken(envFilePath, editorToken) - process.env[SCREENCI_EDIT_TOKEN_ENV] = editorToken - } - credential = secretCredential(secretFromEnv) - devToken = editorToken - } else { - const anonToken = await peekAnonToken(screenciDir) - if (anonToken === null) return null - credential = anonCredential(anonToken) - devToken = anonToken - } - - return { - apiUrl, - credential, - devToken, - projectName, - machineName: depsOverride.machineName ?? hostname(), - } -} - -/** - * Warns when editor edits are still queued for this project without applying - * them. `screenci export` uses this instead of a sync: an export renders - * exactly what the sources say, so edits are never silently written during - * one. Best-effort: any failure (offline, not connected) stays silent. - */ -export async function warnUnsyncedEditsBeforeExport( - options: { config?: string } = {}, - depsOverride: Partial & { machineName?: string } = {}, - /** - * export: exports never sync, so pending edits get a warning with the fix. - * no-sync: the user opted out (preview --no-sync), so pending edits only - * get a short informational note. - */ - mode: 'export' | 'no-sync' = 'export' -): Promise { - try { - const config = await resolveQuickDevConfig(options, depsOverride) - if (config === null) return - const pending = await fetchPendingCodegenCount(config, { - fetchFn: depsOverride.fetchFn ?? fetch, - }) - if (pending > 0) { - if (mode === 'no-sync') { - logger.info( - `${pending} editor edit${pending === 1 ? '' : 's'} left queued (--no-sync).` - ) - } else { - logger.warn( - `${pending} editor edit${pending === 1 ? ' is' : 's are'} not yet in your sources; run ${pc.cyan(getSuggestedScreenciCommand('sync'))} or ${pc.cyan(getSuggestedScreenciCommand('preview'))} first. Exporting without ${pending === 1 ? 'it' : 'them'}.` - ) - } - } - } catch { - // Best-effort peek: never block or fail the run over it. - } -} - -/** - * Builds the queued-edits drainer an edit session runs on connect and, in - * one-shot mode, once more before disconnecting. With syncEnabled false - * (preview --no-sync, CI runners) the drainer is a no-op so the checkout - * stays read-only; queued edits remain on the server for the next syncing - * run. Drain failures only warn: a broken sync should not take down the - * session. - */ -export function createQueuedEditsDrainer(params: { - syncEnabled: boolean - config: DevListenConfig - deps: DevListenDeps - listenerId: string -}): () => Promise { - const { syncEnabled, config, deps, listenerId } = params - return async () => { - if (!syncEnabled) return - try { - const result = await drainDevCodegenRequests(config, deps, listenerId) - const summary = formatDrainSummary(result) - if (summary !== null) deps.logger.info(summary) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - deps.logger.warn(`Could not sync queued editor edits: ${message}`) - } - } -} - -export async function runQuickEditSync( - options: { - config?: string - /** Suppress failure warnings (the drain summary always shows). */ - quiet?: boolean - } = {}, - depsOverride: Partial & { machineName?: string } = {} -): Promise<{ handled: number; failed: number; skipped: number } | null> { - const config = await resolveQuickDevConfig(options, depsOverride) - if (config === null) return null - const deps: DevListenDeps = { - fetchFn: fetch, - sleep: (ms) => new Promise((r) => setTimeout(r, ms)), - logger, - runRecord: async () => { - throw new Error('One-shot sync cannot record') - }, - applyCodegen: createApplyCodegenDep(options.config, () => {}), - // One-shot: the drain summary is the feedback; per-edit lines are for - // live --watch sessions. Skipped and failed edits still log. - logAppliedEdits: false, - ...depsOverride, - } - - try { - const registration = await registerDevListener(config, deps) - try { - const result = await drainDevCodegenRequests( - config, - deps, - registration.listenerId - ) - const summary = formatDrainSummary(result) - if (summary !== null) logger.info(summary) - return result - } finally { - await deregisterDevListener(config, deps, registration.listenerId).catch( - () => {} - ) - } - } catch (error) { - if (options.quiet !== true) { - const message = error instanceof Error ? error.message : String(error) - logger.warn(`Could not sync queued editor edits: ${message}`) - } - return null - } -} - export async function runDevCommand( options: { config?: string verbose?: boolean token?: string - recordKillWindow?: string grep?: string - /** The single video this edit session manages (editor deep link). */ + /** The single video this preview run records (overview deep link). */ videoName?: string forceRecord?: boolean - /** False disables the source-file watcher (--no-watch). */ - watch?: boolean - /** - * One-shot mode (`edit` without --watch): sync queued browser edits into - * the sources, bring previews up to date, print the editor link, and - * exit instead of staying connected as the live bridge. - */ - once?: boolean - /** - * False skips draining queued editor edits into the sources (--no-sync). - * Meant for CI runners, whose checkouts must stay read-only; the edits - * stay queued for the next syncing preview/sync/test run. - */ - sync?: boolean }, depsOverride: Partial & { machineName?: string startupDeps?: Partial - watchDeps?: Partial } = {} ): Promise { const { resolvedConfigPath: authConfigPath, screenciConfig } = @@ -4353,7 +3949,7 @@ export async function runDevCommand( } } - let auth = await resolveDevAuth() + const auth = await resolveDevAuth() // Whether the user scoped this session with --grep themselves. The // resolution below may auto-narrow options.grep to a single video name; @@ -4361,12 +3957,9 @@ export async function runDevCommand( // user's own grep, not the auto-narrowed one. const userGrep = options.grep - // A --watch live bridge deep-links the web editor for a single video, so - // the pattern must resolve to exactly one; anything else lists the - // candidates and exits. A one-shot preview records ANY number of matched - // videos: a single match still deep-links its preview page, several link - // the run listing (/preview/:recordId). Skipped when the caller (tests) - // pre-resolved the video name. + // A preview records ANY number of matched videos: a single match deep-links + // its overview page, several link the run listing (/preview/:recordId). + // Skipped when the caller (tests) pre-resolved the video name. if (options.videoName === undefined) { const editConfigPath = resolveScreenCIConfigPathOrExit(options.config) const allVideoNames = await collectRequestedRecordVideoNames( @@ -4374,94 +3967,37 @@ export async function runDevCommand( [], undefined ) - if (options.once === true) { - const matches = + const matches = + options.grep === undefined + ? allVideoNames + : allVideoNames.filter(grepMatcher(options.grep)) + if (matches.length === 0) { + logger.error( options.grep === undefined - ? allVideoNames - : allVideoNames.filter(grepMatcher(options.grep)) - if (matches.length === 0) { - logger.error( - options.grep === undefined - ? 'No videos found. Declare one with video(...) in your recordings.' - : `No video matches "${options.grep}". Available videos:\n` + - allVideoNames.map((name) => ` - ${name}`).join('\n') - ) - process.exit(1) - } - if (matches.length === 1) { - options.videoName = matches[0]! - options.grep = escapeRegExp(matches[0]!) - } - } else { - const resolution = resolveSingleEditVideo( - allVideoNames, - options.grep, - (name) => pc.cyan(`${getSuggestedScreenciCommand('preview')} "${name}"`) + ? 'No videos found. Declare one with video(...) in your recordings.' + : `No video matches "${options.grep}". Available videos:\n` + + allVideoNames.map((name) => ` - ${name}`).join('\n') ) - if (!resolution.ok) { - logger.error(resolution.message) - process.exit(1) - } - options.videoName = resolution.videoName - options.grep = escapeRegExp(resolution.videoName) + process.exit(1) + } + if (matches.length === 1) { + options.videoName = matches[0]! + options.grep = escapeRegExp(matches[0]!) } } - const killWindowSeconds = Number(options.recordKillWindow) const config: DevListenConfig = { apiUrl, credential: auth.credential, devToken: auth.devToken, projectName: screenciConfig.projectName, machineName: depsOverride.machineName ?? hostname(), - ...(Number.isFinite(killWindowSeconds) && killWindowSeconds >= 0 - ? { recordKillWindowMs: killWindowSeconds * 1000 } - : {}), - } - // Source-file watcher state: created after the startup handshake; the - // codegen/stamp write paths re-baseline files through it so the CLI's own - // writes never trigger a watch re-record. - let watcher: DevWatcherController | null = null - let pendingLocal: LocalRecordRequest | null = null - const enqueueLocalRecord = (videoNames: string[]): void => { - const names = new Set([...(pendingLocal?.videoNames ?? []), ...videoNames]) - if (names.size === 0) return - pendingLocal = { videoNames: [...names] } } const deps: DevListenDeps = { fetchFn: fetch, sleep: (ms) => new Promise((r) => setTimeout(r, ms)), logger, - runRecord: async (trigger, signal) => { - try { - await runTriggeredRecord( - options.config, - trigger, - options.verbose ?? false, - signal - ) - } finally { - // A record may have learned new source files; watch them too. - await refreshWatchTargets() - } - }, - takeLocalRequest: () => { - const request = pendingLocal - pendingLocal = null - return request - }, - applyCodegen: createApplyCodegenDep(options.config, (path) => { - void watcher?.refreshBaseline(path) - }), - // Edits addressed to a video name no longer declared (usually a rename in - // code) only log when the user's own --grep targets that name; without a - // grep they fail silently in the log (still reported to the editor). - shouldLogUnknownVideo: (videoName) => - userGrep !== undefined && grepMatcher(userGrep)(videoName), - // One-shot runs print only the drain summary; live sessions keep the - // per-edit apply lines as their feedback. - logAppliedEdits: options.once !== true, ...depsOverride, } @@ -4479,30 +4015,6 @@ export async function runDevCommand( process.exit(1) } - // The connect banner only earns its line in a long-running session; a - // one-shot preview keeps its output to the phases the user cares about. - if (options.once !== true) { - logger.info( - `${pc.bold(config.machineName)} connected for project "${screenciConfig.projectName}".` - ) - } - - // Sync queued browser edits into the sources on every connect, so edits - // made while no machine was running land in code without a live bridge. - // With --no-sync (CI runners) the drain is skipped and queued edits only - // get a short best-effort note so the log surfaces them. - const drainQueuedEdits = createQueuedEditsDrainer({ - syncEnabled: options.sync !== false, - config, - deps, - listenerId: registration.listenerId, - }) - if (options.sync !== false) { - await drainQueuedEdits() - } else { - await warnUnsyncedEditsBeforeExport(options, depsOverride, 'no-sync') - } - const resolvedConfigPath = resolveScreenCIConfigPathOrExit(options.config) const screenciDir = resolve(dirname(resolvedConfigPath), '.screenci') const readKeptRecordings = async (): Promise => { @@ -4517,51 +4029,6 @@ export async function runDevCommand( } return recordings } - const managedMatcher = grepMatcher(options.grep) - const currentWatchTargets = async () => - buildWatchTargets( - await readKeptRecordings(), - managedMatcher, - resolvedConfigPath - ) - const refreshWatchTargets = async (): Promise => { - if (watcher === null) return - try { - await watcher.refreshTargets(await currentWatchTargets()) - } catch { - // Best-effort: a failed refresh keeps the previous watch set. - } - } - - // Machine-local preview record for the watcher's video names: the editor - // sees it through the sync state, and freshly learned sources are watched - // afterwards. - deps.runLocalRecord ??= async (videoNames, signal) => { - await reportDevSyncState( - config, - deps, - registration.listenerId, - videoNames - ).catch(() => {}) - try { - await runPreviewRecordPass( - options.config, - videoNames.map(escapeRegExp).join('|'), - options.verbose ?? false, - signal, - { - apiUrl, - credential: auth.credential, - projectName: screenciConfig.projectName, - } - ) - } finally { - await reportDevSyncState(config, deps, registration.listenerId, []).catch( - () => {} - ) - await refreshWatchTargets() - } - } // Startup handshake: bring every managed video up to date (source hash // matches, all editable actions carry editIds) before serving the editor. @@ -4605,7 +4072,6 @@ export async function runDevCommand( for (const file of plan.files) { if (file.after !== file.before) { writeFileSync(file.path, await formatFile(file.path, file.after)) - void watcher?.refreshBaseline(file.path) } } if (plan.stamped.length > 0) { @@ -4622,7 +4088,7 @@ export async function runDevCommand( formatFile: createProjectFormatter(dirname(screenciDir), { warn: (message) => logger.warn(message), }), - onFileWritten: (path) => void watcher?.refreshBaseline(path), + onFileWritten: () => {}, }), recordPreview: async (grepPattern) => { await runPreviewRecordPass( @@ -4657,9 +4123,9 @@ export async function runDevCommand( ) } - // With the managed videos up to date, point at the preview page for the - // video this session manages, or at the run listing when a one-shot preview - // recorded several videos. + // With the managed videos up to date, point at the overview page for the + // recorded video, or at the run listing when the preview recorded several + // videos. if (options.videoName !== undefined) { await printEditorLink({ apiUrl, @@ -4668,7 +4134,7 @@ export async function runDevCommand( projectName: screenciConfig.projectName, videoName: options.videoName, }) - } else if (options.once === true) { + } else { const lastRecordId = await readLastRecordId(screenciDir) if (lastRecordId !== null) { const previewUrl = `${getDevFrontendUrl()}/preview/${lastRecordId}` @@ -4681,163 +4147,11 @@ export async function runDevCommand( } } - // One-shot: edits are synced and the preview is fresh; nothing else needs - // this process, so disconnect instead of staying resident. Queued edits - // authored while the preview recorded are picked up by one final drain. - if (options.once === true) { - await drainQueuedEdits() - await deregisterDevListener(config, deps, registration.listenerId).catch( - () => {} - ) - return - } - - // Watch the managed videos' source files (and the config) so saving a test - // source re-records its previews without a manual trigger. - if (options.watch !== false) { - try { - watcher = await startDevWatcher(await currentWatchTargets(), { - logger, - onSourcesChanged: (videoNames) => { - logger.info( - `Source change detected: ${videoNames - .map((name) => `"${name}"`) - .join(', ')}` - ) - enqueueLocalRecord(videoNames) - }, - onConfigChanged: () => { - logger.info( - 'screenci.config.ts changed: re-recording every managed video. ' + - 'Restart screenci preview if the project itself changed.' - ) - void (async () => { - const targets = await currentWatchTargets() - enqueueLocalRecord( - [...targets.files.values()].flatMap((names) => [...names]) - ) - })() - }, - ...depsOverride.watchDeps, - }) - logger.info( - 'Watching test sources for changes (disable with --no-watch).' - ) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - logger.warn(`Source watching unavailable: ${message}`) - } - } - - logger.info( - `Waiting for record requests from ${pc.cyan(getDevFrontendUrl())}. Press Ctrl-C to stop.` + // The preview is fresh and the link is printed; nothing else needs this + // process, so disconnect. + await deregisterDevListener(config, deps, registration.listenerId).catch( + () => {} ) - - const controller = { stopped: false } - const shutdown = () => { - if (controller.stopped) return - controller.stopped = true - watcher?.stop() - logger.info('Disconnecting...') - void deregisterDevListener(config, deps, registration.listenerId) - .catch(() => {}) - .finally(() => process.exit(0)) - } - process.on('SIGINT', shutdown) - process.on('SIGTERM', shutdown) - - try { - while (!controller.stopped) { - try { - await runDevListenLoop( - config, - deps, - registration.listenerId, - controller - ) - break - } catch (error) { - if (!(error instanceof DevAuthError)) throw error - - // Anonymous session: a 401 usually means the trial was just claimed - // (the claim deletes the anon org secret). Self-upgrade to the real - // credentials the claim minted and reconnect without stopping. - if (auth.anonToken !== null) { - const status = await checkAnonSessionStatus(auth.anonToken, { - backendUrl: apiUrl, - }) - if (status.status === 'claimed') { - await persistScreenCISecret(authEnvFilePath, status.secret) - process.env.SCREENCI_SECRET = status.secret - if (status.editToken !== undefined) { - await persistScreenCIEditToken(authEnvFilePath, status.editToken) - process.env[SCREENCI_EDIT_TOKEN_ENV] = status.editToken - } - await deleteAnonSessionFile(authScreenciDir) - let claimedEditToken = status.editToken - if (claimedEditToken === undefined) { - // The claim mints a token unless the user is at the cap; fall - // back to exchanging the fresh secret for a machine token. - const exchanged = await exchangeEditToken({ - apiUrl, - secret: status.secret, - machineName: depsOverride.machineName ?? hostname(), - }) - if (exchanged.ok) { - claimedEditToken = exchanged.editToken - await persistScreenCIEditToken( - authEnvFilePath, - claimedEditToken - ) - process.env[SCREENCI_EDIT_TOKEN_ENV] = claimedEditToken - } - } - if (claimedEditToken === undefined) { - logger.error( - 'Your trial was claimed, but no editor token is configured. ' + - `Create one at ${pc.cyan(getScreenCISecretsUrl())}, add it to your env file as ${SCREENCI_EDIT_TOKEN_ENV}, and re-run this command.` - ) - process.exit(1) - } - auth = { - credential: secretCredential(status.secret), - devToken: claimedEditToken, - anonToken: null, - } - config.credential = auth.credential - config.devToken = auth.devToken - registration = await registerDevListener(config, deps) - logger.info( - 'Trial claimed: reconnected with your account credentials ' + - `(saved to ${pathRelative(process.cwd(), authEnvFilePath)}).` - ) - continue - } - logger.error( - 'This trial session is no longer valid (expired or claimed elsewhere).\n' + - `Sign up to keep editing: ${pc.cyan(getDevFrontendUrl())}` - ) - process.exit(1) - } - - logger.error(error.message) - logger.error( - `Create a new editor token at ${pc.cyan(getScreenCISecretsUrl())} if yours was revoked.` - ) - await deregisterDevListener( - config, - deps, - registration.listenerId - ).catch(() => {}) - process.exit(1) - } - } - } finally { - controller.stopped = true - watcher?.stop() - process.off('SIGINT', shutdown) - process.off('SIGTERM', shutdown) - } } function getRecordRunLockPath(screenciDir: string): string { @@ -5345,6 +4659,7 @@ async function uploadRecordedVideosForConfig( recordId: string | null hadFailures: boolean uploadedVideoNames: string[] + uploadedVideos: Array<{ baseVideoName: string; videoId: string | null }> failedVideoNames: string[] failedVideoMessages: Array<{ videoName: string; message: string }> studioNotices: StudioUploadNotice[] @@ -5356,6 +4671,7 @@ async function uploadRecordedVideosForConfig( recordId: null, hadFailures: false, uploadedVideoNames: [], + uploadedVideos: [], failedVideoNames: [], failedVideoMessages: [], studioNotices: [], @@ -5384,6 +4700,7 @@ async function uploadRecordedVideosForConfig( recordId, hadFailures, uploadedVideoNames, + uploadedVideos, failedVideoNames, failedVideoMessages, studioNotices, @@ -5432,7 +4749,14 @@ async function uploadRecordedVideosForConfig( recordId !== null && projectId !== null ) { - const exportUrl = `${appUrl}/export/${recordId}` + // A single-video run deep-links that video's overview page with the + // run preselected; several videos link the combined run page. + const singleVideoId = + uploadedVideos.length === 1 ? uploadedVideos[0]!.videoId : null + const exportUrl = + singleVideoId !== null + ? formatVideoExportUrl(appUrl, projectId, singleVideoId, recordId) + : `${appUrl}/export/${recordId}` resultUrl = exportUrl await writeGitHubProjectOutput(exportUrl) logger.info('') @@ -5487,9 +4811,10 @@ async function uploadRecordedVideosForConfig( ] for (const notice of studioNotices) { if ('held' in notice.studio) { + // The hold is resolved in the editor, so the link deep-links it. const resolveUrl = projectId !== null && notice.videoId !== null - ? formatPreviewUrl(appUrl, projectId, notice.videoId) + ? `${formatPreviewUrl(appUrl, projectId, notice.videoId)}?editor` : null logger.info('') logger.info( @@ -5501,7 +4826,7 @@ async function uploadRecordedVideosForConfig( const blankCues = notice.studio.blankNarrationCues ?? [] if (blankCues.length > 0) { logger.info( - `Narration ${blankCues.map((cue) => `"${cue}"`).join(', ')} has no text in code yet. Write it in the editor, then run ${pc.cyan(getSuggestedScreenciCommand('preview'))} to sync it into your sources and re-record.` + `Narration ${blankCues.map((cue) => `"${cue}"`).join(', ')} has no text in code yet. Write the narration text in your test source, then re-run ${pc.cyan(getSuggestedScreenciCommand('preview'))} to re-record.` ) } // Machine-readable status line so agents can relay the hold @@ -5603,11 +4928,6 @@ export async function main() { '--force', 'deprecated no-op: export always re-records every requested video' ) - .option( - '--no-sync', - 'deprecated no-op: export never applies queued browser edits (it warns ' + - 'about them instead; run screenci sync to apply)' - ) .action( async ( patterns: string[], @@ -5620,7 +4940,6 @@ export async function main() { output?: string wait?: boolean force?: boolean - sync?: boolean } ) => { const positionalGrep = @@ -5634,12 +4953,6 @@ export async function main() { return } - // Export never applies queued browser edits: an export renders - // exactly what the sources say. Queued edits only produce a warning - // (apply them with `screenci sync` or `screenci preview`). - await warnUnsyncedEditsBeforeExport({ - ...(options.config !== undefined ? { config: options.config } : {}), - }) try { await stampEditIdsForProject(options.config) } catch (err) { @@ -5657,60 +4970,21 @@ export async function main() { } ) - // sync command: one-shot drain of queued browser edits into the sources. - program - .command('sync') - .description( - 'Pull queued browser edits from the ScreenCI editor into your test ' + - 'sources (also runs automatically before test, export, and edit)' - ) - .option('-c, --config ', 'path to config file') - .action(async (options: { config?: string }) => { - const result = await runQuickEditSync({ - ...(options.config !== undefined ? { config: options.config } : {}), - }) - if (result === null) { - logger.info( - 'Nothing to sync: this project is not connected yet. Run ' + - `${pc.cyan(getSuggestedScreenciCommand('preview'))} first.` - ) - return - } - if (result.handled === 0 && result.failed === 0 && result.skipped === 0) { - logger.info('No queued editor edits; sources are up to date.') - } - }) - - // dev command: sync edits, record a preview, and print the editor link. - // One-shot by default; --watch keeps the machine connected as the live - // code-sync bridge. + // preview command: record fresh live previews and print the video link. program .command('preview [grepPatterns...]') .description( - 'Sync browser edits into your sources, record fresh live previews, and ' + - 'print the preview link (one video links its preview page directly; ' + - 'several link the run listing). Add --watch to stay connected: ' + - 'records on demand and applies editor changes to code live. ' + + 'Record fresh live previews and print the video link (one video links ' + + 'its overview page directly; several link the run listing). ' + 'Positional patterns filter managed videos by title (same as --grep, ' + 'like `playwright test `); multiple patterns are OR-combined.' ) - .option( - '-w, --watch', - 'stay connected as the live bridge: watch test sources, record on ' + - 'demand from the editor, and apply editor changes to code as they ' + - 'happen (Ctrl-C to stop)' - ) .option('-c, --config ', 'path to config file') .option('-v, --verbose', 'verbose output') .option( '--token ', `personal editor token (defaults to ${SCREENCI_EDIT_TOKEN_ENV} from your env file)` ) - .option( - '--record-kill-window ', - 'a running record younger than this is killed and replaced when a new ' + - 'record request arrives; an older one finishes first (default: 10)' - ) .option( '-g, --grep ', 'only manage videos whose title matches this pattern (same filter as ' + @@ -5721,17 +4995,6 @@ export async function main() { 're-record every managed video at startup even when the kept ' + 'recordings are up to date' ) - .option( - '--no-watch', - 'with --watch semantics but without the source-file watcher: stay ' + - 'connected, but do not re-record previews on source changes' - ) - .option( - '--no-sync', - 'do not pull queued browser edits into the sources (for CI runners, ' + - 'whose checkouts must stay read-only; edits stay queued for your ' + - 'next local preview or sync)' - ) .action( async ( grepPatterns: string[], @@ -5739,11 +5002,8 @@ export async function main() { config?: string verbose?: boolean token?: string - recordKillWindow?: string grep?: string forceRecord?: boolean - watch?: boolean - sync?: boolean } ) => { // Positional patterns act like `playwright test `: filter the @@ -5753,15 +5013,10 @@ export async function main() { grepPatterns.length > 0 ? grepPatterns.map(escapeRegExp).join('|') : undefined - const { grep, watch, ...rest } = options + const { grep, ...rest } = options const resolvedGrep = grep ?? positionalGrep - // No --watch flag at all: one-shot (sync edits, record a preview, - // print the link, exit). --watch or --no-watch keep the session - // connected as before. await runDevCommand({ ...rest, - once: watch === undefined, - watch: watch ?? false, ...(resolvedGrep !== undefined ? { grep: resolvedGrep } : {}), }) } @@ -5801,18 +5056,6 @@ export async function main() { } } - // Pull queued browser edits into the sources before running, so the - // test exercises what the editor shows. Best-effort and silent when - // the project has no credentials or the backend is unreachable. - if (process.env.SCREENCI_RECORDING !== 'true') { - await runQuickEditSync({ - ...(parsed.configPath !== undefined - ? { config: parsed.configPath } - : {}), - quiet: true, - }) - } - await run( 'test', parsed.otherArgs, @@ -5825,7 +5068,7 @@ export async function main() { // Stamp editIds while the sources and kept recordings are known-good, // so browser edits resolve to stable ids even before the first - // `screenci edit` session. + // `screenci preview` run. try { await stampEditIdsForProject(parsed.configPath) } catch (err) { diff --git a/docs/agent-integration.mdx b/docs/agent-integration.mdx index 99da2c2..6b70e53 100644 --- a/docs/agent-integration.mdx +++ b/docs/agent-integration.mdx @@ -41,12 +41,10 @@ The brief walks the agent through these steps: driven with visible clicks. 3. **Verify, then preview.** It runs `npx screenci test` until the script is green, then `npx screenci preview "