From d3ec22674375704820cadc7fdc2932416c8b07ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 18 Aug 2026 14:35:28 +0200 Subject: [PATCH 1/2] refactor: split the test-suite command out of the replay handler handleSessionReplayCommands becomes the routing decision alone; the test suite's harness-flag admission, request translation and scheduler run move to session-test-suite-command.ts beside the replay runtime they already sit next to. Pure move: no behavior change. --- ...n-test-suite-command-cancellation.test.ts} | 2 +- ...ion-test-suite-command-flag-policy.test.ts | 188 ++++++++ ...on-test-suite-command-nested-flags.test.ts | 69 +++ ... session-test-suite-command-video.test.ts} | 242 +---------- src/daemon/handlers/session-replay.ts | 405 +---------------- .../handlers/session-test-suite-command.ts | 410 ++++++++++++++++++ 6 files changed, 687 insertions(+), 629 deletions(-) rename src/daemon/handlers/__tests__/{session-replay-cancellation.test.ts => session-test-suite-command-cancellation.test.ts} (96%) create mode 100644 src/daemon/handlers/__tests__/session-test-suite-command-flag-policy.test.ts create mode 100644 src/daemon/handlers/__tests__/session-test-suite-command-nested-flags.test.ts rename src/daemon/handlers/__tests__/{session-replay.test.ts => session-test-suite-command-video.test.ts} (57%) create mode 100644 src/daemon/handlers/session-test-suite-command.ts 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-replay.test.ts b/src/daemon/handlers/__tests__/session-test-suite-command-video.test.ts similarity index 57% rename from src/daemon/handlers/__tests__/session-replay.test.ts rename to src/daemon/handlers/__tests__/session-test-suite-command-video.test.ts index d439fa8d8..0701ca3df 100644 --- a/src/daemon/handlers/__tests__/session-replay.test.ts +++ b/src/daemon/handlers/__tests__/session-test-suite-command-video.test.ts @@ -1,3 +1,8 @@ +/** + * 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'; @@ -6,9 +11,7 @@ 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 { 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 { @@ -211,67 +214,6 @@ function expectRecordRuntimeCall( 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); -}); - -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' }); -}); - 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(); @@ -385,175 +327,3 @@ test('test finalizes replay video exactly once when cancellation arrives after s 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('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/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; +} From a3388f8e3bdfcd48bd02711e89207545db8a84a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 18 Aug 2026 15:37:52 +0200 Subject: [PATCH 2/2] test: pin the replay handler's routing decisions session-replay.ts is a router now, so it gets a focused test of its own (AGENTS.md 1:1 source/test topology): replay reaches the script-source runtime, test reaches the suite command with the whole parameter set, and an unrelated command is declined. Both destinations are mocked so a wrong edge shows up as the wrong marker; each case was proven red by mutating the routing it pins. --- .../handlers/__tests__/session-replay.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/daemon/handlers/__tests__/session-replay.test.ts diff --git a/src/daemon/handlers/__tests__/session-replay.test.ts b/src/daemon/handlers/__tests__/session-replay.test.ts new file mode 100644 index 000000000..0ab259125 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-replay.test.ts @@ -0,0 +1,92 @@ +/** + * `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 type { DaemonRequest } from '../../types.ts'; +import { SessionStore } from '../../session-store.ts'; +import { LeaseRegistry } from '../../lease-registry.ts'; +import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; + +vi.mock('../session-replay-runtime.ts', () => ({ + runReplayScriptSource: vi.fn(async () => ({ ok: true, data: { reached: 'replay-runtime' } })), +})); + +vi.mock('../session-test-suite-command.ts', () => ({ + runReplayTestSuiteCommand: vi.fn(async () => ({ ok: true, data: { reached: 'test-suite' } })), +})); + +import { handleSessionReplayCommands } from '../session-replay.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; +import { runReplayTestSuiteCommand } from '../session-test-suite-command.ts'; + +const mockRunReplayScriptSource = vi.mocked(runReplayScriptSource); +const mockRunReplayTestSuiteCommand = vi.mocked(runReplayTestSuiteCommand); + +beforeEach(() => { + mockRunReplayScriptSource.mockClear(); + mockRunReplayTestSuiteCommand.mockClear(); +}); + +function routerParams(command: string) { + const root = mkdtempForTestSync('agent-device-replay-router-'); + const req: DaemonRequest = { + token: 'token', + session: 'default', + command, + positionals: [], + flags: {}, + }; + return { + req, + sessionName: 'default', + logPath: path.join(root, 'daemon.log'), + sessionStore: new SessionStore(path.join(root, 'sessions')), + leaseRegistry: new LeaseRegistry(), + invoke: async () => ({ ok: true as const, data: {} }), + }; +} + +test('a replay request routes to the script-source runtime, not the suite command', async () => { + const params = routerParams('replay'); + + const response = await handleSessionReplayCommands(params); + + 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, + }); +}); + +test('a test request routes to the suite command with the whole parameter set', async () => { + const params = routerParams('test'); + + const response = await handleSessionReplayCommands(params); + + 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('an unrelated command is declined so another handler family can claim it', async () => { + const response = await handleSessionReplayCommands(routerParams('snapshot')); + + expect(response).toBeNull(); + expect(mockRunReplayScriptSource).not.toHaveBeenCalled(); + expect(mockRunReplayTestSuiteCommand).not.toHaveBeenCalled(); +});