diff --git a/src/daemon/handlers/__tests__/session-replay.test.ts b/src/daemon/handlers/__tests__/session-replay.test.ts index d439fa8d8..0ab259125 100644 --- a/src/daemon/handlers/__tests__/session-replay.test.ts +++ b/src/daemon/handlers/__tests__/session-replay.test.ts @@ -1,559 +1,92 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; +/** + * `session-replay.ts` is a routing decision and nothing else: `replay` is one script run, `test` is + * a suite of them, and any other command belongs to a different handler family. Both arms' + * orchestration lives in its own module, so what is left to pin here is the routing itself — + * which arm each command reaches, that the other arm is NOT reached, and that an unrelated command + * is declined rather than swallowed. + * + * Both destinations are mocked, so a wrong edge is observable as the wrong marker rather than as a + * device-level failure: swapping the two delegations flips the first two cases red. + */ +import { beforeEach, expect, test, vi } from 'vitest'; import path from 'node:path'; -import { beforeEach, test, vi } from 'vitest'; +import type { DaemonRequest } from '../../types.ts'; import { SessionStore } from '../../session-store.ts'; import { LeaseRegistry } from '../../lease-registry.ts'; -import type { DaemonRequest, DaemonResponse } from '../../types.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/index.ts'; -import { buildNestedReplayFlags, handleSessionReplayCommands } from '../session-replay.ts'; -import { REPLAY_ONLY_TEST_FLAG_REJECTIONS } from '../session-replay-test-policy.ts'; -import { replayCommandFamily } from '../../../commands/replay/index.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; -import { replayScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; -import { - unavailableBindDevice, - unavailableBindExactDevice, -} from '../../__tests__/test-device-runtime-gateway.ts'; -import { createScreenRecordingAdmissionLedger } from '../../screen-recording-admission-ledger.ts'; -import type { RecordRuntimeHandlerParams } from '../record-runtime.ts'; -import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; -import { - localRuntimeOwner, - type ScreenRecordingLiveHandle, -} from '@agent-device/contracts/platform'; -const recordRuntimeMocks = vi.hoisted(() => ({ - handleRecordCommand: vi.fn(), +vi.mock('../session-replay-runtime.ts', () => ({ + runReplayScriptSource: vi.fn(async () => ({ ok: true, data: { reached: 'replay-runtime' } })), })); -vi.mock('../record-runtime.ts', () => ({ - handleRecordCommand: recordRuntimeMocks.handleRecordCommand, +vi.mock('../session-test-suite-command.ts', () => ({ + runReplayTestSuiteCommand: vi.fn(async () => ({ ok: true, data: { reached: 'test-suite' } })), })); -beforeEach(() => { - vi.useRealTimers(); - recordRuntimeMocks.handleRecordCommand.mockReset(); -}); - -type RecordCommandCall = [RecordRuntimeHandlerParams]; - -type RecordVideoFixture = { - root: string; - replayPath: string; - sessionStore: SessionStore; - nestedRequests: DaemonRequest[]; - events: string[]; -}; - -type MockRecordingState = { - recordingPath: string; - events: string[]; - finish: ScreenRecordingLiveHandle['finish']; - liveSlotCleared: boolean; -}; - -function createRecordVideoFixture(): RecordVideoFixture { - const root = mkdtempForTestSync('agent-device-replay-record-video-'); - const replayPath = path.join(root, 'flow.ad'); - fs.writeFileSync(replayPath, 'open "Demo"\nclick "Continue"\n'); - return { - root, - replayPath, - sessionStore: new SessionStore(path.join(root, 'sessions')), - nestedRequests: [], - events: [], - }; -} - -function installMockRecordingHandler(sessionStore: SessionStore, state: MockRecordingState): void { - recordRuntimeMocks.handleRecordCommand.mockImplementation( - async (params: { req: DaemonRequest }): Promise => - await handleMockRecordCommand({ - req: params.req, - sessionStore, - state, - }), - ); -} - -async function handleMockRecordCommand(params: { - req: DaemonRequest; - sessionStore: SessionStore; - state: MockRecordingState; -}): Promise { - const { req, sessionStore, state } = params; - const action = req.positionals?.[0]; - if (action === 'start') return startMockRecording({ req, sessionStore, state }); - if (action === 'stop') return stopMockRecording({ req, sessionStore, state }); - return { ok: false, error: { code: 'INVALID_ARGS', message: 'unexpected record action' } }; -} - -function startMockRecording(params: { - req: DaemonRequest; - sessionStore: SessionStore; - state: MockRecordingState; -}): DaemonResponse { - const { req, sessionStore, state } = params; - state.events.push('record:start'); - state.recordingPath = req.positionals?.[1] ?? ''; - const session = sessionStore.get(req.session); - if (session) { - const outPath = state.recordingPath; - const handle: ScreenRecordingLiveHandle = { - inspect: () => ({ - backend: 'test', - outPath, - startedAt: Date.now(), - scope: 'app', - showTouches: false, - recordOnlySession: false, - gestureEvents: [], - }), - appendGestureEvents: () => {}, - setTouchReferenceFrame: () => {}, - setRunnerSessionId: () => {}, - invalidate: () => {}, - finish: state.finish, - forceCleanup: async () => ({ status: 'cleaned' }), - [Symbol.asyncDispose]: async () => {}, - }; - session.screenRecording = { - handle, - envelope: createDurableResourceEnvelope({ - resourceKind: 'screen-recording', - sessionId: session.name, - device: { id: session.device.id, family: 'apple', appleOs: 'ios', kind: 'simulator' }, - owner: localRuntimeOwner('apple'), - fence: { token: `${session.name}-fence`, generation: 1 }, - lifecycle: 'open', - descriptor: { version: 1, body: { recordingId: session.name } }, - metadata: { phase: 'active' }, - }), - }; - sessionStore.set(req.session, session); - } - return { ok: true, data: { recording: 'started', outPath: state.recordingPath } }; -} - -async function stopMockRecording(params: { - req: DaemonRequest; - sessionStore: SessionStore; - state: MockRecordingState; -}): Promise { - const { req, sessionStore, state } = params; - state.events.push('record:stop'); - const session = sessionStore.get(req.session); - if (session) { - await session.screenRecording?.handle.finish(); - session.screenRecording = undefined; - sessionStore.set(req.session, session); - state.liveSlotCleared = sessionStore.get(req.session)?.screenRecording === undefined; - } - fs.writeFileSync(state.recordingPath, 'video'); - return { - ok: true, - data: { - recording: 'stopped', - outPath: state.recordingPath, - artifacts: [ - { - field: 'outPath', - artifactType: 'screen-recording', - path: state.recordingPath, - fileName: path.basename(state.recordingPath), - }, - ], - }, - }; -} - -function expectRecordVideoCalls(params: { - generatedSession: string; - artifactsDir: string | undefined; - admissionLedger: RecordRuntimeHandlerParams['admissionLedger']; - requestScope: RecordRuntimeHandlerParams['requestScope']; - throwIfCanceled: RecordRuntimeHandlerParams['throwIfCanceled']; -}): void { - const { artifactsDir } = params; - const [startCall, stopCall] = requireRecordVideoCalls(); - expectRecordRuntimeCall(startCall, params); - assert.deepEqual(startCall.req.positionals, [ - 'start', - path.join(artifactsDir ?? '', 'attempt-1', 'recording.mp4'), - ]); - expectRecordRuntimeCall(stopCall, params); - assert.deepEqual(stopCall.req.positionals, ['stop']); -} - -function requireRecordVideoCalls(): [RecordRuntimeHandlerParams, RecordRuntimeHandlerParams] { - const calls = recordRuntimeMocks.handleRecordCommand.mock.calls as RecordCommandCall[]; - assert.equal(calls.length, 2); - const startCall = calls[0]?.[0]; - const stopCall = calls[1]?.[0]; - if (!startCall || !stopCall) throw new Error('Expected record start and stop calls'); - return [startCall, stopCall]; -} +import { handleSessionReplayCommands } from '../session-replay.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; +import { runReplayTestSuiteCommand } from '../session-test-suite-command.ts'; -function expectRecordRuntimeCall( - call: RecordRuntimeHandlerParams, - expected: Pick< - Parameters[0], - 'generatedSession' | 'admissionLedger' | 'requestScope' | 'throwIfCanceled' - >, -): void { - assert.equal(call.sessionName, expected.generatedSession); - assert.equal(call.req.session, expected.generatedSession); - assert.strictEqual(call.bindDevice, unavailableBindDevice); - assert.strictEqual(call.bindExactDevice, unavailableBindExactDevice); - assert.strictEqual(call.admissionLedger, expected.admissionLedger); - assert.strictEqual(call.requestScope, expected.requestScope); - assert.strictEqual(call.throwIfCanceled, expected.throwIfCanceled); -} - -test('buildNestedReplayFlags returns parent flags untouched when neither override is set', () => { - const parent = { platform: 'android' as const, timeoutMs: 5000 }; - const result = buildNestedReplayFlags({ - parentFlags: parent, - platform: undefined, - target: undefined, - artifactsDir: undefined, - }); - assert.strictEqual(result, parent); -}); - -test('buildNestedReplayFlags merges platform, target, and artifactsDir into parent flags', () => { - const parent = { timeoutMs: 5000, retries: 1 }; - const result = buildNestedReplayFlags({ - parentFlags: parent, - platform: 'ios', - target: 'mobile', - artifactsDir: '/tmp/attempt-1', - }); - assert.deepEqual(result, { - timeoutMs: 5000, - retries: 1, - platform: 'ios', - target: 'mobile', - artifactsDir: '/tmp/attempt-1', - }); - // Parent object must not be mutated. - assert.equal((parent as Record).artifactsDir, undefined); -}); +const mockRunReplayScriptSource = vi.mocked(runReplayScriptSource); +const mockRunReplayTestSuiteCommand = vi.mocked(runReplayTestSuiteCommand); -test('buildNestedReplayFlags threads artifactsDir through even when parent lacks it', () => { - const result = buildNestedReplayFlags({ - parentFlags: undefined, - platform: undefined, - target: undefined, - artifactsDir: '/tmp/attempt-1', - }); - assert.deepEqual(result, { artifactsDir: '/tmp/attempt-1' }); -}); - -test('buildNestedReplayFlags overrides a parent artifactsDir with the attempt-level one', () => { - const result = buildNestedReplayFlags({ - parentFlags: { artifactsDir: '/suite-root' }, - platform: undefined, - target: undefined, - artifactsDir: '/suite-root/flow/attempt-2', - }); - assert.equal(result?.artifactsDir, '/suite-root/flow/attempt-2'); -}); - -test('buildNestedReplayFlags strips test-only recordVideo before replay actions inherit flags', () => { - const result = buildNestedReplayFlags({ - parentFlags: { platform: 'ios', recordVideo: true }, - platform: undefined, - target: undefined, - artifactsDir: undefined, - }); - - assert.deepEqual(result, { platform: 'ios' }); +beforeEach(() => { + mockRunReplayScriptSource.mockClear(); + mockRunReplayTestSuiteCommand.mockClear(); }); -test('test finalizes replay video exactly once when cancellation arrives after start', async () => { - vi.useFakeTimers({ now: 1_000 }); - const { root, replayPath, sessionStore, nestedRequests, events } = createRecordVideoFixture(); - const finish = vi.fn(async () => ({ - status: 'completed' as const, - result: { - backend: 'test', - outPath: path.join(root, 'capture.mp4'), - startedAt: 1, - completedAt: 2, - scope: 'app' as const, - showTouches: false, - recordOnlySession: false, - }, - })); - const recordingState: MockRecordingState = { - recordingPath: '', - events, - finish, - liveSlotCleared: false, - }; - installMockRecordingHandler(sessionStore, recordingState); - const screenRecordingAdmissionLedger = createScreenRecordingAdmissionLedger(); - const requestScope = { - signal: new AbortController().signal, - diagnostics: { emit: () => {} }, - progress: { report: () => {} }, +function routerParams(command: string) { + const root = mkdtempForTestSync('agent-device-replay-router-'); + const req: DaemonRequest = { + token: 'token', + session: 'default', + command, + positionals: [], + flags: {}, }; - const cancellation = new Error('request canceled after recording start'); - const throwIfCanceled = vi - .fn<() => void>() - .mockImplementationOnce(() => {}) - .mockImplementation(() => { - throw cancellation; - }); - - const responsePromise = handleSessionReplayCommands({ - req: { - token: 'token', - session: 'default', - command: 'test', - positionals: [replayPath], - flags: { - recordVideo: true, - replayKeepSession: false, - saveScript: false, - force: false, - artifactsDir: path.join(root, 'artifacts'), - replayScriptSources: [replayScriptSourceBundleFor(replayPath)], - }, - meta: { cwd: root, requestId: 'record-video-suite' }, - }, + return { + req, sessionName: 'default', logPath: path.join(root, 'daemon.log'), - sessionStore, + sessionStore: new SessionStore(path.join(root, 'sessions')), leaseRegistry: new LeaseRegistry(), - bindDevice: unavailableBindDevice, - bindExactDevice: unavailableBindExactDevice, - screenRecordingAdmissionLedger, - requestScope, - retainDeviceExecutionLock: async () => {}, - throwIfCanceled, - invoke: async (nestedReq) => { - nestedRequests.push(nestedReq); - if (nestedReq.command === 'open') { - const provisionalSession = makeIosSession(nestedReq.session); - sessionStore.set(nestedReq.session, provisionalSession); - const hookResponse = - await nestedReq.internal?.openLifecycle?.beforeDispatch?.(provisionalSession); - if (hookResponse && !hookResponse.ok) return hookResponse; - events.push('open:dispatch'); - } - return { ok: true, data: { session: nestedReq.session } }; - }, - }); - await vi.advanceTimersByTimeAsync(4_000); - const response = await responsePromise; - vi.useRealTimers(); - - if (!response) throw new Error('Expected response'); - if (!response.ok) throw new Error(response.error.message); - const suite = response.data as { - tests?: Array<{ session?: string; artifactsDir?: string }>; + invoke: async () => ({ ok: true as const, data: {} }), }; - const testResult = suite.tests?.[0] ?? {}; - const generatedSession = testResult.session; - if (typeof generatedSession !== 'string') throw new Error('Expected generated test session'); - expectRecordVideoCalls({ - generatedSession, - artifactsDir: testResult.artifactsDir, - admissionLedger: screenRecordingAdmissionLedger, - requestScope, - throwIfCanceled, - }); - assert.equal(throwIfCanceled.mock.calls.length, 1); - assert.equal(finish.mock.calls.length, 1); - assert.equal(recordingState.liveSlotCleared, true); - assert.deepEqual(events, ['record:start', 'open:dispatch', 'record:stop']); - const timingPath = path.join(testResult.artifactsDir ?? '', 'attempt-1', 'replay-timing.ndjson'); - const timingEvents = fs - .readFileSync(timingPath, 'utf8') - .trim() - .split('\n') - .map((line) => JSON.parse(line) as { type?: string }); - assert.deepEqual( - timingEvents.map((event) => event.type).filter((type) => type?.startsWith('video_')), - ['video_recording_start', 'video_preroll_done', 'video_tail_start', 'video_recording_stop'], - ); - assert.equal( - nestedRequests.some((nestedReq) => nestedReq.flags?.recordVideo === true), - false, - ); -}); - -// --- ADR 0012 decision 4 / migration step 5: `--from` is replay-only --- - -test('raw test-request guards enumerate every daemon-visible replay-only CLI flag', () => { - const replayFlags = replayCommandFamily.cliSchemas.replay?.allowedFlags ?? []; - const testFlags = new Set(replayCommandFamily.cliSchemas.test?.allowedFlags ?? []); - const clientOnlyReplayFlags = new Set(['out']); - const expectedDaemonFlags = replayFlags - .filter((flag) => !testFlags.has(flag) && !clientOnlyReplayFlags.has(flag)) - .sort(); - - const guardedDaemonFlags = REPLAY_ONLY_TEST_FLAG_REJECTIONS.flatMap( - (rejection) => rejection.keys, - ).sort(); - assert.deepEqual(guardedDaemonFlags, expectedDaemonFlags); -}); - -test('test rejects raw --keep-session with INVALID_ARGS before running the suite', async () => { - const root = mkdtempForTestSync('agent-device-test-keep-session-rejected-'); - const replayPath = path.join(root, 'flow.ad'); - fs.writeFileSync(replayPath, 'open "Demo"\n'); - const sessionStore = new SessionStore(path.join(root, 'sessions')); - const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - - const response = await handleSessionReplayCommands({ - req: { - token: 'token', - session: 'default', - command: 'test', - positionals: [replayPath], - flags: { replayKeepSession: true }, - meta: { cwd: root }, - }, - sessionName: 'default', - logPath: path.join(root, 'daemon.log'), - sessionStore, - leaseRegistry: new LeaseRegistry(), - invoke, - }); - - if (!response) throw new Error('Expected response'); - assert.equal(response.ok, false); - if (response.ok) return; - assert.equal(response.error.code, 'INVALID_ARGS'); - assert.match(response.error.message, /--keep-session/); - assert.equal(invoke.mock.calls.length, 0); -}); - -test('test rejects --from with INVALID_ARGS before running the suite', async () => { - const root = mkdtempForTestSync('agent-device-test-from-rejected-'); - const replayPath = path.join(root, 'flow.ad'); - fs.writeFileSync(replayPath, 'open "Demo"\nclick "Continue"\n'); - const sessionStore = new SessionStore(path.join(root, 'sessions')); - - const response = await handleSessionReplayCommands({ - req: { - token: 'token', - session: 'default', - command: 'test', - positionals: [replayPath], - flags: { replayFrom: 2, replayPlanDigest: 'deadbeef' }, - meta: { cwd: root }, - }, - sessionName: 'default', - logPath: path.join(root, 'daemon.log'), - sessionStore, - leaseRegistry: new LeaseRegistry(), - invoke: async () => { - throw new Error('test must not start executing when --from is rejected'); - }, - }); +} - if (!response) throw new Error('Expected response'); - assert.equal(response.ok, false); - if (response.ok) return; - assert.equal(response.error.code, 'INVALID_ARGS'); - assert.match(response.error.message, /--from/); -}); +test('a replay request routes to the script-source runtime, not the suite command', async () => { + const params = routerParams('replay'); -test('test rejects --plan-digest alone with INVALID_ARGS before running the suite', async () => { - const root = mkdtempForTestSync('agent-device-test-digest-rejected-'); - const replayPath = path.join(root, 'flow.ad'); - fs.writeFileSync(replayPath, 'open "Demo"\nclick "Continue"\n'); - const sessionStore = new SessionStore(path.join(root, 'sessions')); + const response = await handleSessionReplayCommands(params); - const response = await handleSessionReplayCommands({ - req: { - token: 'token', - session: 'default', - command: 'test', - positionals: [replayPath], - flags: { replayPlanDigest: 'deadbeef' }, - meta: { cwd: root }, - }, - sessionName: 'default', - logPath: path.join(root, 'daemon.log'), - sessionStore, - leaseRegistry: new LeaseRegistry(), - invoke: async () => { - throw new Error('test must not start executing when --plan-digest is rejected'); - }, + expect(response).toMatchObject({ ok: true, data: { reached: 'replay-runtime' } }); + expect(mockRunReplayTestSuiteCommand).not.toHaveBeenCalled(); + // The replay arm narrows the params rather than forwarding the suite's own shape. + expect(mockRunReplayScriptSource).toHaveBeenCalledWith({ + req: params.req, + sessionName: params.sessionName, + logPath: params.logPath, + sessionStore: params.sessionStore, + invoke: params.invoke, }); - - if (!response) throw new Error('Expected response'); - assert.equal(response.ok, false); - if (response.ok) return; - assert.equal(response.error.code, 'INVALID_ARGS'); }); -// --- ADR 0012 decision 6: `--save-script` is replay-only --- +test('a test request routes to the suite command with the whole parameter set', async () => { + const params = routerParams('test'); -test('test rejects --save-script with INVALID_ARGS before running the suite', async () => { - const root = mkdtempForTestSync('agent-device-test-savescript-rejected-'); - const replayPath = path.join(root, 'flow.ad'); - fs.writeFileSync(replayPath, 'open "Demo"\nclick "Continue"\n'); - const sessionStore = new SessionStore(path.join(root, 'sessions')); + const response = await handleSessionReplayCommands(params); - const response = await handleSessionReplayCommands({ - req: { - token: 'token', - session: 'default', - command: 'test', - positionals: [replayPath], - flags: { saveScript: true }, - meta: { cwd: root }, - }, - sessionName: 'default', - logPath: path.join(root, 'daemon.log'), - sessionStore, - leaseRegistry: new LeaseRegistry(), - invoke: async () => { - throw new Error('test must not start executing when --save-script is rejected'); - }, - }); - - if (!response) throw new Error('Expected response'); - assert.equal(response.ok, false); - if (response.ok) return; - assert.equal(response.error.code, 'INVALID_ARGS'); - assert.match(response.error.message, /--save-script/); + expect(response).toMatchObject({ ok: true, data: { reached: 'test-suite' } }); + expect(mockRunReplayScriptSource).not.toHaveBeenCalled(); + // The suite owns video recording, sharding and device binding, so it receives `params` whole — + // narrowing here is what would silently drop those capabilities. + expect(mockRunReplayTestSuiteCommand).toHaveBeenCalledWith(params); }); -test('test rejects raw --force without --save-script before running the suite', async () => { - const root = mkdtempForTestSync('agent-device-test-force-rejected-'); - const replayPath = path.join(root, 'flow.ad'); - fs.writeFileSync(replayPath, 'open "Demo"\n'); - const sessionStore = new SessionStore(path.join(root, 'sessions')); - const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - - const response = await handleSessionReplayCommands({ - req: { - token: 'token', - session: 'default', - command: 'test', - positionals: [replayPath], - flags: { force: true }, - meta: { cwd: root }, - }, - sessionName: 'default', - logPath: path.join(root, 'daemon.log'), - sessionStore, - leaseRegistry: new LeaseRegistry(), - invoke, - }); +test('an unrelated command is declined so another handler family can claim it', async () => { + const response = await handleSessionReplayCommands(routerParams('snapshot')); - if (!response) throw new Error('Expected response'); - assert.equal(response.ok, false); - if (response.ok) return; - assert.equal(response.error.code, 'INVALID_ARGS'); - assert.match(response.error.message, /--force/); - assert.equal(invoke.mock.calls.length, 0); + expect(response).toBeNull(); + expect(mockRunReplayScriptSource).not.toHaveBeenCalled(); + expect(mockRunReplayTestSuiteCommand).not.toHaveBeenCalled(); }); diff --git a/src/daemon/handlers/__tests__/session-replay-cancellation.test.ts b/src/daemon/handlers/__tests__/session-test-suite-command-cancellation.test.ts similarity index 96% rename from src/daemon/handlers/__tests__/session-replay-cancellation.test.ts rename to src/daemon/handlers/__tests__/session-test-suite-command-cancellation.test.ts index 401513e50..d6c6173cc 100644 --- a/src/daemon/handlers/__tests__/session-replay-cancellation.test.ts +++ b/src/daemon/handlers/__tests__/session-test-suite-command-cancellation.test.ts @@ -6,7 +6,7 @@ import { markRequestCanceled, registerRequestAbort, } from '../../../request/cancel.ts'; -import { bindReplayTestAttemptCancellation } from '../session-replay.ts'; +import { bindReplayTestAttemptCancellation } from '../session-test-suite-command.ts'; // The daemon half of the replay-test cancellation seam (#1478 P3b). The scheduler only says // "cancel" and "release"; everything here — registry entries, the parent-abort relay, and diff --git a/src/daemon/handlers/__tests__/session-test-suite-command-flag-policy.test.ts b/src/daemon/handlers/__tests__/session-test-suite-command-flag-policy.test.ts new file mode 100644 index 000000000..16b2e994c --- /dev/null +++ b/src/daemon/handlers/__tests__/session-test-suite-command-flag-policy.test.ts @@ -0,0 +1,188 @@ +/** + * The raw-wire flag policy `test` enforces before a suite runs: replay-only flags must be refused + * at the daemon boundary, not silently fanned into every attempt. Moved here with the command + * itself when the test-suite command left `session-replay.ts` (AGENTS.md, tests mirror source + * topology). + */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test, vi } from 'vitest'; +import { SessionStore } from '../../session-store.ts'; +import { LeaseRegistry } from '../../lease-registry.ts'; +import { handleSessionReplayCommands } from '../session-replay.ts'; +import { REPLAY_ONLY_TEST_FLAG_REJECTIONS } from '../session-replay-test-policy.ts'; +import { replayCommandFamily } from '../../../commands/replay/index.ts'; +import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; + +// --- ADR 0012 decision 4 / migration step 5: `--from` is replay-only --- + +test('raw test-request guards enumerate every daemon-visible replay-only CLI flag', () => { + const replayFlags = replayCommandFamily.cliSchemas.replay?.allowedFlags ?? []; + const testFlags = new Set(replayCommandFamily.cliSchemas.test?.allowedFlags ?? []); + const clientOnlyReplayFlags = new Set(['out']); + const expectedDaemonFlags = replayFlags + .filter((flag) => !testFlags.has(flag) && !clientOnlyReplayFlags.has(flag)) + .sort(); + + const guardedDaemonFlags = REPLAY_ONLY_TEST_FLAG_REJECTIONS.flatMap( + (rejection) => rejection.keys, + ).sort(); + assert.deepEqual(guardedDaemonFlags, expectedDaemonFlags); +}); + +test('test rejects raw --keep-session with INVALID_ARGS before running the suite', async () => { + const root = mkdtempForTestSync('agent-device-test-keep-session-rejected-'); + const replayPath = path.join(root, 'flow.ad'); + fs.writeFileSync(replayPath, 'open "Demo"\n'); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); + + const response = await handleSessionReplayCommands({ + req: { + token: 'token', + session: 'default', + command: 'test', + positionals: [replayPath], + flags: { replayKeepSession: true }, + meta: { cwd: root }, + }, + sessionName: 'default', + logPath: path.join(root, 'daemon.log'), + sessionStore, + leaseRegistry: new LeaseRegistry(), + invoke, + }); + + if (!response) throw new Error('Expected response'); + assert.equal(response.ok, false); + if (response.ok) return; + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match(response.error.message, /--keep-session/); + assert.equal(invoke.mock.calls.length, 0); +}); + +test('test rejects --from with INVALID_ARGS before running the suite', async () => { + const root = mkdtempForTestSync('agent-device-test-from-rejected-'); + const replayPath = path.join(root, 'flow.ad'); + fs.writeFileSync(replayPath, 'open "Demo"\nclick "Continue"\n'); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + + const response = await handleSessionReplayCommands({ + req: { + token: 'token', + session: 'default', + command: 'test', + positionals: [replayPath], + flags: { replayFrom: 2, replayPlanDigest: 'deadbeef' }, + meta: { cwd: root }, + }, + sessionName: 'default', + logPath: path.join(root, 'daemon.log'), + sessionStore, + leaseRegistry: new LeaseRegistry(), + invoke: async () => { + throw new Error('test must not start executing when --from is rejected'); + }, + }); + + if (!response) throw new Error('Expected response'); + assert.equal(response.ok, false); + if (response.ok) return; + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match(response.error.message, /--from/); +}); + +test('test rejects --plan-digest alone with INVALID_ARGS before running the suite', async () => { + const root = mkdtempForTestSync('agent-device-test-digest-rejected-'); + const replayPath = path.join(root, 'flow.ad'); + fs.writeFileSync(replayPath, 'open "Demo"\nclick "Continue"\n'); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + + const response = await handleSessionReplayCommands({ + req: { + token: 'token', + session: 'default', + command: 'test', + positionals: [replayPath], + flags: { replayPlanDigest: 'deadbeef' }, + meta: { cwd: root }, + }, + sessionName: 'default', + logPath: path.join(root, 'daemon.log'), + sessionStore, + leaseRegistry: new LeaseRegistry(), + invoke: async () => { + throw new Error('test must not start executing when --plan-digest is rejected'); + }, + }); + + if (!response) throw new Error('Expected response'); + assert.equal(response.ok, false); + if (response.ok) return; + assert.equal(response.error.code, 'INVALID_ARGS'); +}); + +// --- ADR 0012 decision 6: `--save-script` is replay-only --- + +test('test rejects --save-script with INVALID_ARGS before running the suite', async () => { + const root = mkdtempForTestSync('agent-device-test-savescript-rejected-'); + const replayPath = path.join(root, 'flow.ad'); + fs.writeFileSync(replayPath, 'open "Demo"\nclick "Continue"\n'); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + + const response = await handleSessionReplayCommands({ + req: { + token: 'token', + session: 'default', + command: 'test', + positionals: [replayPath], + flags: { saveScript: true }, + meta: { cwd: root }, + }, + sessionName: 'default', + logPath: path.join(root, 'daemon.log'), + sessionStore, + leaseRegistry: new LeaseRegistry(), + invoke: async () => { + throw new Error('test must not start executing when --save-script is rejected'); + }, + }); + + if (!response) throw new Error('Expected response'); + assert.equal(response.ok, false); + if (response.ok) return; + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match(response.error.message, /--save-script/); +}); + +test('test rejects raw --force without --save-script before running the suite', async () => { + const root = mkdtempForTestSync('agent-device-test-force-rejected-'); + const replayPath = path.join(root, 'flow.ad'); + fs.writeFileSync(replayPath, 'open "Demo"\n'); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); + + const response = await handleSessionReplayCommands({ + req: { + token: 'token', + session: 'default', + command: 'test', + positionals: [replayPath], + flags: { force: true }, + meta: { cwd: root }, + }, + sessionName: 'default', + logPath: path.join(root, 'daemon.log'), + sessionStore, + leaseRegistry: new LeaseRegistry(), + invoke, + }); + + if (!response) throw new Error('Expected response'); + assert.equal(response.ok, false); + if (response.ok) return; + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match(response.error.message, /--force/); + assert.equal(invoke.mock.calls.length, 0); +}); diff --git a/src/daemon/handlers/__tests__/session-test-suite-command-nested-flags.test.ts b/src/daemon/handlers/__tests__/session-test-suite-command-nested-flags.test.ts new file mode 100644 index 000000000..d6496a3f2 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-test-suite-command-nested-flags.test.ts @@ -0,0 +1,69 @@ +/** + * `buildNestedReplayFlags` — how one suite attempt's flags are projected from the parent `test` + * request. Moved here with the function itself when the test-suite command left + * `session-replay.ts`; tests mirror source topology (AGENTS.md). + */ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { buildNestedReplayFlags } from '../session-test-suite-command.ts'; + +test('buildNestedReplayFlags returns parent flags untouched when neither override is set', () => { + const parent = { platform: 'android' as const, timeoutMs: 5000 }; + const result = buildNestedReplayFlags({ + parentFlags: parent, + platform: undefined, + target: undefined, + artifactsDir: undefined, + }); + assert.strictEqual(result, parent); +}); + +test('buildNestedReplayFlags merges platform, target, and artifactsDir into parent flags', () => { + const parent = { timeoutMs: 5000, retries: 1 }; + const result = buildNestedReplayFlags({ + parentFlags: parent, + platform: 'ios', + target: 'mobile', + artifactsDir: '/tmp/attempt-1', + }); + assert.deepEqual(result, { + timeoutMs: 5000, + retries: 1, + platform: 'ios', + target: 'mobile', + artifactsDir: '/tmp/attempt-1', + }); + // Parent object must not be mutated. + assert.equal((parent as Record).artifactsDir, undefined); +}); + +test('buildNestedReplayFlags threads artifactsDir through even when parent lacks it', () => { + const result = buildNestedReplayFlags({ + parentFlags: undefined, + platform: undefined, + target: undefined, + artifactsDir: '/tmp/attempt-1', + }); + assert.deepEqual(result, { artifactsDir: '/tmp/attempt-1' }); +}); + +test('buildNestedReplayFlags overrides a parent artifactsDir with the attempt-level one', () => { + const result = buildNestedReplayFlags({ + parentFlags: { artifactsDir: '/suite-root' }, + platform: undefined, + target: undefined, + artifactsDir: '/suite-root/flow/attempt-2', + }); + assert.equal(result?.artifactsDir, '/suite-root/flow/attempt-2'); +}); + +test('buildNestedReplayFlags strips test-only recordVideo before replay actions inherit flags', () => { + const result = buildNestedReplayFlags({ + parentFlags: { platform: 'ios', recordVideo: true }, + platform: undefined, + target: undefined, + artifactsDir: undefined, + }); + + assert.deepEqual(result, { platform: 'ios' }); +}); diff --git a/src/daemon/handlers/__tests__/session-test-suite-command-video.test.ts b/src/daemon/handlers/__tests__/session-test-suite-command-video.test.ts new file mode 100644 index 000000000..0701ca3df --- /dev/null +++ b/src/daemon/handlers/__tests__/session-test-suite-command-video.test.ts @@ -0,0 +1,329 @@ +/** + * Per-attempt screen recording inside a `test` suite: the video is finalized exactly once even + * when the request is canceled after recording started. Moved here with the command itself when + * the test-suite command left `session-replay.ts` (AGENTS.md, tests mirror source topology). + */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { beforeEach, test, vi } from 'vitest'; +import { SessionStore } from '../../session-store.ts'; +import { LeaseRegistry } from '../../lease-registry.ts'; +import type { DaemonRequest, DaemonResponse } from '../../types.ts'; +import { makeIosSession } from '../../../__tests__/test-utils/index.ts'; +import { handleSessionReplayCommands } from '../session-replay.ts'; +import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { replayScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; +import { + unavailableBindDevice, + unavailableBindExactDevice, +} from '../../__tests__/test-device-runtime-gateway.ts'; +import { createScreenRecordingAdmissionLedger } from '../../screen-recording-admission-ledger.ts'; +import type { RecordRuntimeHandlerParams } from '../record-runtime.ts'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { + localRuntimeOwner, + type ScreenRecordingLiveHandle, +} from '@agent-device/contracts/platform'; + +const recordRuntimeMocks = vi.hoisted(() => ({ + handleRecordCommand: vi.fn(), +})); + +vi.mock('../record-runtime.ts', () => ({ + handleRecordCommand: recordRuntimeMocks.handleRecordCommand, +})); + +beforeEach(() => { + vi.useRealTimers(); + recordRuntimeMocks.handleRecordCommand.mockReset(); +}); + +type RecordCommandCall = [RecordRuntimeHandlerParams]; + +type RecordVideoFixture = { + root: string; + replayPath: string; + sessionStore: SessionStore; + nestedRequests: DaemonRequest[]; + events: string[]; +}; + +type MockRecordingState = { + recordingPath: string; + events: string[]; + finish: ScreenRecordingLiveHandle['finish']; + liveSlotCleared: boolean; +}; + +function createRecordVideoFixture(): RecordVideoFixture { + const root = mkdtempForTestSync('agent-device-replay-record-video-'); + const replayPath = path.join(root, 'flow.ad'); + fs.writeFileSync(replayPath, 'open "Demo"\nclick "Continue"\n'); + return { + root, + replayPath, + sessionStore: new SessionStore(path.join(root, 'sessions')), + nestedRequests: [], + events: [], + }; +} + +function installMockRecordingHandler(sessionStore: SessionStore, state: MockRecordingState): void { + recordRuntimeMocks.handleRecordCommand.mockImplementation( + async (params: { req: DaemonRequest }): Promise => + await handleMockRecordCommand({ + req: params.req, + sessionStore, + state, + }), + ); +} + +async function handleMockRecordCommand(params: { + req: DaemonRequest; + sessionStore: SessionStore; + state: MockRecordingState; +}): Promise { + const { req, sessionStore, state } = params; + const action = req.positionals?.[0]; + if (action === 'start') return startMockRecording({ req, sessionStore, state }); + if (action === 'stop') return stopMockRecording({ req, sessionStore, state }); + return { ok: false, error: { code: 'INVALID_ARGS', message: 'unexpected record action' } }; +} + +function startMockRecording(params: { + req: DaemonRequest; + sessionStore: SessionStore; + state: MockRecordingState; +}): DaemonResponse { + const { req, sessionStore, state } = params; + state.events.push('record:start'); + state.recordingPath = req.positionals?.[1] ?? ''; + const session = sessionStore.get(req.session); + if (session) { + const outPath = state.recordingPath; + const handle: ScreenRecordingLiveHandle = { + inspect: () => ({ + backend: 'test', + outPath, + startedAt: Date.now(), + scope: 'app', + showTouches: false, + recordOnlySession: false, + gestureEvents: [], + }), + appendGestureEvents: () => {}, + setTouchReferenceFrame: () => {}, + setRunnerSessionId: () => {}, + invalidate: () => {}, + finish: state.finish, + forceCleanup: async () => ({ status: 'cleaned' }), + [Symbol.asyncDispose]: async () => {}, + }; + session.screenRecording = { + handle, + envelope: createDurableResourceEnvelope({ + resourceKind: 'screen-recording', + sessionId: session.name, + device: { id: session.device.id, family: 'apple', appleOs: 'ios', kind: 'simulator' }, + owner: localRuntimeOwner('apple'), + fence: { token: `${session.name}-fence`, generation: 1 }, + lifecycle: 'open', + descriptor: { version: 1, body: { recordingId: session.name } }, + metadata: { phase: 'active' }, + }), + }; + sessionStore.set(req.session, session); + } + return { ok: true, data: { recording: 'started', outPath: state.recordingPath } }; +} + +async function stopMockRecording(params: { + req: DaemonRequest; + sessionStore: SessionStore; + state: MockRecordingState; +}): Promise { + const { req, sessionStore, state } = params; + state.events.push('record:stop'); + const session = sessionStore.get(req.session); + if (session) { + await session.screenRecording?.handle.finish(); + session.screenRecording = undefined; + sessionStore.set(req.session, session); + state.liveSlotCleared = sessionStore.get(req.session)?.screenRecording === undefined; + } + fs.writeFileSync(state.recordingPath, 'video'); + return { + ok: true, + data: { + recording: 'stopped', + outPath: state.recordingPath, + artifacts: [ + { + field: 'outPath', + artifactType: 'screen-recording', + path: state.recordingPath, + fileName: path.basename(state.recordingPath), + }, + ], + }, + }; +} + +function expectRecordVideoCalls(params: { + generatedSession: string; + artifactsDir: string | undefined; + admissionLedger: RecordRuntimeHandlerParams['admissionLedger']; + requestScope: RecordRuntimeHandlerParams['requestScope']; + throwIfCanceled: RecordRuntimeHandlerParams['throwIfCanceled']; +}): void { + const { artifactsDir } = params; + const [startCall, stopCall] = requireRecordVideoCalls(); + expectRecordRuntimeCall(startCall, params); + assert.deepEqual(startCall.req.positionals, [ + 'start', + path.join(artifactsDir ?? '', 'attempt-1', 'recording.mp4'), + ]); + expectRecordRuntimeCall(stopCall, params); + assert.deepEqual(stopCall.req.positionals, ['stop']); +} + +function requireRecordVideoCalls(): [RecordRuntimeHandlerParams, RecordRuntimeHandlerParams] { + const calls = recordRuntimeMocks.handleRecordCommand.mock.calls as RecordCommandCall[]; + assert.equal(calls.length, 2); + const startCall = calls[0]?.[0]; + const stopCall = calls[1]?.[0]; + if (!startCall || !stopCall) throw new Error('Expected record start and stop calls'); + return [startCall, stopCall]; +} + +function expectRecordRuntimeCall( + call: RecordRuntimeHandlerParams, + expected: Pick< + Parameters[0], + 'generatedSession' | 'admissionLedger' | 'requestScope' | 'throwIfCanceled' + >, +): void { + assert.equal(call.sessionName, expected.generatedSession); + assert.equal(call.req.session, expected.generatedSession); + assert.strictEqual(call.bindDevice, unavailableBindDevice); + assert.strictEqual(call.bindExactDevice, unavailableBindExactDevice); + assert.strictEqual(call.admissionLedger, expected.admissionLedger); + assert.strictEqual(call.requestScope, expected.requestScope); + assert.strictEqual(call.throwIfCanceled, expected.throwIfCanceled); +} + +test('test finalizes replay video exactly once when cancellation arrives after start', async () => { + vi.useFakeTimers({ now: 1_000 }); + const { root, replayPath, sessionStore, nestedRequests, events } = createRecordVideoFixture(); + const finish = vi.fn(async () => ({ + status: 'completed' as const, + result: { + backend: 'test', + outPath: path.join(root, 'capture.mp4'), + startedAt: 1, + completedAt: 2, + scope: 'app' as const, + showTouches: false, + recordOnlySession: false, + }, + })); + const recordingState: MockRecordingState = { + recordingPath: '', + events, + finish, + liveSlotCleared: false, + }; + installMockRecordingHandler(sessionStore, recordingState); + const screenRecordingAdmissionLedger = createScreenRecordingAdmissionLedger(); + const requestScope = { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }; + const cancellation = new Error('request canceled after recording start'); + const throwIfCanceled = vi + .fn<() => void>() + .mockImplementationOnce(() => {}) + .mockImplementation(() => { + throw cancellation; + }); + + const responsePromise = handleSessionReplayCommands({ + req: { + token: 'token', + session: 'default', + command: 'test', + positionals: [replayPath], + flags: { + recordVideo: true, + replayKeepSession: false, + saveScript: false, + force: false, + artifactsDir: path.join(root, 'artifacts'), + replayScriptSources: [replayScriptSourceBundleFor(replayPath)], + }, + meta: { cwd: root, requestId: 'record-video-suite' }, + }, + sessionName: 'default', + logPath: path.join(root, 'daemon.log'), + sessionStore, + leaseRegistry: new LeaseRegistry(), + bindDevice: unavailableBindDevice, + bindExactDevice: unavailableBindExactDevice, + screenRecordingAdmissionLedger, + requestScope, + retainDeviceExecutionLock: async () => {}, + throwIfCanceled, + invoke: async (nestedReq) => { + nestedRequests.push(nestedReq); + if (nestedReq.command === 'open') { + const provisionalSession = makeIosSession(nestedReq.session); + sessionStore.set(nestedReq.session, provisionalSession); + const hookResponse = + await nestedReq.internal?.openLifecycle?.beforeDispatch?.(provisionalSession); + if (hookResponse && !hookResponse.ok) return hookResponse; + events.push('open:dispatch'); + } + return { ok: true, data: { session: nestedReq.session } }; + }, + }); + await vi.advanceTimersByTimeAsync(4_000); + const response = await responsePromise; + vi.useRealTimers(); + + if (!response) throw new Error('Expected response'); + if (!response.ok) throw new Error(response.error.message); + const suite = response.data as { + tests?: Array<{ session?: string; artifactsDir?: string }>; + }; + const testResult = suite.tests?.[0] ?? {}; + const generatedSession = testResult.session; + if (typeof generatedSession !== 'string') throw new Error('Expected generated test session'); + expectRecordVideoCalls({ + generatedSession, + artifactsDir: testResult.artifactsDir, + admissionLedger: screenRecordingAdmissionLedger, + requestScope, + throwIfCanceled, + }); + assert.equal(throwIfCanceled.mock.calls.length, 1); + assert.equal(finish.mock.calls.length, 1); + assert.equal(recordingState.liveSlotCleared, true); + assert.deepEqual(events, ['record:start', 'open:dispatch', 'record:stop']); + const timingPath = path.join(testResult.artifactsDir ?? '', 'attempt-1', 'replay-timing.ndjson'); + const timingEvents = fs + .readFileSync(timingPath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { type?: string }); + assert.deepEqual( + timingEvents.map((event) => event.type).filter((type) => type?.startsWith('video_')), + ['video_recording_start', 'video_preroll_done', 'video_tail_start', 'video_recording_stop'], + ); + assert.equal( + nestedRequests.some((nestedReq) => nestedReq.flags?.recordVideo === true), + false, + ); +}); diff --git a/src/daemon/handlers/session-replay.ts b/src/daemon/handlers/session-replay.ts index 28292add9..ceba7a485 100644 --- a/src/daemon/handlers/session-replay.ts +++ b/src/daemon/handlers/session-replay.ts @@ -1,157 +1,20 @@ -import type { CommandFlags } from '@agent-device/contracts/command'; -import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; -import { REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE } from '../../replay/script-source-bundle.ts'; -import type { ReplayScriptMetadata } from '@agent-device/ad-script'; -import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../types.ts'; -import { SessionStore } from '../session-store.ts'; -import { runReplayTestSuite } from '@agent-device/replay-test'; -import { handleCloseCommand } from './session-close.ts'; +import type { DaemonResponse } from '../types.ts'; import { runReplayScriptSource } from './session-replay-runtime.ts'; -import { collectReplayActionArtifactPaths } from './session-replay-runtime-artifacts.ts'; -import { errorResponse } from './response.ts'; -import { AppError, asAppError } from '@agent-device/kernel/errors'; -import { emitRequestProgress } from '../../request/progress.ts'; import { - clearRequestCanceled, - getRequestSignal, - isRequestCanceled, - markRequestCanceled, - registerRequestAbort, -} from '../../request/cancel.ts'; -import { emitDiagnostic } from '../../utils/diagnostics.ts'; -import type { - ReplayTestBindAttemptCancellation, - ReplayTestShardContext, - ReplayTestSuiteRequest, -} from '@agent-device/replay-test'; -import { buildReplayTestSourceDiscovery } from './session-test-source-discovery.ts'; -import { - buildReplayTestShardFlags, - buildReplayTestShardTargetResolver, - readReplayTestShardSelection, -} from './session-test-shard-devices.ts'; -import { toReplayTestAttemptOutcome, toReplayTestFinalizeFailure } from './session-test-outcome.ts'; -import type { LeaseRegistry } from '../lease-registry.ts'; -import type { - BindDeviceRuntime, - BindExactDeviceRuntime, - InspectDeviceRuntimeFacts, -} from '../request-runtime-binding.ts'; -import type { ScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; -import type { PlatformRequestScope } from '@agent-device/contracts/platform'; -import { - buildReplayTestVideoOpenLifecycle, - finalizeReplayTestVideoRecording, - startReplayTestVideoRecordingIfReady, -} from './session-replay-video-recording.ts'; -import { REPLAY_ONLY_TEST_FLAG_REJECTIONS } from './session-replay-test-policy.ts'; + runReplayTestSuiteCommand, + type ReplayTestSuiteCommandParams, +} from './session-test-suite-command.ts'; /** - * Binds one replay-test attempt to daemon request cancellation (#1478 P3b). - * - * The scheduler owns timeout policy and says only "cancel this attempt" / "release it". Every - * registry interaction — registering the abort, relaying the parent request's abort so a - * canceled suite stops its in-flight attempt, and clearing the entry — is host work and lives - * here, next to the rest of the daemon adapter. + * The replay family's routing decision, and only that: a `replay` request is one script run, a + * `test` request is a suite of them, and anything else belongs to another handler family. Both + * arms' orchestration lives in its own module (`session-replay-runtime.ts`, + * `session-test-suite-command.ts`). */ -export const bindReplayTestAttemptCancellation: ReplayTestBindAttemptCancellation = ({ - attemptId, - parentAttemptId, -}) => { - registerRequestAbort(attemptId); - const clearParentRelay = relayReplayTestAbortFromParent(attemptId, parentAttemptId); - return { - cancel: () => markRequestCanceled(attemptId), - release: () => { - clearParentRelay(); - clearRequestCanceled(attemptId); - }, - }; -}; - -function relayReplayTestAbortFromParent( - requestId: string, - parentRequestId: string | undefined, -): () => void { - if (!parentRequestId || parentRequestId === requestId) return () => {}; - const parentSignal = getRequestSignal(parentRequestId); - if (!parentSignal) return () => {}; - - const cancelRequest = () => { - markRequestCanceled(requestId); - }; - if (parentSignal.aborted) { - cancelRequest(); - return () => {}; - } - parentSignal.addEventListener('abort', cancelRequest, { once: true }); - return () => { - parentSignal.removeEventListener('abort', cancelRequest); - }; -} - -export function buildNestedReplayFlags(params: { - parentFlags: CommandFlags | undefined; - platform: ReplayScriptMetadata['platform'] | undefined; - target: ReplayScriptMetadata['target'] | undefined; - artifactsDir: string | undefined; - shard?: ReplayTestShardContext; - /** The one source bundle this attempt replays; `test`'s own multi-source list never fans in. */ - sourceBundle?: ReplayScriptSourceBundle; -}): CommandFlags | undefined { - const { platform, target, artifactsDir, shard, sourceBundle } = params; - const parentFlags = stripReplayTestHarnessFlags(params.parentFlags); - if ( - platform === undefined && - target === undefined && - artifactsDir === undefined && - shard === undefined && - sourceBundle === undefined - ) { - return parentFlags; - } - return buildReplayTestShardFlags( - { - ...(parentFlags ?? {}), - ...(platform !== undefined ? { platform } : {}), - ...(target !== undefined ? { target } : {}), - ...(artifactsDir !== undefined ? { artifactsDir } : {}), - ...(sourceBundle !== undefined ? { replayScriptSource: sourceBundle } : {}), - }, - shard, - ); -} - -/** - * Strips what belongs to the SUITE rather than to one attempt: the harness's own - * `--record-video`, and (#1802) `replayScriptSources` — the suite's whole discovery result, which - * `buildNestedReplayFlags` replaces with the single `replayScriptSource` this attempt runs. - */ -function stripReplayTestHarnessFlags(flags: CommandFlags | undefined): CommandFlags | undefined { - if (!flags) return flags; - if (flags.recordVideo !== true && flags.replayScriptSources === undefined) return flags; - const nestedFlags = { ...flags }; - delete nestedFlags.recordVideo; - delete nestedFlags.replayScriptSources; - return Object.keys(nestedFlags).length > 0 ? nestedFlags : undefined; -} - -export async function handleSessionReplayCommands(params: { - req: DaemonRequest; - sessionName: string; - logPath: string; - sessionStore: SessionStore; - leaseRegistry: LeaseRegistry; - invoke: DaemonInvokeFn; - bindDevice?: BindDeviceRuntime; - inspectFacts?: InspectDeviceRuntimeFacts; - bindExactDevice?: BindExactDeviceRuntime; - screenRecordingAdmissionLedger?: ScreenRecordingAdmissionLedger; - requestScope?: PlatformRequestScope; - retainDeviceExecutionLock?: (deviceId: string) => Promise; - throwIfCanceled?: () => void; -}): Promise { - const { req, sessionName, logPath, sessionStore, leaseRegistry, invoke } = params; +export async function handleSessionReplayCommands( + params: ReplayTestSuiteCommandParams, +): Promise { + const { req, sessionName, logPath, sessionStore, invoke } = params; if (req.command === 'replay') { return await runReplayScriptSource({ @@ -164,250 +27,8 @@ export async function handleSessionReplayCommands(params: { } if (req.command === 'test') { - const replayVideoRuntime = resolveReplayVideoRuntime(params); - if (req.flags?.recordVideo === true && replayVideoRuntime === undefined) { - return errorResponse( - 'COMMAND_FAILED', - 'Screen-recording runtime is not configured for replay video capture', - ); - } - // `test` shares replay execution below, but replay-only flags must not fan - // into every nested suite attempt. Keep the raw-daemon defense declarative - // and aligned with the command grammar; the CLI rejects these earlier. - const flags = req.flags ?? {}; - for (const rejection of REPLAY_ONLY_TEST_FLAG_REJECTIONS) { - if (rejection.requested(flags)) { - return errorResponse('INVALID_ARGS', rejection.message); - } - } - // Translating flags can reject them (mutually exclusive or non-positive shard counts). - // That rejection has always surfaced as an INVALID_ARGS response, so it is caught here - // rather than escaping the handler now that translation happens before the suite runs. - let suiteRequest: ReplayTestSuiteRequest; - // #1802: the caller expanded its own paths/globs and sent one script source bundle per - // discovered source. `sourceBundles` is that list, keyed below by entry path so each nested - // replay attempt executes exactly the text the caller read for that file. - let sourceBundles: readonly ReplayScriptSourceBundle[]; - try { - suiteRequest = toReplayTestSuiteRequest(req, sessionName); - sourceBundles = requireReplayTestScriptSources(req); - } catch (err) { - const appErr = asAppError(err); - return errorResponse(appErr.code, appErr.message); - } - const sourceBundlesByPath = new Map(sourceBundles.map((bundle) => [bundle.entry, bundle])); - const outcome = await runReplayTestSuite({ - request: suiteRequest, - // The host owns the request-global progress sink; the scheduler receives only the - // narrow emit capability (#1478 P3b). - emitProgress: emitRequestProgress, - isCanceled: () => isRequestCanceled(req.meta?.requestId), - emitDiagnostic, - bindAttemptCancellation: bindReplayTestAttemptCancellation, - runReplay: async ({ - filePath, - sessionName: testSessionName, - platform, - target, - requestId, - artifactsDir, - artifactPaths, - tracePath, - appendTimingEvent, - shard, - onStep, - }) => { - const captureArtifacts = (response: DaemonResponse): DaemonResponse => { - if (!artifactPaths) return response; - collectReplayActionArtifactPaths(response).forEach((entry) => artifactPaths.add(entry)); - return response; - }; - - const nestedFlags = buildNestedReplayFlags({ - parentFlags: req.flags, - platform, - target, - artifactsDir, - shard, - sourceBundle: sourceBundlesByPath.get(filePath), - }); - - const videoRecordingParams = replayVideoRuntime - ? { - req, - sessionName: testSessionName, - sessionStore, - artifactsDir, - appendTimingEvent, - ...replayVideoRuntime, - } - : undefined; - const openLifecycle = videoRecordingParams - ? buildReplayTestVideoOpenLifecycle(videoRecordingParams) - : undefined; - const replayResponse = await runReplayScriptSource({ - req: { - ...req, - command: 'replay', - session: testSessionName, - positionals: [filePath], - flags: nestedFlags, - meta: { - ...(req.meta ?? {}), - ...(requestId ? { requestId } : {}), - }, - ...(req.internal || openLifecycle - ? { - internal: { - ...(req.internal ?? {}), - ...(openLifecycle ? { openLifecycle } : {}), - }, - } - : {}), - }, - sessionName: testSessionName, - logPath, - sessionStore, - tracePath, - onStep, - invoke: async (nestedReq) => { - const startResponse = videoRecordingParams - ? await startReplayTestVideoRecordingIfReady(videoRecordingParams) - : undefined; - if (startResponse && !startResponse.ok) return startResponse; - const response = captureArtifacts(await invoke(nestedReq)); - return response; - }, - }); - return toReplayTestAttemptOutcome(replayResponse); - }, - finalizeAttempt: async ({ - sessionName: testSessionName, - artifactPaths, - artifactsDir, - appendTimingEvent, - }) => { - if (!replayVideoRuntime) return undefined; - return toReplayTestFinalizeFailure( - await finalizeReplayTestVideoRecording({ - req, - sessionName: testSessionName, - sessionStore, - artifactsDir, - appendTimingEvent, - artifactPaths, - ...replayVideoRuntime, - }), - ); - }, - discoverSources: buildReplayTestSourceDiscovery(sourceBundles, req.flags?.replayBackend), - resolveShardTargets: buildReplayTestShardTargetResolver(req.flags), - cleanupSession: async (testSessionName) => { - if (!sessionStore.get(testSessionName)) return; - await handleCloseCommand({ - req: { - token: req.token, - session: testSessionName, - command: 'close', - positionals: [], - flags: {}, - meta: req.meta, - }, - sessionName: testSessionName, - logPath, - sessionStore, - leaseRegistry, - inspectFacts: params.inspectFacts, - bindDevice: params.bindDevice, - }); - }, - }); - return outcome.status === 'completed' - ? { ok: true, data: outcome.data } - : errorResponse(outcome.error.code, outcome.error.message); + return await runReplayTestSuiteCommand(params); } return null; } - -type ReplayVideoRuntime = Readonly<{ - bindDevice: BindDeviceRuntime; - bindExactDevice: BindExactDeviceRuntime; - screenRecordingAdmissionLedger: ScreenRecordingAdmissionLedger; - requestScope: PlatformRequestScope; - retainDeviceExecutionLock(deviceId: string): Promise; - throwIfCanceled(): void; -}>; - -function resolveReplayVideoRuntime(params: { - bindDevice?: BindDeviceRuntime; - bindExactDevice?: BindExactDeviceRuntime; - screenRecordingAdmissionLedger?: ScreenRecordingAdmissionLedger; - requestScope?: PlatformRequestScope; - retainDeviceExecutionLock?: (deviceId: string) => Promise; - throwIfCanceled?: () => void; -}): ReplayVideoRuntime | undefined { - if ( - !params.bindDevice || - !params.bindExactDevice || - !params.screenRecordingAdmissionLedger || - !params.requestScope || - !params.retainDeviceExecutionLock || - !params.throwIfCanceled - ) { - return undefined; - } - return { - bindDevice: params.bindDevice, - bindExactDevice: params.bindExactDevice, - screenRecordingAdmissionLedger: params.screenRecordingAdmissionLedger, - requestScope: params.requestScope, - retainDeviceExecutionLock: params.retainDeviceExecutionLock, - throwIfCanceled: params.throwIfCanceled, - }; -} - -/** - * Translates a daemon `test` request into the scheduler's neutral request (#1478 P3b). - * - * `replayBackend` is deliberately not carried across: it selects an engine, and it has already - * been applied here when building the source-discovery and shard-target capabilities. - */ -/** - * #1802: a `test` request states the script sources its suite runs, because the daemon opens no - * caller path. Absent entirely means a client too old to send them; it is rejected as a typed - * `AppError` so it travels the same translation-failure path the shard/flag rejections already - * take, rather than adding a second refusal shape to the handler. - */ -function requireReplayTestScriptSources(req: DaemonRequest): readonly ReplayScriptSourceBundle[] { - const sources = req.flags?.replayScriptSources; - if (!sources) throw new AppError('INVALID_ARGS', REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE); - return sources; -} - -function toReplayTestSuiteRequest(req: DaemonRequest, sessionName: string): ReplayTestSuiteRequest { - const flags = req.flags ?? {}; - const cwd = req.meta?.cwd; - const artifactsDir = stringFlag(flags.artifactsDir); - return { - inputs: req.positionals ?? [], - sessionName, - cwd, - requestId: req.meta?.requestId, - platformFilter: flags.platform, - artifactsDir: - artifactsDir === undefined ? undefined : SessionStore.expandHome(artifactsDir, cwd), - failFast: flags.failFast === true, - retries: numberFlag(flags.retries), - timeoutMs: numberFlag(flags.timeoutMs), - shard: readReplayTestShardSelection(flags), - }; -} - -function numberFlag(value: unknown): number | undefined { - return typeof value === 'number' ? value : undefined; -} - -function stringFlag(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined; -} diff --git a/src/daemon/handlers/session-test-suite-command.ts b/src/daemon/handlers/session-test-suite-command.ts new file mode 100644 index 000000000..e11921823 --- /dev/null +++ b/src/daemon/handlers/session-test-suite-command.ts @@ -0,0 +1,410 @@ +/** + * The `test` command's own orchestration: harness-flag admission, suite translation, and the + * scheduler run whose every attempt is a nested `replay`. Extracted from + * `handleSessionReplayCommands` (`session-replay.ts`), which is now the routing decision alone — + * this is the half that grew every time the suite gained a capability (video recording, shards, + * per-attempt step sinks, and #1802's per-source script bundles). + */ + +import type { CommandFlags } from '@agent-device/contracts/command'; +import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; +import { REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE } from '../../replay/script-source-bundle.ts'; +import type { ReplayScriptMetadata } from '@agent-device/ad-script'; +import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../types.ts'; +import { SessionStore } from '../session-store.ts'; +import { runReplayTestSuite } from '@agent-device/replay-test'; +import { handleCloseCommand } from './session-close.ts'; +import { runReplayScriptSource } from './session-replay-runtime.ts'; +import { collectReplayActionArtifactPaths } from './session-replay-runtime-artifacts.ts'; +import { errorResponse } from './response.ts'; +import { AppError, asAppError } from '@agent-device/kernel/errors'; +import { emitRequestProgress } from '../../request/progress.ts'; +import { + clearRequestCanceled, + getRequestSignal, + isRequestCanceled, + markRequestCanceled, + registerRequestAbort, +} from '../../request/cancel.ts'; +import { emitDiagnostic } from '../../utils/diagnostics.ts'; +import type { + ReplayTestBindAttemptCancellation, + ReplayTestShardContext, + ReplayTestSuiteRequest, +} from '@agent-device/replay-test'; +import { buildReplayTestSourceDiscovery } from './session-test-source-discovery.ts'; +import { + buildReplayTestShardFlags, + buildReplayTestShardTargetResolver, + readReplayTestShardSelection, +} from './session-test-shard-devices.ts'; +import { toReplayTestAttemptOutcome, toReplayTestFinalizeFailure } from './session-test-outcome.ts'; +import type { LeaseRegistry } from '../lease-registry.ts'; +import type { + BindDeviceRuntime, + BindExactDeviceRuntime, + InspectDeviceRuntimeFacts, +} from '../request-runtime-binding.ts'; +import type { ScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; +import type { PlatformRequestScope } from '@agent-device/contracts/platform'; +import { + buildReplayTestVideoOpenLifecycle, + finalizeReplayTestVideoRecording, + startReplayTestVideoRecordingIfReady, +} from './session-replay-video-recording.ts'; +import { REPLAY_ONLY_TEST_FLAG_REJECTIONS } from './session-replay-test-policy.ts'; + +/** + * Binds one replay-test attempt to daemon request cancellation (#1478 P3b). + * + * The scheduler owns timeout policy and says only "cancel this attempt" / "release it". Every + * registry interaction — registering the abort, relaying the parent request's abort so a + * canceled suite stops its in-flight attempt, and clearing the entry — is host work and lives + * here, next to the rest of the daemon adapter. + */ +export const bindReplayTestAttemptCancellation: ReplayTestBindAttemptCancellation = ({ + attemptId, + parentAttemptId, +}) => { + registerRequestAbort(attemptId); + const clearParentRelay = relayReplayTestAbortFromParent(attemptId, parentAttemptId); + return { + cancel: () => markRequestCanceled(attemptId), + release: () => { + clearParentRelay(); + clearRequestCanceled(attemptId); + }, + }; +}; + +function relayReplayTestAbortFromParent( + requestId: string, + parentRequestId: string | undefined, +): () => void { + if (!parentRequestId || parentRequestId === requestId) return () => {}; + const parentSignal = getRequestSignal(parentRequestId); + if (!parentSignal) return () => {}; + + const cancelRequest = () => { + markRequestCanceled(requestId); + }; + if (parentSignal.aborted) { + cancelRequest(); + return () => {}; + } + parentSignal.addEventListener('abort', cancelRequest, { once: true }); + return () => { + parentSignal.removeEventListener('abort', cancelRequest); + }; +} + +export function buildNestedReplayFlags(params: { + parentFlags: CommandFlags | undefined; + platform: ReplayScriptMetadata['platform'] | undefined; + target: ReplayScriptMetadata['target'] | undefined; + artifactsDir: string | undefined; + shard?: ReplayTestShardContext; + /** The one source bundle this attempt replays; `test`'s own multi-source list never fans in. */ + sourceBundle?: ReplayScriptSourceBundle; +}): CommandFlags | undefined { + const { platform, target, artifactsDir, shard, sourceBundle } = params; + const parentFlags = stripReplayTestHarnessFlags(params.parentFlags); + if ( + platform === undefined && + target === undefined && + artifactsDir === undefined && + shard === undefined && + sourceBundle === undefined + ) { + return parentFlags; + } + return buildReplayTestShardFlags( + { + ...(parentFlags ?? {}), + ...(platform !== undefined ? { platform } : {}), + ...(target !== undefined ? { target } : {}), + ...(artifactsDir !== undefined ? { artifactsDir } : {}), + ...(sourceBundle !== undefined ? { replayScriptSource: sourceBundle } : {}), + }, + shard, + ); +} + +/** + * Strips what belongs to the SUITE rather than to one attempt: the harness's own + * `--record-video`, and (#1802) `replayScriptSources` — the suite's whole discovery result, which + * `buildNestedReplayFlags` replaces with the single `replayScriptSource` this attempt runs. + */ +function stripReplayTestHarnessFlags(flags: CommandFlags | undefined): CommandFlags | undefined { + if (!flags) return flags; + if (flags.recordVideo !== true && flags.replayScriptSources === undefined) return flags; + const nestedFlags = { ...flags }; + delete nestedFlags.recordVideo; + delete nestedFlags.replayScriptSources; + return Object.keys(nestedFlags).length > 0 ? nestedFlags : undefined; +} + +export type ReplayTestSuiteCommandParams = { + req: DaemonRequest; + sessionName: string; + logPath: string; + sessionStore: SessionStore; + leaseRegistry: LeaseRegistry; + invoke: DaemonInvokeFn; + bindDevice?: BindDeviceRuntime; + inspectFacts?: InspectDeviceRuntimeFacts; + bindExactDevice?: BindExactDeviceRuntime; + screenRecordingAdmissionLedger?: ScreenRecordingAdmissionLedger; + requestScope?: PlatformRequestScope; + retainDeviceExecutionLock?: (deviceId: string) => Promise; + throwIfCanceled?: () => void; +}; + +export async function runReplayTestSuiteCommand( + params: ReplayTestSuiteCommandParams, +): Promise { + const { req, sessionName, logPath, sessionStore, leaseRegistry, invoke } = params; + const replayVideoRuntime = resolveReplayVideoRuntime(params); + if (req.flags?.recordVideo === true && replayVideoRuntime === undefined) { + return errorResponse( + 'COMMAND_FAILED', + 'Screen-recording runtime is not configured for replay video capture', + ); + } + // `test` shares replay execution below, but replay-only flags must not fan + // into every nested suite attempt. Keep the raw-daemon defense declarative + // and aligned with the command grammar; the CLI rejects these earlier. + const flags = req.flags ?? {}; + for (const rejection of REPLAY_ONLY_TEST_FLAG_REJECTIONS) { + if (rejection.requested(flags)) { + return errorResponse('INVALID_ARGS', rejection.message); + } + } + // Translating flags can reject them (mutually exclusive or non-positive shard counts). + // That rejection has always surfaced as an INVALID_ARGS response, so it is caught here + // rather than escaping the handler now that translation happens before the suite runs. + let suiteRequest: ReplayTestSuiteRequest; + // #1802: the caller expanded its own paths/globs and sent one script source bundle per + // discovered source. `sourceBundles` is that list, keyed below by entry path so each nested + // replay attempt executes exactly the text the caller read for that file. + let sourceBundles: readonly ReplayScriptSourceBundle[]; + try { + suiteRequest = toReplayTestSuiteRequest(req, sessionName); + sourceBundles = requireReplayTestScriptSources(req); + } catch (err) { + const appErr = asAppError(err); + return errorResponse(appErr.code, appErr.message); + } + const sourceBundlesByPath = new Map(sourceBundles.map((bundle) => [bundle.entry, bundle])); + const outcome = await runReplayTestSuite({ + request: suiteRequest, + // The host owns the request-global progress sink; the scheduler receives only the + // narrow emit capability (#1478 P3b). + emitProgress: emitRequestProgress, + isCanceled: () => isRequestCanceled(req.meta?.requestId), + emitDiagnostic, + bindAttemptCancellation: bindReplayTestAttemptCancellation, + runReplay: async ({ + filePath, + sessionName: testSessionName, + platform, + target, + requestId, + artifactsDir, + artifactPaths, + tracePath, + appendTimingEvent, + shard, + onStep, + }) => { + const captureArtifacts = (response: DaemonResponse): DaemonResponse => { + if (!artifactPaths) return response; + collectReplayActionArtifactPaths(response).forEach((entry) => artifactPaths.add(entry)); + return response; + }; + + const nestedFlags = buildNestedReplayFlags({ + parentFlags: req.flags, + platform, + target, + artifactsDir, + shard, + sourceBundle: sourceBundlesByPath.get(filePath), + }); + + const videoRecordingParams = replayVideoRuntime + ? { + req, + sessionName: testSessionName, + sessionStore, + artifactsDir, + appendTimingEvent, + ...replayVideoRuntime, + } + : undefined; + const openLifecycle = videoRecordingParams + ? buildReplayTestVideoOpenLifecycle(videoRecordingParams) + : undefined; + const replayResponse = await runReplayScriptSource({ + req: { + ...req, + command: 'replay', + session: testSessionName, + positionals: [filePath], + flags: nestedFlags, + meta: { + ...(req.meta ?? {}), + ...(requestId ? { requestId } : {}), + }, + ...(req.internal || openLifecycle + ? { + internal: { + ...(req.internal ?? {}), + ...(openLifecycle ? { openLifecycle } : {}), + }, + } + : {}), + }, + sessionName: testSessionName, + logPath, + sessionStore, + tracePath, + onStep, + invoke: async (nestedReq) => { + const startResponse = videoRecordingParams + ? await startReplayTestVideoRecordingIfReady(videoRecordingParams) + : undefined; + if (startResponse && !startResponse.ok) return startResponse; + const response = captureArtifacts(await invoke(nestedReq)); + return response; + }, + }); + return toReplayTestAttemptOutcome(replayResponse); + }, + finalizeAttempt: async ({ + sessionName: testSessionName, + artifactPaths, + artifactsDir, + appendTimingEvent, + }) => { + if (!replayVideoRuntime) return undefined; + return toReplayTestFinalizeFailure( + await finalizeReplayTestVideoRecording({ + req, + sessionName: testSessionName, + sessionStore, + artifactsDir, + appendTimingEvent, + artifactPaths, + ...replayVideoRuntime, + }), + ); + }, + discoverSources: buildReplayTestSourceDiscovery(sourceBundles, req.flags?.replayBackend), + resolveShardTargets: buildReplayTestShardTargetResolver(req.flags), + cleanupSession: async (testSessionName) => { + if (!sessionStore.get(testSessionName)) return; + await handleCloseCommand({ + req: { + token: req.token, + session: testSessionName, + command: 'close', + positionals: [], + flags: {}, + meta: req.meta, + }, + sessionName: testSessionName, + logPath, + sessionStore, + leaseRegistry, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }); + }, + }); + return outcome.status === 'completed' + ? { ok: true, data: outcome.data } + : errorResponse(outcome.error.code, outcome.error.message); +} + +type ReplayVideoRuntime = Readonly<{ + bindDevice: BindDeviceRuntime; + bindExactDevice: BindExactDeviceRuntime; + screenRecordingAdmissionLedger: ScreenRecordingAdmissionLedger; + requestScope: PlatformRequestScope; + retainDeviceExecutionLock(deviceId: string): Promise; + throwIfCanceled(): void; +}>; + +function resolveReplayVideoRuntime(params: { + bindDevice?: BindDeviceRuntime; + bindExactDevice?: BindExactDeviceRuntime; + screenRecordingAdmissionLedger?: ScreenRecordingAdmissionLedger; + requestScope?: PlatformRequestScope; + retainDeviceExecutionLock?: (deviceId: string) => Promise; + throwIfCanceled?: () => void; +}): ReplayVideoRuntime | undefined { + if ( + !params.bindDevice || + !params.bindExactDevice || + !params.screenRecordingAdmissionLedger || + !params.requestScope || + !params.retainDeviceExecutionLock || + !params.throwIfCanceled + ) { + return undefined; + } + return { + bindDevice: params.bindDevice, + bindExactDevice: params.bindExactDevice, + screenRecordingAdmissionLedger: params.screenRecordingAdmissionLedger, + requestScope: params.requestScope, + retainDeviceExecutionLock: params.retainDeviceExecutionLock, + throwIfCanceled: params.throwIfCanceled, + }; +} + +/** + * Translates a daemon `test` request into the scheduler's neutral request (#1478 P3b). + * + * `replayBackend` is deliberately not carried across: it selects an engine, and it has already + * been applied here when building the source-discovery and shard-target capabilities. + */ +/** + * #1802: a `test` request states the script sources its suite runs, because the daemon opens no + * caller path. Absent entirely means a client too old to send them; it is rejected as a typed + * `AppError` so it travels the same translation-failure path the shard/flag rejections already + * take, rather than adding a second refusal shape to the handler. + */ +function requireReplayTestScriptSources(req: DaemonRequest): readonly ReplayScriptSourceBundle[] { + const sources = req.flags?.replayScriptSources; + if (!sources) throw new AppError('INVALID_ARGS', REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE); + return sources; +} + +function toReplayTestSuiteRequest(req: DaemonRequest, sessionName: string): ReplayTestSuiteRequest { + const flags = req.flags ?? {}; + const cwd = req.meta?.cwd; + const artifactsDir = stringFlag(flags.artifactsDir); + return { + inputs: req.positionals ?? [], + sessionName, + cwd, + requestId: req.meta?.requestId, + platformFilter: flags.platform, + artifactsDir: + artifactsDir === undefined ? undefined : SessionStore.expandHome(artifactsDir, cwd), + failFast: flags.failFast === true, + retries: numberFlag(flags.retries), + timeoutMs: numberFlag(flags.timeoutMs), + shard: readReplayTestShardSelection(flags), + }; +} + +function numberFlag(value: unknown): number | undefined { + return typeof value === 'number' ? value : undefined; +} + +function stringFlag(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +}