From bfaaeb98b1769117a146240422c306b86380aaa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 18 Aug 2026 09:22:33 +0200 Subject: [PATCH 1/4] fix: read replay scripts on the caller and ship them with the request Closes #1802 --- CONTEXT.md | 5 + .../src/internal/__tests__/inspect.test.ts | 45 ++++ .../src/internal/__tests__/step-loop.test.ts | 4 +- packages/ad-replay/src/internal/inspect.ts | 14 +- packages/ad-replay/src/internal/step-loop.ts | 2 +- packages/contracts/src/cli-flags.ts | 8 + packages/contracts/src/client-request.ts | 5 + packages/contracts/src/facades/replay.ts | 1 + packages/contracts/src/replay.ts | 22 ++ packages/maestro/src/index.ts | 7 + .../internal/__tests__/program-loader.test.ts | 50 ++-- .../internal/__tests__/source-closure.test.ts | 118 ++++++++ .../maestro/src/internal/facade-execution.ts | 12 +- .../maestro/src/internal/program-loader.ts | 17 +- .../maestro/src/internal/source-closure.ts | 121 +++++++++ .../__tests__/session-test-discovery.test.ts | 4 - .../src/internal/session-test-discovery.ts | 6 +- .../src/internal/session-test-types.ts | 10 +- .../replay-test/src/internal/session-test.ts | 2 - src/__tests__/cli-client-commands.test.ts | 50 ++-- src/__tests__/cli-network.test.ts | 132 +++++---- .../cli-replay-script-sources.test.ts | 94 +++++++ src/__tests__/client.test.ts | 27 ++ .../test-utils/replay-script-source.ts | 70 +++++ src/cli/parser/cli-help.ts | 3 + src/commands/command-flags.ts | 2 + src/commands/replay/index.test.ts | 35 ++- src/commands/replay/index.ts | 72 ++++- .../replay-coordinator-ownership.test.ts | 2 +- .../__tests__/replay-device-selection.test.ts | 24 +- .../__tests__/request-router-open.test.ts | 14 +- .../request-router-repair-expired.test.ts | 6 +- .../request-router-replay-env.test.ts | 14 +- .../request-save-script-transports.test.ts | 7 +- .../__tests__/test-device-runtime-gateway.ts | 9 +- .../daemon-runtime-port-observation.test.ts | 3 +- .../__tests__/daemon-runtime-port.test.ts | 5 +- .../interaction-ios-tap-outcome.test.ts | 10 +- .../__tests__/session-command-harness.ts | 2 + .../__tests__/session-command-replay.test.ts | 41 +-- ...sion-replay-dispatch-selector-miss.test.ts | 16 +- .../session-replay-maestro-failure.test.ts | 14 +- .../session-replay-repair-acceptance.test.ts | 10 +- .../session-replay-repair-empty-tail.test.ts | 34 +-- .../session-replay-repair-loop.test.ts | 40 +-- ...ion-replay-repair-record-exclusion.test.ts | 16 +- .../session-replay-repair-transaction.test.ts | 32 +-- ...ion-replay-runtime-artifact-ledger.test.ts | 8 +- .../session-replay-runtime-binding.test.ts | 8 +- ...on-replay-runtime-failure-response.test.ts | 12 +- .../session-replay-runtime-failure.test.ts | 22 +- ...ession-replay-runtime-keep-session.test.ts | 18 +- .../session-replay-runtime-maestro.test.ts | 100 +++---- .../session-replay-runtime-plan.test.ts | 49 ++-- .../session-replay-runtime.fixtures.ts | 23 +- .../__tests__/session-replay-runtime.test.ts | 46 ++-- .../session-replay-script-source.test.ts | 186 +++++++++++++ .../session-replay-selector-routes.test.ts | 4 +- ...replay-target-verification-runtime.test.ts | 73 +++-- .../handlers/__tests__/session-replay.test.ts | 2 + ...ssion-test-reporter-values-maestro.test.ts | 4 +- .../session-test-source-discovery.test.ts | 255 ++++-------------- .../session-replay-maestro-response.ts | 7 +- .../session-replay-maestro-runtime.ts | 31 ++- src/daemon/handlers/session-replay-resume.ts | 2 +- .../session-replay-runtime-engine-adapter.ts | 8 +- .../handlers/session-replay-runtime-plan.ts | 34 +-- .../session-replay-runtime-session.ts | 2 +- src/daemon/handlers/session-replay-runtime.ts | 33 ++- src/daemon/handlers/session-replay.ts | 36 ++- .../handlers/session-test-source-discovery.ts | 25 +- src/daemon/replay-device-selection.ts | 16 +- src/daemon/session-replay-coordinator.ts | 2 +- .../daemon-artifacts-save-script.test.ts | 58 ++++ src/remote/daemon-artifacts.ts | 22 ++ .../__tests__/script-source-bundle.test.ts | 92 +++++++ src/replay/__tests__/source-discovery.test.ts | 198 ++++++++++++++ src/replay/script-source-bundle.ts | 112 ++++++++ .../source-discovery.ts} | 11 +- website/docs/docs/commands.md | 3 +- 80 files changed, 2004 insertions(+), 735 deletions(-) create mode 100644 packages/ad-replay/src/internal/__tests__/inspect.test.ts create mode 100644 packages/maestro/src/internal/__tests__/source-closure.test.ts create mode 100644 packages/maestro/src/internal/source-closure.ts create mode 100644 src/__tests__/cli-replay-script-sources.test.ts create mode 100644 src/__tests__/test-utils/replay-script-source.ts create mode 100644 src/daemon/handlers/__tests__/session-replay-script-source.test.ts create mode 100644 src/remote/__tests__/daemon-artifacts-save-script.test.ts create mode 100644 src/replay/__tests__/script-source-bundle.test.ts create mode 100644 src/replay/__tests__/source-discovery.test.ts create mode 100644 src/replay/script-source-bundle.ts rename src/{daemon/replay-source-discovery.ts => replay/source-discovery.ts} (91%) diff --git a/CONTEXT.md b/CONTEXT.md index 321f8a4efb..8a1d872cb3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -247,6 +247,11 @@ task touches: - Destination guard: portable selector-targeted `wait` near the end of an open-to-destination script that confirms a landmark on the ready destination screen before replay hands the live session to its caller. +- Replay script source bundle: every script file one `replay`/`test` run needs, read and resolved by + the CALLER and shipped inside the request — an entry display path plus a resolved-path-to-text map + covering the `.ad` script or the Maestro flow and its `runFlow` includes. The daemon executes only + what the bundle carries and resolves no caller path, so a local run and a run against a remote + daemon read identical bytes (#1802). Avoid: script upload, flow payload. - Screen-recording facet: runtime facet that starts platform screen/video capture and returns a live handle plus its durable descriptor. It is distinct from script recording. - Live resource handle: process-local authority to finish or forcibly dispose active app-log, diff --git a/packages/ad-replay/src/internal/__tests__/inspect.test.ts b/packages/ad-replay/src/internal/__tests__/inspect.test.ts new file mode 100644 index 0000000000..38533b3acf --- /dev/null +++ b/packages/ad-replay/src/internal/__tests__/inspect.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { inspectAdReplay } from '../inspect.ts'; + +const SCRIPT = 'context platform=ios\nopen "Demo"\nclick label="Save"\n'; + +test('inspectAdReplay parses script text into actions, a line table and header metadata', () => { + const manifest = inspectAdReplay(SCRIPT); + + assert.deepEqual( + manifest.actions.map((action) => action.command), + ['open', 'click'], + ); + assert.deepEqual(manifest.actionLines, [2, 3]); + assert.equal(manifest.metadata.platform, 'ios'); +}); + +/** + * ADR 0012's `--from`/`--plan-digest` resume quotes a digest back from a divergence report, so the + * digest for a given script must not move. #1802 changed only HOW the script reaches the daemon + * (a replay script source bundle instead of a path it opened itself); the digest is computed over + * the same text and stays byte-identical, which is what keeps a resume issued before the change + * valid after it. + */ +test('inspectAdReplay pins the plan digest for a known script', () => { + assert.equal( + inspectAdReplay(SCRIPT).planDigest, + inspectAdReplay(SCRIPT, { platform: 'ios' }).planDigest, + ); + assert.equal( + inspectAdReplay(SCRIPT).planDigest, + '0641236777b11822d90d446022965629ec99ac2ba1af2d5c637f06dd684fe695', + ); +}); + +test('inspectAdReplay rejects a legacy JSON replay payload', () => { + assert.throws( + () => inspectAdReplay('[{"command":"open"}]'), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message.includes('JSON replay payloads are no longer supported'), + ); +}); diff --git a/packages/ad-replay/src/internal/__tests__/step-loop.test.ts b/packages/ad-replay/src/internal/__tests__/step-loop.test.ts index d01a2917ca..795b5a0229 100644 --- a/packages/ad-replay/src/internal/__tests__/step-loop.test.ts +++ b/packages/ad-replay/src/internal/__tests__/step-loop.test.ts @@ -11,8 +11,8 @@ import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; * terminal-close suppression) is engine-private — never re-exported by the * façade (`packages/ad-replay/src/index.ts`) — so these tests exercise it * only through `runAdReplay` itself, the same way the daemon's own - * `session-replay-runtime.ts` (`runReplayScriptFile`) does. The equivalent - * daemon-level assertions (full `SessionStore`/`runReplayScriptFile` round + * `session-replay-runtime.ts` (`runReplayScriptSource`) does. The equivalent + * daemon-level assertions (full `SessionStore`/`runReplayScriptSource` round * trip, including the `--keep-session` live-session postcondition) live in * `src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts` * (renamed from `session-replay-terminal-lifecycle.test.ts` by the #1555 diff --git a/packages/ad-replay/src/internal/inspect.ts b/packages/ad-replay/src/internal/inspect.ts index 510262f0b9..cd695938d3 100644 --- a/packages/ad-replay/src/internal/inspect.ts +++ b/packages/ad-replay/src/internal/inspect.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs'; import { AppError } from '@agent-device/kernel/errors'; import type { SessionAction } from '@agent-device/contracts/session'; import { @@ -12,7 +11,7 @@ import { resolveReplayEntryIndex, type PendingRecordAndHeal } from './resume.ts' /** * #1478 P5 stage C2b: the read-only `.ad` inspection façade. Moved out of - * `session-replay-runtime.ts`'s old `parseReplayScript` (the fs read + the + * `session-replay-runtime.ts`'s old `parseReplayScript` (the * legacy-JSON-payload rejection it guarded) plus the `parseReplayInput` * composition (`src/compat/replay-input.ts`) it fed into — this is the same * `parseReplayScriptDetailed` + `readReplayScriptMetadata` pair @@ -50,12 +49,14 @@ export type AdReplayManifest = Readonly<{ export type AdReplayDigestFlags = Readonly<{ platform?: string; target?: string }>; /** - * Reads `sourcePath` once and returns its parsed actions/line table, header - * metadata, plan digest, and resume-index resolver. Throws + * Parses one `.ad` script's TEXT and returns its actions/line table, header + * metadata, plan digest, and resume-index resolver. Takes the script itself, + * never a path: #1802 made the CALLER read every script file a replay run + * needs, so nothing below this façade opens a file. Throws * `AppError('INVALID_ARGS', …)` for the one source format `.ad` replay no * longer accepts — a legacy JSON replay payload — matching the daemon's * prior explicit rejection exactly. Callers do not need to check for this - * case separately: `runReplayScriptFile`'s top-level catch (`asAppError`) + * case separately: `runReplayScriptSource`'s top-level catch (`asAppError`) * maps a thrown `AppError` straight to the same `errorResponse` the old * explicit branch built, so this is not a behavior change, only where the * check lives. @@ -67,10 +68,9 @@ export type AdReplayDigestFlags = Readonly<{ platform?: string; target?: string * `open` wins; absent that, the `context platform=`/`target=` header line. */ export function inspectAdReplay( - sourcePath: string, + script: string, digestFlags?: AdReplayDigestFlags, ): AdReplayManifest { - const script = fs.readFileSync(sourcePath, 'utf8'); const firstNonWhitespace = script.trimStart()[0]; if (firstNonWhitespace === '{' || firstNonWhitespace === '[') { throw new AppError( diff --git a/packages/ad-replay/src/internal/step-loop.ts b/packages/ad-replay/src/internal/step-loop.ts index 8561ea7b92..af5119e167 100644 --- a/packages/ad-replay/src/internal/step-loop.ts +++ b/packages/ad-replay/src/internal/step-loop.ts @@ -71,7 +71,7 @@ import type { * * #1478 P5 follow-up (one daemon-owned artifact ledger): artifact-path * accumulation used to be DOUBLE-WRITTEN — `dispatchStep` added each step's - * entries to the daemon's own `Set` (`runReplayScriptFile`'s, read by its + * entries to the daemon's own `Set` (`runReplayScriptSource`'s, read by its * catch block so a mid-loop throw still reports what was collected) AND * returned them for this loop to add to a second `Set` of its own. Two * mutable collections, kept in sync by hand, with no single owner. The diff --git a/packages/contracts/src/cli-flags.ts b/packages/contracts/src/cli-flags.ts index 116ec502fd..1f37c9e793 100644 --- a/packages/contracts/src/cli-flags.ts +++ b/packages/contracts/src/cli-flags.ts @@ -20,6 +20,7 @@ import type { } from './remote-config-fields.ts'; import type { ScreenshotRequestFlags } from './screenshot.ts'; import type { RecordingScope } from './recording-scope.ts'; +import type { ReplayScriptSourceBundle } from './replay.ts'; export type CliFlags = CloudProviderProfileFields & RemoteConfigMetroOptions & @@ -143,6 +144,13 @@ export type CliFlags = CloudProviderProfileFields & replayMaestro?: boolean; replayEnv?: string[]; replayShellEnv?: Record; + /** + * #1802: the caller-read script text `replay` executes. The daemon never + * opens a caller path, so this is the ONLY source a replay run reads. + */ + replayScriptSource?: ReplayScriptSourceBundle; + /** #1802: `test`'s caller-side discovery result — one bundle per discovered source, in run order. */ + replayScriptSources?: ReplayScriptSourceBundle[]; replayFrom?: number; replayPlanDigest?: string; /** Replay: leave the session active by suppressing an authored terminal close in native .ad. */ diff --git a/packages/contracts/src/client-request.ts b/packages/contracts/src/client-request.ts index e3532d8f4c..b8706318cf 100644 --- a/packages/contracts/src/client-request.ts +++ b/packages/contracts/src/client-request.ts @@ -15,6 +15,7 @@ import type { SessionRuntimeHints, } from '@agent-device/kernel/contracts'; import type { DaemonBatchStep } from './batch-step.ts'; +import type { ReplayScriptSourceBundle } from './replay.ts'; import type { AgentDeviceClientConfig, AgentDeviceSelectionOptions } from './client-connection.ts'; export type CommandExecutionOptions = Partial & { @@ -54,6 +55,10 @@ export type CommandExecutionOptions = Partial & { replayBackend?: string; replayEnv?: string[]; replayShellEnv?: Record; + /** #1802: caller-read script text for `replay`; the daemon never opens a caller path. */ + replayScriptSource?: ReplayScriptSourceBundle; + /** #1802: caller-side `test` discovery result — one bundle per discovered source, in run order. */ + replayScriptSources?: ReplayScriptSourceBundle[]; replayFrom?: number; replayPlanDigest?: string; replayKeepSession?: boolean; diff --git a/packages/contracts/src/facades/replay.ts b/packages/contracts/src/facades/replay.ts index 6360740dc8..dea2d64679 100644 --- a/packages/contracts/src/facades/replay.ts +++ b/packages/contracts/src/facades/replay.ts @@ -2,6 +2,7 @@ export type { RefFrameEffect } from '../ref-frame-effect.ts'; export { REPLAY_TARGET_GUARD_MISMATCH_REASON, WAIT_LANDMARK_MISMATCH_REASON } from '../replay.ts'; export type { ReplayCommandResult, + ReplayScriptSourceBundle, ReplaySuiteAttemptFailure, ReplaySuiteResult, ReplaySuiteTestFailed, diff --git a/packages/contracts/src/replay.ts b/packages/contracts/src/replay.ts index 28fd7d33bb..21de0768ea 100644 --- a/packages/contracts/src/replay.ts +++ b/packages/contracts/src/replay.ts @@ -141,3 +141,25 @@ export type ReplaySuiteResult = { tests: ReplaySuiteTestResult[]; snapshotDiagnostics?: SnapshotDiagnosticsSummary; }; + +/** + * A **replay script source bundle**: every script file one replay run needs, + * read and resolved by the CALLER and shipped with the request. + * + * `replay ` used to send only the path, which the daemon then opened on + * ITS filesystem. That works only while caller and daemon share a disk; against + * a remote daemon it fails with `ENOENT` on a path the caller can read (#1802). + * The bundle removes the class: the daemon never resolves a caller path, so a + * local run and a remote run read exactly the same bytes. + * + * `entry` is the caller-resolved absolute path of the script that was invoked — + * it is also the display path every error, line reference, and + * `actionSourcePaths` entry is stated in, and it is always a key of `files`. + * `files` maps each caller-resolved absolute path to that file's text: one + * entry for a native `.ad` script, plus one per transitively included flow for + * Maestro YAML `runFlow`. + */ +export type ReplayScriptSourceBundle = Readonly<{ + entry: string; + files: Readonly>; +}>; diff --git a/packages/maestro/src/index.ts b/packages/maestro/src/index.ts index e7b71795bf..e6a57b94c4 100644 --- a/packages/maestro/src/index.ts +++ b/packages/maestro/src/index.ts @@ -11,6 +11,13 @@ export { type MaestroFlow, } from './internal/facade-execution.ts'; +export type { MaestroSourceReader } from './internal/program-loader.ts'; + +export { + collectMaestroFlowSources, + type MaestroOptionalSourceReader, +} from './internal/source-closure.ts'; + export { exportReplayActionsToMaestro, MAESTRO_SELECTOR_PROJECTION, diff --git a/packages/maestro/src/internal/__tests__/program-loader.test.ts b/packages/maestro/src/internal/__tests__/program-loader.test.ts index 78e6842d96..89a67950c0 100644 --- a/packages/maestro/src/internal/__tests__/program-loader.test.ts +++ b/packages/maestro/src/internal/__tests__/program-loader.test.ts @@ -1,51 +1,57 @@ -import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; import { createMaestroProgramLoader, resolveMaestroIncludePath } from '../program-loader.ts'; +const ROOT = path.resolve('/flows'); + +function sourceReaderFor(files: Record): (resolvedPath: string) => string { + return (resolvedPath) => { + const source = files[resolvedPath]; + if (source === undefined) throw new Error(`no source for ${resolvedPath}`); + return source; + }; +} + test('resolves nested Maestro includes relative to their parent source', async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-maestro-loader-')); - const flowDir = path.join(root, 'flows'); - fs.mkdirSync(flowDir); + const flowDir = path.join(ROOT, 'nested'); const childPath = path.join(flowDir, 'child.yaml'); - fs.writeFileSync(childPath, '---\n- inputText: child\n'); + const loader = createMaestroProgramLoader( + ROOT, + sourceReaderFor({ [childPath]: '---\n- inputText: child\n' }), + ); - const loader = createMaestroProgramLoader(root); const program = await loader('./child.yaml', path.join(flowDir, 'parent.yaml')); expect(program.source.path).toBe(childPath); expect(program.commands[0]).toMatchObject({ kind: 'inputText', text: 'child' }); - expect(resolveMaestroIncludePath('./root.yaml', undefined, root)).toBe( - path.join(root, 'root.yaml'), + expect(resolveMaestroIncludePath('./root.yaml', undefined, ROOT)).toBe( + path.join(ROOT, 'root.yaml'), ); }); -test('caches parsed programs by resolved path and honors cancellation before I/O', async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-maestro-loader-cache-')); - const childPath = path.join(root, 'child.yaml'); - fs.writeFileSync(childPath, '---\n- back\n'); - const readFileSync = vi.spyOn(fs, 'readFileSync'); - const loader = createMaestroProgramLoader(root); +test('caches parsed programs by resolved path and honors cancellation before reading', async () => { + const childPath = path.join(ROOT, 'child.yaml'); + const readSource = vi.fn(sourceReaderFor({ [childPath]: '---\n- back\n' })); + const loader = createMaestroProgramLoader(ROOT, readSource); await loader('child.yaml'); await loader('./child.yaml'); - expect(readFileSync.mock.calls.filter(([entry]) => entry === childPath)).toHaveLength(1); + expect(readSource.mock.calls.filter(([entry]) => entry === childPath)).toHaveLength(1); const controller = new AbortController(); controller.abort(); await expect(loader('missing.yaml', undefined, controller.signal)).rejects.toMatchObject({ details: { reason: 'request_canceled' }, }); - readFileSync.mockRestore(); + expect(readSource).toHaveBeenCalledTimes(1); }); test('preserves the resolved child source path when an included flow is unsupported', async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-maestro-loader-error-')); - const childPath = path.join(root, 'child.yaml'); - fs.writeFileSync(childPath, '---\n- unsupportedCommand: true\n'); - - const loader = createMaestroProgramLoader(root); + const childPath = path.join(ROOT, 'child.yaml'); + const loader = createMaestroProgramLoader( + ROOT, + sourceReaderFor({ [childPath]: '---\n- unsupportedCommand: true\n' }), + ); await expect(loader('child.yaml')).rejects.toThrow(`${childPath}:line 2`); }); diff --git a/packages/maestro/src/internal/__tests__/source-closure.test.ts b/packages/maestro/src/internal/__tests__/source-closure.test.ts new file mode 100644 index 0000000000..f7ab8f549d --- /dev/null +++ b/packages/maestro/src/internal/__tests__/source-closure.test.ts @@ -0,0 +1,118 @@ +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; +import { collectMaestroFlowSources } from '../source-closure.ts'; + +const ROOT = path.resolve('/flows'); +const entryPath = path.join(ROOT, 'parent.yaml'); + +function readerFor(files: Record): (resolvedPath: string) => string | undefined { + return (resolvedPath) => files[resolvedPath]; +} + +// #1802: the caller ships every flow file the run can read, so the collector has to reach the +// includes nested inside `repeat`/`retry`/inline `runFlow.commands` and the lifecycle hooks — +// not just the top-level ones — and it has to do it transitively. +test('collects every transitively reachable runFlow include exactly once', () => { + const entrySource = [ + 'appId: com.example.app', + 'onFlowStart:', + ' - runFlow: setup.yaml', + 'onFlowComplete:', + ' - runFlow: teardown.yaml', + '---', + '- runFlow: shared.yaml', + '- repeat:', + ' times: 2', + ' commands:', + ' - runFlow: looped.yaml', + '- runFlow:', + ' commands:', + ' - runFlow: inline-child.yaml', + '- runFlow: shared.yaml', + ].join('\n'); + const readSource = vi.fn( + readerFor({ + [path.join(ROOT, 'setup.yaml')]: '---\n- back\n', + [path.join(ROOT, 'teardown.yaml')]: '---\n- back\n', + [path.join(ROOT, 'shared.yaml')]: '---\n- runFlow: nested/deep.yaml\n', + [path.join(ROOT, 'looped.yaml')]: '---\n- back\n', + [path.join(ROOT, 'inline-child.yaml')]: '---\n- back\n', + [path.join(ROOT, 'nested', 'deep.yaml')]: '---\n- back\n', + }), + ); + + const files = collectMaestroFlowSources({ entryPath, entrySource, readSource }); + + expect(Object.keys(files).sort()).toEqual( + [ + entryPath, + path.join(ROOT, 'setup.yaml'), + path.join(ROOT, 'teardown.yaml'), + path.join(ROOT, 'shared.yaml'), + path.join(ROOT, 'looped.yaml'), + path.join(ROOT, 'inline-child.yaml'), + path.join(ROOT, 'nested', 'deep.yaml'), + ].sort(), + ); + expect( + readSource.mock.calls.filter(([entry]) => entry === path.join(ROOT, 'shared.yaml')), + ).toHaveLength(1); +}); + +test('stops at a runFlow cycle instead of recursing forever', () => { + const files = collectMaestroFlowSources({ + entryPath, + entrySource: '---\n- runFlow: child.yaml\n', + readSource: readerFor({ + [path.join(ROOT, 'child.yaml')]: '---\n- runFlow: parent.yaml\n', + }), + }); + + expect(Object.keys(files).sort()).toEqual([path.join(ROOT, 'child.yaml'), entryPath].sort()); +}); + +test('resolves a templated include path from the variables the caller already has', () => { + const files = collectMaestroFlowSources({ + entryPath, + entrySource: ['env:', ' FLOW_NAME: checkout', '---', '- runFlow: ${FLOW_NAME}.yaml'].join( + '\n', + ), + readSource: readerFor({ [path.join(ROOT, 'checkout.yaml')]: '---\n- back\n' }), + }); + + expect(Object.keys(files)).toContain(path.join(ROOT, 'checkout.yaml')); +}); + +// Best effort past the entry flow: a `when:`-guarded include may never run, and an include path +// keyed on a value only the daemon binds cannot be known here. Neither may fail the collection — +// the run reports precisely which file was missing if it turns out to need one. +test('skips an include it cannot resolve or read without failing collection', () => { + const files = collectMaestroFlowSources({ + entryPath, + entrySource: [ + '---', + '- runFlow: ${UNKNOWN_FLOW}.yaml', + '- runFlow: absent.yaml', + '- runFlow: broken.yaml', + ].join('\n'), + readSource: readerFor({ + [path.join(ROOT, 'broken.yaml')]: '---\n- unsupportedCommand: true\n', + }), + }); + + expect(Object.keys(files).sort()).toEqual([entryPath, path.join(ROOT, 'broken.yaml')].sort()); +}); + +test('returns the entry alone when the entry flow does not parse', () => { + const entrySource = '---\n- unsupportedCommand: true\n'; + + expect( + collectMaestroFlowSources({ + entryPath, + entrySource, + readSource: () => { + throw new Error('must not read past an unparseable entry'); + }, + }), + ).toEqual({ [entryPath]: entrySource }); +}); diff --git a/packages/maestro/src/internal/facade-execution.ts b/packages/maestro/src/internal/facade-execution.ts index ea7ebd9140..a85f2db12d 100644 --- a/packages/maestro/src/internal/facade-execution.ts +++ b/packages/maestro/src/internal/facade-execution.ts @@ -16,7 +16,7 @@ import type { MaestroProgram, MaestroSourceLocation, } from './program-ir.ts'; -import { createMaestroProgramLoader } from './program-loader.ts'; +import { createMaestroProgramLoader, type MaestroSourceReader } from './program-loader.ts'; import { formatMaestroCommandProgress } from './progress.ts'; import { compileMaestroReplayPlan, @@ -92,6 +92,12 @@ export type MaestroExecutionOptions = { readonly planDigest?: string; readonly signal?: AbortSignal; readonly observer?: MaestroExecutionObserver; + /** + * #1802: how a `runFlow` include's text is obtained. Required — the engine + * owns no filesystem, so a run against a remote daemon reads the caller's + * bundled flows through exactly the port a local run reads them through. + */ + readonly readSource: MaestroSourceReader; }; export type MaestroExecutionOutcome = @@ -122,11 +128,11 @@ export function inspectMaestroFlow(source: string, sourcePath: string): MaestroF export async function executeMaestroFlow( flow: MaestroFlow, port: MaestroRuntimePort, - options: MaestroExecutionOptions = {}, + options: MaestroExecutionOptions, ): Promise { let failed: MaestroFailedAction | undefined; try { - const loader = createMaestroProgramLoader(path.dirname(flow.sourcePath)); + const loader = createMaestroProgramLoader(path.dirname(flow.sourcePath), options.readSource); const plan = await compileMaestroReplayPlan(flow[flowProgram], { defaults: options.defaults, env: options.env, diff --git a/packages/maestro/src/internal/program-loader.ts b/packages/maestro/src/internal/program-loader.ts index 6f8f7505ad..19f61bef05 100644 --- a/packages/maestro/src/internal/program-loader.ts +++ b/packages/maestro/src/internal/program-loader.ts @@ -1,23 +1,34 @@ -import fs from 'node:fs'; import path from 'node:path'; import { createRequestCanceledError } from './shared.ts'; import type { MaestroProgram } from './program-ir.ts'; import { parseMaestroProgram } from './program-ir-parser.ts'; +/** + * How a flow's text is obtained. #1802: the engine never touches a filesystem — + * the caller reads its own flows (fs) when it builds a replay script source + * bundle, and the daemon serves the same flows back out of that bundle, so a + * `runFlow` include resolves to identical bytes locally and against a remote + * daemon. + */ +export type MaestroSourceReader = (resolvedPath: string) => string; + export type MaestroProgramLoader = ( includePath: string, parentSource?: string, signal?: AbortSignal, ) => Promise; -export function createMaestroProgramLoader(cwd: string): MaestroProgramLoader { +export function createMaestroProgramLoader( + cwd: string, + readSource: MaestroSourceReader, +): MaestroProgramLoader { const programs = new Map(); return async (includePath, parentSource, signal) => { if (signal?.aborted) throw createRequestCanceledError(); const resolvedPath = resolveMaestroIncludePath(includePath, parentSource, cwd); const cached = programs.get(resolvedPath); if (cached) return cached; - const program = parseMaestroProgram(fs.readFileSync(resolvedPath, 'utf8'), { + const program = parseMaestroProgram(readSource(resolvedPath), { sourcePath: resolvedPath, }); programs.set(resolvedPath, program); diff --git a/packages/maestro/src/internal/source-closure.ts b/packages/maestro/src/internal/source-closure.ts new file mode 100644 index 0000000000..c9e30292b7 --- /dev/null +++ b/packages/maestro/src/internal/source-closure.ts @@ -0,0 +1,121 @@ +import { createMaestroExecutionContext } from './engine-context.ts'; +import type { MaestroCommand, MaestroProgram } from './program-ir.ts'; +import { parseMaestroProgram } from './program-ir-parser.ts'; +import { resolveMaestroIncludePath } from './program-loader.ts'; + +/** + * Reads one flow file, or reports that it is unavailable (#1802). Collection is + * BEST EFFORT by design — see `collectMaestroFlowSources`. + */ +export type MaestroOptionalSourceReader = (resolvedPath: string) => string | undefined; + +/** + * Every flow file a run of `entryPath` can reach through `runFlow: file:`, as + * a path-to-text map, collected before the run starts (#1802: the caller ships + * this to the daemon instead of a path the daemon would open itself). + * + * Collection is best effort past the entry flow. An include path is a template + * (`runFlow: ${FLOW_NAME}.yaml`) resolved from the execution context, and the + * context a compile really runs with also carries daemon-side defaults — the + * device, platform, and session the caller has not bound yet. So an include + * whose path does not resolve from the variables available here, and one whose + * file cannot be read, is simply left out rather than failing the whole + * collection: a `when:`-guarded include may never run at all. If a run does + * need one, the engine's loader reports exactly which file was missing from + * the sources the request carried. + */ +export function collectMaestroFlowSources(params: { + entryPath: string; + entrySource: string; + readSource: MaestroOptionalSourceReader; + env?: Readonly>; +}): Record { + const files: Record = { [params.entryPath]: params.entrySource }; + const program = tryParseMaestroProgram(params.entrySource, params.entryPath); + if (!program) return files; + visitProgram({ + program, + sourcePath: params.entryPath, + context: createMaestroExecutionContext({}, { ...params.env }), + files, + readSource: params.readSource, + }); + return files; +} + +/** + * A flow that does not parse is not a collection failure: the daemon reports + * every authoring error for the whole run, in one place, with its source + * location. Collecting stops at the unparseable file and the run does the + * reporting. + */ +function tryParseMaestroProgram(source: string, sourcePath: string): MaestroProgram | undefined { + try { + return parseMaestroProgram(source, { sourcePath }); + } catch { + return undefined; + } +} + +type VisitParams = { + program: MaestroProgram; + sourcePath: string; + context: ReturnType; + files: Record; + readSource: MaestroOptionalSourceReader; +}; + +function visitProgram(params: VisitParams): void { + const leave = params.context.enter(params.program.config.env ?? {}); + try { + visitCommands( + [ + ...(params.program.config.onFlowStart ?? []), + ...params.program.commands, + ...(params.program.config.onFlowComplete ?? []), + ], + params, + ); + } finally { + leave(); + } +} + +function visitCommands(commands: readonly MaestroCommand[], params: VisitParams): void { + for (const command of commands) { + if (command.kind === 'repeat' || command.kind === 'retry') { + visitCommands(command.commands, params); + continue; + } + if (command.kind !== 'runFlow') continue; + const leave = params.context.enter(command.env ?? {}); + try { + if (command.include.kind === 'commands') { + visitCommands(command.include.commands, params); + continue; + } + visitInclude(command.include.path, params); + } finally { + leave(); + } + } +} + +function visitInclude(includePath: string, params: VisitParams): void { + // `resolveDeferred` leaves an unknown `${VAR}` in place instead of throwing, which is how an + // include this side cannot resolve gets recognized and skipped. + const resolvedInclude = params.context.resolveDeferred(includePath); + if (resolvedInclude.includes('${')) return; + const resolvedPath = resolveMaestroIncludePath( + resolvedInclude, + params.sourcePath, + params.sourcePath, + ); + if (params.files[resolvedPath] !== undefined) return; + const source = params.readSource(resolvedPath); + if (source === undefined) return; + params.files[resolvedPath] = source; + const program = tryParseMaestroProgram(source, resolvedPath); + if (!program) return; + visitProgram({ ...params, program, sourcePath: resolvedPath }); +} diff --git a/packages/replay-test/src/internal/__tests__/session-test-discovery.test.ts b/packages/replay-test/src/internal/__tests__/session-test-discovery.test.ts index 3f2a3abb6e..4d26cc6801 100644 --- a/packages/replay-test/src/internal/__tests__/session-test-discovery.test.ts +++ b/packages/replay-test/src/internal/__tests__/session-test-discovery.test.ts @@ -26,7 +26,6 @@ const sourcesOf = test('platform filter skips sources that declared no platform', () => { const entries = discoverReplayTestEntries({ - inputs: ['suite'], platformFilter: 'android', discoverSources: sourcesOf( { path: '01-untyped.ad', manifest: unspecified }, @@ -44,7 +43,6 @@ test('platform filter skips sources that declared no platform', () => { test('platform filter runs caller-bound sources, which take their platform from the invocation', () => { const entries = discoverReplayTestEntries({ - inputs: ['suite'], platformFilter: 'android', discoverSources: sourcesOf({ path: '01-flow.yaml', manifest: callerBound }), }); @@ -55,7 +53,6 @@ test('platform filter runs caller-bound sources, which take their platform from test('platform filter drops declared sources that do not match, without a skip entry', () => { const entries = discoverReplayTestEntries({ - inputs: ['suite'], platformFilter: 'android', discoverSources: sourcesOf( { path: '01-ios.ad', manifest: declared('ios') }, @@ -73,7 +70,6 @@ test('a suite that matched nothing after filtering is rejected', () => { assert.throws( () => discoverReplayTestEntries({ - inputs: ['suite'], platformFilter: 'android', discoverSources: sourcesOf({ path: '01-ios.ad', manifest: declared('ios') }), }), diff --git a/packages/replay-test/src/internal/session-test-discovery.ts b/packages/replay-test/src/internal/session-test-discovery.ts index 0d8550ffd1..3dc97a766f 100644 --- a/packages/replay-test/src/internal/session-test-discovery.ts +++ b/packages/replay-test/src/internal/session-test-discovery.ts @@ -34,13 +34,11 @@ export type ReplayTestRunEntry = Extract; /** - * Expands the caller's inputs and inspects each source (#1478 P3b). + * Inspects each source the caller sent (#1478 P3b). * - * Path expansion, file reading, format routing, and per-engine inspection are all host work. + * Format routing and per-engine inspection are host work. #1802: so is expansion — the CALLER + * expands its own inputs and ships each source's text, so nothing here takes inputs or a cwd. * The scheduler keeps discovery *policy* — platform filtering, run/skip classification, and * the "no replay tests matched" error — which is the part that is genuinely format-neutral. */ -export type ReplayTestDiscoverSources = (params: { - inputs: string[]; - cwd?: string; -}) => readonly ReplayTestSource[]; +export type ReplayTestDiscoverSources = () => readonly ReplayTestSource[]; /** * One execution step an engine reports while an attempt runs (#1478 P3, finding 1). diff --git a/packages/replay-test/src/internal/session-test.ts b/packages/replay-test/src/internal/session-test.ts index 8e3051accf..af48e3ccdd 100644 --- a/packages/replay-test/src/internal/session-test.ts +++ b/packages/replay-test/src/internal/session-test.ts @@ -162,8 +162,6 @@ function discoverReplayTestSuiteEntries( discoverSources: ReplayTestDiscoverSources, ): ReplayTestEntry[] { return discoverReplayTestEntries({ - inputs: [...request.inputs], - cwd: request.cwd, platformFilter: request.platformFilter, discoverSources, }); diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index c305d36d55..e7e19af326 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -17,6 +17,15 @@ import { AppError } from '@agent-device/kernel/errors'; import { resolveCliOptions } from '../cli/resolve-cli-options.ts'; import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; +// #1802: the replay client reads the script it names, so CLI-level replay cases need a real file. +const REPLAY_SCRIPT_ROOT = mkdtempForTestSync('agent-device-cli-replay-scripts-'); + +function writeReplayScript(name: string): string { + const scriptPath = path.join(REPLAY_SCRIPT_ROOT, name); + fs.writeFileSync(scriptPath, 'open "Demo"\n'); + return scriptPath; +} + test('install-from-source forwards URL and repeated headers to client.apps.installFromSource', async () => { let observed: AppInstallFromSourceOptions | undefined; const client = createStubClient({ @@ -721,33 +730,40 @@ test('replay without a path reaches replay validation instead of unknown command }); test('replay path falls through to the generic client command route', async () => { - const missingPath = '/tmp/does-not-exist.ad'; - const result = await runCliCapture(['replay', missingPath], async (request) => { + const scriptPath = writeReplayScript('fallthrough.ad'); + const result = await runCliCapture(['replay', scriptPath], async (request) => { assert.equal(request.command, 'replay'); - assert.deepEqual(request.positionals, [missingPath]); - return { - ok: false, - error: { - code: 'UNKNOWN', - message: `ENOENT: no such file or directory, open '${missingPath}'`, - }, - }; + assert.deepEqual(request.positionals, [scriptPath]); + return { ok: false, error: { code: 'COMMAND_FAILED', message: 'replay step failed' } }; }); assert.equal(result.code, 1); assert.equal(result.calls.length, 1); - assert.match(result.stderr, /ENOENT/); + assert.match(result.stderr, /replay step failed/); assert.doesNotMatch(result.stderr, /Unknown command: replay/); }); +// #1802: the caller reads the script, so a path that does not resolve on THIS machine is refused +// here — before any daemon round-trip — naming the path exactly as it was typed. Against a remote +// daemon this used to travel and come back as an ENOENT for a file the caller can read. +test('replay refuses a script missing on the caller before any daemon request', async () => { + const missingPath = '/tmp/agent-device-does-not-exist.ad'; + const result = await runCliCapture(['replay', missingPath], async () => ({ + ok: true, + data: {}, + })); + + assert.equal(result.code, 1); + assert.equal(result.calls.length, 0); + assert.match(result.stderr, /replay script not found on this machine/); + assert.match(result.stderr, new RegExp(missingPath.replace(/[.]/g, '\\.'))); +}); + test('replay --timeout reaches the daemon request envelope through the public CLI', async () => { - const missingPath = '/tmp/does-not-exist.ad'; - const result = await runCliCapture(['replay', missingPath, '--timeout', '1000'], async () => ({ + const scriptPath = writeReplayScript('timeout.ad'); + const result = await runCliCapture(['replay', scriptPath, '--timeout', '1000'], async () => ({ ok: false, - error: { - code: 'UNKNOWN', - message: `ENOENT: no such file or directory, open '${missingPath}'`, - }, + error: { code: 'COMMAND_FAILED', message: 'replay step failed' }, })); assert.equal(result.code, 1); diff --git a/src/__tests__/cli-network.test.ts b/src/__tests__/cli-network.test.ts index 9b4bd34edd..42cffac0b4 100644 --- a/src/__tests__/cli-network.test.ts +++ b/src/__tests__/cli-network.test.ts @@ -1,9 +1,26 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { promises as fs } from 'node:fs'; +import nodeFs, { promises as fs } from 'node:fs'; import path from 'node:path'; -import { runCliCapture } from './cli-capture.ts'; -import { mkdtempForTest } from './test-utils/tmp-dir.ts'; +import { runCliCapture, type CliCaptureOptions } from './cli-capture.ts'; +import { mkdtempForTest, mkdtempForTestSync } from './test-utils/tmp-dir.ts'; + +/** + * #1802: `test ` expands on the CALLER, so these reporter cases need a real suite + * directory to expand. The suite's contents are irrelevant here — every case stubs the daemon + * response — but the inputs have to resolve, so each runs from a cwd that has `./suite`. + */ +const SUITE_CWD = mkdtempForTestSync('agent-device-cli-suite-'); +nodeFs.mkdirSync(path.join(SUITE_CWD, 'suite'), { recursive: true }); +nodeFs.writeFileSync(path.join(SUITE_CWD, 'suite', '01-flow.ad'), 'open "Demo"\n'); + +function runTestSuiteCli( + argv: string[], + responderOrOptions: Parameters[1] = {}, + extraOptions: CliCaptureOptions = {}, +): ReturnType { + return runCliCapture(argv, responderOrOptions, { ...extraOptions, cwd: SUITE_CWD }); +} function makeFailedReplayResult() { return { @@ -134,7 +151,7 @@ test('json commands do not opt into progress streaming', async () => { }); test('test command prints suite summary and exits non-zero on failures', async () => { - const result = await runCliCapture(['test', './suite'], async () => makeReplaySuiteResponse()); + const result = await runTestSuiteCli(['test', './suite'], async () => makeReplaySuiteResponse()); assert.equal(result.code, 1); assert.equal(result.calls.length, 1); @@ -189,7 +206,7 @@ test('doctor command keeps json output non-streaming', async () => { }); test('test command --verbose prints all test statuses', async () => { - const result = await runCliCapture(['test', './suite', '--verbose'], async () => + const result = await runTestSuiteCli(['test', './suite', '--verbose'], async () => makeReplaySuiteResponse(), ); @@ -202,7 +219,7 @@ test('test command --verbose prints all test statuses', async () => { }); test('test command colors suite summary segments when color is enabled', async () => { - const result = await runCliCapture( + const result = await runTestSuiteCli( ['test', './suite'], async () => ({ ok: true, @@ -282,7 +299,7 @@ test('test command --verbose omits step telemetry for passing tests without debu ); try { - const result = await runCliCapture(['test', './suite', '--verbose'], async () => ({ + const result = await runTestSuiteCli(['test', './suite', '--verbose'], async () => ({ ok: true, data: { total: 1, @@ -350,46 +367,49 @@ test('test command --verbose includes step telemetry in completed progress outpu ); try { - const result = await runCliCapture(['test', './suite', '--verbose'], async (_req, options) => { - options?.onProgress?.({ - type: 'replay-test', - file: '/tmp/auth-flow.yml', - title: 'Authentication flow', - status: 'pass', - index: 1, - total: 1, - durationMs: 500, - attempt: 1, - artifactsDir, - }); - return { - ok: true, - data: { + const result = await runTestSuiteCli( + ['test', './suite', '--verbose'], + async (_req, options) => { + options?.onProgress?.({ + type: 'replay-test', + file: '/tmp/auth-flow.yml', + title: 'Authentication flow', + status: 'pass', + index: 1, total: 1, - executed: 1, - passed: 1, - failed: 0, - skipped: 0, - notRun: 0, durationMs: 500, - failures: [], - tests: [ - { - file: '/tmp/auth-flow.yml', - title: 'Authentication flow', - session: 'default:test:suite:1', - status: 'passed', - durationMs: 500, - finalAttemptDurationMs: 500, - attempts: 1, - artifactsDir, - replayed: 1, - healed: 0, - }, - ], - }, - }; - }); + attempt: 1, + artifactsDir, + }); + return { + ok: true, + data: { + total: 1, + executed: 1, + passed: 1, + failed: 0, + skipped: 0, + notRun: 0, + durationMs: 500, + failures: [], + tests: [ + { + file: '/tmp/auth-flow.yml', + title: 'Authentication flow', + session: 'default:test:suite:1', + status: 'passed', + durationMs: 500, + finalAttemptDurationMs: 500, + attempts: 1, + artifactsDir, + replayed: 1, + healed: 0, + }, + ], + }, + }; + }, + ); assert.equal(result.code, null); assert.equal(result.calls[0]?.meta?.debug, false); @@ -461,7 +481,7 @@ test('test command --verbose omits nested passing step telemetry', async () => { ); try { - const result = await runCliCapture(['test', './suite', '--verbose'], async () => ({ + const result = await runTestSuiteCli(['test', './suite', '--verbose'], async () => ({ ok: true, data: { total: 1, @@ -509,7 +529,7 @@ test('test command --verbose omits nested passing step telemetry', async () => { }); test('test command reports flaky passed-on-retry cases in the default summary', async () => { - const result = await runCliCapture(['test', './suite'], async () => ({ + const result = await runTestSuiteCli(['test', './suite'], async () => ({ ok: true, data: { total: 1, @@ -649,7 +669,7 @@ test('test command --debug prints failed attempt step window when timing trace e message: 'Replay failed at step 3 (assertVisible "Receipt"): selector not found', }, }; - const result = await runCliCapture(['test', './suite', '--debug'], async () => ({ + const result = await runTestSuiteCli(['test', './suite', '--debug'], async () => ({ ok: true, data: { total: 1, @@ -691,7 +711,7 @@ test('test --maestro forwards Maestro backend and platform for directory suites' ); try { - const result = await runCliCapture( + const result = await runTestSuiteCli( ['test', '--maestro', '--platform', 'android', tmpDir], async () => ({ ok: true, @@ -723,7 +743,7 @@ test('test --maestro forwards Maestro backend and platform for directory suites' }); test('test forwards shard flags and comma device lists', async () => { - const result = await runCliCapture( + const result = await runTestSuiteCli( ['test', '--maestro', '--device', 'udid1,emulator-5554', '--shard-all', '2', './suite'], async () => ({ ok: true, @@ -753,7 +773,7 @@ test('test command writes JUnit report with failure metadata', async () => { const reportPath = path.join(tmpDir, 'replays.junit.xml'); try { - const result = await runCliCapture( + const result = await runTestSuiteCli( ['test', './suite', '--report-junit', reportPath], async () => ({ ok: true, @@ -857,7 +877,7 @@ test('test command supports explicit reporter lists', async () => { const reportPath = path.join(tmpDir, 'replays.junit.xml'); try { - const result = await runCliCapture( + const result = await runTestSuiteCli( ['test', './suite', '--reporter', `junit:${reportPath}`], async () => makeReplaySuiteResponse(), ); @@ -894,7 +914,7 @@ test('test command loads custom reporter modules', async () => { 'utf8', ); - const result = await runCliCapture( + const result = await runTestSuiteCli( ['test', './suite', '--reporter', reporterPath], async (_req, options) => { options?.onProgress?.({ @@ -946,7 +966,7 @@ test('test command streams progress to custom reporter modules', async () => { 'utf8', ); - const result = await runCliCapture( + const result = await runTestSuiteCli( ['test', './suite', '--reporter', reporterPath], async (_req, options) => { options?.onProgress?.({ @@ -1004,7 +1024,7 @@ test('test command reuses custom reporter instance for progress and final output 'utf8', ); - const result = await runCliCapture( + const result = await runTestSuiteCli( ['test', './suite', '--reporter', reporterPath], async (_req, options) => { options?.onProgress?.({ @@ -1048,7 +1068,7 @@ test('test command surfaces a throwing live reporter hook without aborting the r 'utf8', ); - const result = await runCliCapture( + const result = await runTestSuiteCli( ['test', './suite', '--reporter', reporterPath], async (_req, options) => { options?.onProgress?.({ diff --git a/src/__tests__/cli-replay-script-sources.test.ts b/src/__tests__/cli-replay-script-sources.test.ts new file mode 100644 index 0000000000..c78acb4762 --- /dev/null +++ b/src/__tests__/cli-replay-script-sources.test.ts @@ -0,0 +1,94 @@ +/** + * #1802, at the surface the reporter drives: a Linux CI runner with + * `AGENT_DEVICE_DAEMON_BASE_URL` pointed at a remote daemon, and the `.ad` flows in the runner's + * own checkout. The request the CLI puts on the wire must carry the script's CONTENT — a bare + * path is a path on a filesystem the daemon cannot see. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; +import { runCliCapture } from './cli-capture.ts'; +import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; + +const REMOTE_DAEMON_ENV = { + AGENT_DEVICE_DAEMON_BASE_URL: 'http://remote-mac.example.test:7777/agent-device', + AGENT_DEVICE_DAEMON_AUTH_TOKEN: 'remote-secret', +}; + +function makeFlowsCheckout(files: Record): string { + const root = mkdtempForTestSync('agent-device-cli-remote-replay-'); + for (const [relativePath, contents] of Object.entries(files)) { + const absolutePath = path.join(root, relativePath); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, contents); + } + // The CLI reports its cwd as the resolved real path (macOS `/tmp` is a symlink), and that is + // what the bundle's keys are built from. + return fs.realpathSync(root); +} + +test('replay against a remote daemon sends the script content, not just the path', async () => { + const script = 'open "Demo"\nclick "Checkout"\n'; + const cwd = makeFlowsCheckout({ 'flows/login.ad': script }); + + const result = await runCliCapture(['replay', './flows/login.ad', '--platform', 'ios'], { + cwd, + env: REMOTE_DAEMON_ENV, + defaultResponse: { ok: true, data: { replayed: 2 } }, + }); + + assert.equal(result.calls.length, 1); + const request = result.calls[0]; + assert.equal(request?.command, 'replay'); + assert.deepEqual(request?.positionals, ['./flows/login.ad']); + const bundle = request?.flags?.replayScriptSource as ReplayScriptSourceBundle | undefined; + assert.equal(bundle?.entry, path.join(cwd, 'flows/login.ad')); + assert.deepEqual(bundle?.files, { [path.join(cwd, 'flows/login.ad')]: script }); +}); + +test('test against a remote daemon expands its inputs locally and sends every suite source', async () => { + const first = 'open "First"\n'; + const second = 'open "Second"\n'; + const cwd = makeFlowsCheckout({ 'flows/01-first.ad': first, 'flows/02-second.ad': second }); + + const result = await runCliCapture(['test', './flows'], { + cwd, + env: REMOTE_DAEMON_ENV, + defaultResponse: { ok: true, data: { tests: [], total: 0, passed: 0, failed: 0 } }, + }); + + assert.equal(result.calls.length, 1); + const bundles = result.calls[0]?.flags?.replayScriptSources as + | ReplayScriptSourceBundle[] + | undefined; + assert.deepEqual(bundles?.map((bundle) => path.basename(bundle.entry)).sort(), [ + '01-first.ad', + '02-second.ad', + ]); + assert.deepEqual( + bundles?.map((bundle) => bundle.files[bundle.entry]).sort(), + [first, second].sort(), + ); +}); + +test('a Maestro replay against a remote daemon sends its runFlow includes too', async () => { + const entry = 'appId: com.example.app\n---\n- runFlow: shared/login.yaml\n'; + const included = '---\n- back\n'; + const cwd = makeFlowsCheckout({ + 'flows/checkout.yaml': entry, + 'flows/shared/login.yaml': included, + }); + + const result = await runCliCapture( + ['replay', './flows/checkout.yaml', '--maestro', '--platform', 'ios'], + { cwd, env: REMOTE_DAEMON_ENV, defaultResponse: { ok: true, data: { replayed: 1 } } }, + ); + + const bundle = result.calls[0]?.flags?.replayScriptSource as ReplayScriptSourceBundle | undefined; + assert.deepEqual(bundle?.files, { + [path.join(cwd, 'flows/checkout.yaml')]: entry, + [path.join(cwd, 'flows/shared/login.yaml')]: included, + }); +}); diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index b221aaaba6..e1ad75120a 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -30,11 +30,30 @@ import type { DaemonResponseData, } from '@agent-device/kernel/contracts'; import { AppError } from '@agent-device/kernel/errors'; +import fs from 'node:fs'; +import nodePath from 'node:path'; import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; // Isolated so open/close metro-session-hint file writes never touch the real state dir. const TEST_STATE_DIR = mkdtempForTestSync('agent-device-client-test-'); +// #1802: replay/test requests carry the script text the CLIENT read, so these cases need real +// files. `cwd` is what the writer resolves the caller's relative path against. +const FLOWS_CWD = mkdtempForTestSync('agent-device-client-flows-'); +for (const relativePath of [ + 'flows/login.ad', + 'flows/login.yaml', + 'flows/mod-lists.yaml', + 'e2e/maestro/01-flow.yaml', +]) { + const absolutePath = nodePath.join(FLOWS_CWD, relativePath); + fs.mkdirSync(nodePath.dirname(absolutePath), { recursive: true }); + fs.writeFileSync( + absolutePath, + relativePath.endsWith('.ad') ? 'open "Demo"\n' : 'appId: demo\n---\n- launchApp\n', + ); +} + type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; @@ -883,6 +902,7 @@ test('replay.run serializes client-collected AD_VAR shell env into daemon reques await client.replay.run({ path: './flows/login.ad', + cwd: FLOWS_CWD, env: ['APP_ID=cli-override'], }); @@ -913,6 +933,7 @@ test('replay.run keeps deprecated maestro option as backend alias', async () => await client.replay.run({ path: './flows/login.yaml', + cwd: FLOWS_CWD, maestro: true, }); @@ -928,6 +949,7 @@ test('replay.run forwards timeout budget', async () => { await client.replay.run({ path: './flows/mod-lists.yaml', + cwd: FLOWS_CWD, backend: 'maestro', timeoutMs: 240_000, }); @@ -943,6 +965,7 @@ test('replay.test keeps backend alias for suite discovery', async () => { await client.replay.test({ paths: ['./flows/login.yaml'], + cwd: FLOWS_CWD, backend: 'maestro', }); @@ -958,6 +981,7 @@ test('replay.test forwards recordVideo for per-attempt video recording', async ( await client.replay.test({ paths: ['./flows/login.ad'], + cwd: FLOWS_CWD, recordVideo: true, }); @@ -972,6 +996,7 @@ test('structured replay.test command forwards Maestro backend for suite discover await runCommand(client, 'test', { paths: ['./e2e/maestro'], + cwd: FLOWS_CWD, backend: 'maestro', platform: 'android', }); @@ -989,10 +1014,12 @@ test('structured replay commands keep deprecated Maestro boolean alias', async ( await runCommand(client, 'replay', { path: './flows/login.yaml', + cwd: FLOWS_CWD, maestro: true, }); await runCommand(client, 'test', { paths: ['./e2e/maestro'], + cwd: FLOWS_CWD, maestro: true, platform: 'android', }); diff --git a/src/__tests__/test-utils/replay-script-source.ts b/src/__tests__/test-utils/replay-script-source.ts new file mode 100644 index 0000000000..15c308a58f --- /dev/null +++ b/src/__tests__/test-utils/replay-script-source.ts @@ -0,0 +1,70 @@ +import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; +import type { MaestroSourceReader } from '@agent-device/maestro'; +import type { DaemonRequest } from '../../daemon/types.ts'; +import { buildReplayScriptSourceBundle } from '../../replay/script-source-bundle.ts'; +import { discoverReplaySourcePaths } from '../../replay/source-discovery.ts'; + +/** + * Builds a replay script source bundle from a file on disk the way a real + * caller does (#1802) — through the client's own builder, so a test never + * hand-rolls a bundle shape the CLI would not actually send. + */ +export function replayScriptSourceBundleFor( + filePath: string, + options: { replayBackend?: string } = {}, +): ReplayScriptSourceBundle { + return buildReplayScriptSourceBundle({ + inputPath: filePath, + cwd: process.cwd(), + replayBackend: options.replayBackend, + }); +} + +/** + * `readSource` for a flow that declares no `runFlow` includes: the engine must + * never reach for one, so a call here means the fixture drifted rather than a + * missing file. + */ +export const noMaestroIncludeSources: MaestroSourceReader = (resolvedPath) => { + throw new Error(`unexpected Maestro include read: ${resolvedPath}`); +}; + +/** + * Attaches the script sources a real client would have collected for a `replay`/`test` request + * (#1802): the daemon opens no caller path, so every request that reaches one carries them. + * Test harnesses that stand in for "a request as it arrives at the daemon" call this so their + * cases keep expressing what they are about; a request that already carries its own sources (or + * deliberately carries none, to pin the daemon's refusal) is returned untouched. + */ +export function withClientReplayScriptSources(req: DaemonRequest): DaemonRequest { + if (req.command !== 'replay' && req.command !== 'test') return req; + const inputs = req.positionals ?? []; + if (inputs.length === 0) return req; + const cwd = req.meta?.cwd ?? process.cwd(); + const replayBackend = req.flags?.replayBackend; + const entryPath = inputs[0]; + if (req.command === 'replay') { + if (req.flags?.replayScriptSource || entryPath === undefined) return req; + return { + ...req, + flags: { + ...(req.flags ?? {}), + replayScriptSource: buildReplayScriptSourceBundle({ + inputPath: entryPath, + cwd, + replayBackend, + }), + }, + }; + } + if (req.flags?.replayScriptSources) return req; + return { + ...req, + flags: { + ...(req.flags ?? {}), + replayScriptSources: discoverReplaySourcePaths({ inputs, cwd, replayBackend }).map( + (inputPath) => buildReplayScriptSourceBundle({ inputPath, cwd, replayBackend }), + ), + }, + }; +} diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 808be759a4..9b43cff31b 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -203,6 +203,9 @@ Escalate: Use this for reusable .ad script authoring (save-script), scripted destination guards, secret-safe fills, batch multi-step JSON, replay divergence/repair, and evidence recording. +Script paths are the caller's: + replay and test resolve and read on the machine running the command, then send the script content (Maestro runFlow includes too) with the request. The same flows therefore run against a local daemon and against a remote one (AGENT_DEVICE_DAEMON_BASE_URL) with no copy step, and a missing script fails immediately, naming the path you typed. --save-script writes on the DAEMON host and is rejected against a remote daemon. + Reusable open-to-destination scripts: Arm recording on the first open, perform the full journey, verify the destination with a selector-targeted wait, then publish without closing: agent-device open com.example.app --relaunch --save-script=screen-x.ad diff --git a/src/commands/command-flags.ts b/src/commands/command-flags.ts index 2128825ed8..8fa88063d9 100644 --- a/src/commands/command-flags.ts +++ b/src/commands/command-flags.ts @@ -100,6 +100,8 @@ function buildFlags(options: InternalRequestOptions): CommandFlags { replayBackend: options.replayBackend, replayEnv: options.replayEnv, replayShellEnv: options.replayShellEnv, + replayScriptSource: options.replayScriptSource, + replayScriptSources: options.replayScriptSources, replayFrom: options.replayFrom, replayPlanDigest: options.replayPlanDigest, replayKeepSession: options.replayKeepSession, diff --git a/src/commands/replay/index.test.ts b/src/commands/replay/index.test.ts index 67380ff97a..9a42ca69d3 100644 --- a/src/commands/replay/index.test.ts +++ b/src/commands/replay/index.test.ts @@ -1,5 +1,8 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { afterEach, describe, expect, test } from 'vitest'; import type { CliFlags } from '@agent-device/contracts/command'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; import { replayCliReader, replayCommandDefinition, @@ -13,6 +16,17 @@ import { const ORIGINAL_AD_VAR = process.env.AD_VAR_REPLAY_TEST; +// #1802: the daemon writers READ the scripts they name, so writer cases need real files. `cwd` is +// the caller's working directory the writer resolves relative paths against — the CLI passes +// `process.cwd()`, and pinning it here is what proves the resolution happens caller-side. +const SCRIPT_ROOT = mkdtempForTestSync('agent-device-replay-writer-'); +const CHECKOUT_SCRIPT = 'open "Demo"\nclick "Checkout"\n'; +for (const name of ['checkout.ad', 'probe-hang.ad', 'suite.ad', 'flow.ad']) { + fs.writeFileSync(path.join(SCRIPT_ROOT, name), CHECKOUT_SCRIPT); +} +fs.mkdirSync(path.join(SCRIPT_ROOT, 'suite'), { recursive: true }); +fs.writeFileSync(path.join(SCRIPT_ROOT, 'suite', '01-first.ad'), CHECKOUT_SCRIPT); + function flags(overrides: Partial = {}): CliFlags { return overrides as CliFlags; } @@ -113,6 +127,7 @@ describe('replay command interface', () => { expect( replayDaemonWriter({ path: './checkout.ad', + cwd: SCRIPT_ROOT, update: true, backend: 'maestro', env: ['FOO=bar'], @@ -130,6 +145,7 @@ describe('replay command interface', () => { const testRequest = testDaemonWriter({ paths: ['./suite.ad'], + cwd: SCRIPT_ROOT, maestro: true, metroHost: '127.0.0.1', metroPort: 8083, @@ -151,7 +167,7 @@ describe('replay command interface', () => { const input = replayCliReader(['./probe-hang.ad'], flags({ timeoutMs: 1_000 })); expect(input).toMatchObject({ path: './probe-hang.ad', timeoutMs: 1_000 }); - expect(replayDaemonWriter(input)).toMatchObject({ + expect(replayDaemonWriter({ ...input, cwd: SCRIPT_ROOT })).toMatchObject({ command: 'replay', positionals: ['./probe-hang.ad'], options: { timeoutMs: 1_000 }, @@ -219,6 +235,7 @@ describe('replay resume (ADR 0012 decision 4 / migration step 5)', () => { expect( replayDaemonWriter({ path: './checkout.ad', + cwd: SCRIPT_ROOT, resumeFrom: 3, resumePlanDigest: 'deadbeef', }), @@ -233,7 +250,7 @@ describe('replay resume (ADR 0012 decision 4 / migration step 5)', () => { }); test('test daemon writer never emits replayFrom/replayPlanDigest', () => { - const request = testDaemonWriter({ paths: ['./suite.ad'] }); + const request = testDaemonWriter({ paths: ['./suite.ad'], cwd: SCRIPT_ROOT }); expect(request.options).not.toHaveProperty('replayFrom'); expect(request.options).not.toHaveProperty('replayPlanDigest'); }); @@ -243,7 +260,7 @@ describe('replay --keep-session', () => { test('projects the CLI flag through structured replay input and the daemon request', () => { const input = replayCliReader(['./checkout.ad'], flags({ replayKeepSession: true })); expect(input).toMatchObject({ path: './checkout.ad', keepSession: true }); - expect(replayDaemonWriter(input)).toMatchObject({ + expect(replayDaemonWriter({ ...input, cwd: SCRIPT_ROOT })).toMatchObject({ command: 'replay', positionals: ['./checkout.ad'], options: { replayKeepSession: true }, @@ -255,7 +272,9 @@ describe('replay --keep-session', () => { const input = testCliReader(['./suite.ad'], flags({ replayKeepSession: true } as never)); expect(input).not.toHaveProperty('keepSession'); expect(testCommandMetadata.inputSchema.properties).not.toHaveProperty('keepSession'); - expect(testDaemonWriter(input).options).not.toHaveProperty('replayKeepSession'); + expect(testDaemonWriter({ ...input, cwd: SCRIPT_ROOT }).options).not.toHaveProperty( + 'replayKeepSession', + ); }); }); @@ -277,12 +296,16 @@ describe('replay --save-script arming (ADR 0012 decision 6, R1/R6)', () => { }); test('writes saveScript onto the daemon request unchanged', () => { - expect(replayDaemonWriter({ path: './checkout.ad', saveScript: true })).toMatchObject({ + expect( + replayDaemonWriter({ path: './checkout.ad', cwd: SCRIPT_ROOT, saveScript: true }), + ).toMatchObject({ command: 'replay', positionals: ['./checkout.ad'], options: { saveScript: true }, }); - expect(replayDaemonWriter({ path: './checkout.ad', saveScript: './out.ad' })).toMatchObject({ + expect( + replayDaemonWriter({ path: './checkout.ad', cwd: SCRIPT_ROOT, saveScript: './out.ad' }), + ).toMatchObject({ command: 'replay', positionals: ['./checkout.ad'], options: { saveScript: './out.ad' }, diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts index ec1651bed3..de8fecf04c 100644 --- a/src/commands/replay/index.ts +++ b/src/commands/replay/index.ts @@ -21,6 +21,13 @@ import { import type { CliReader, CommandInput, DaemonWriter } from '../cli-grammar/types.ts'; import { METRO_RELOAD_FLAGS, REPLAY_FLAGS } from '../cli-grammar/flag-groups.ts'; import { withCommandRuntimeHints } from '../runtime-hints.ts'; +import { + collectReplayShellEnv, + parseReplayCliEnvEntries, + readReplayCliEnvEntries, +} from '@agent-device/ad-script'; +import { buildReplayScriptSourceBundle } from '../../replay/script-source-bundle.ts'; +import { discoverReplaySourcePaths } from '../../replay/source-discovery.ts'; const REPLAY_COMMAND_NAME = 'replay'; const TEST_COMMAND_NAME = 'test'; @@ -178,27 +185,57 @@ export const testCliReader: CliReader = (positionals, flags) => ({ shardSplit: flags.shardSplit, }); -export const replayDaemonWriter: DaemonWriter = (input) => - request(REPLAY_COMMAND_NAME, [requiredDaemonString(input.path, 'replay requires path')], { +export const replayDaemonWriter: DaemonWriter = (input) => { + const inputPath = requiredDaemonString(input.path, 'replay requires path'); + const replayBackend = readReplayBackend(input); + const replayShellEnv = collectReplayClientShellEnv(process.env); + return request(REPLAY_COMMAND_NAME, [inputPath], { ...input, replayUpdate: input.update, - replayBackend: readReplayBackend(input), + replayBackend, replayEnv: input.env, - replayShellEnv: collectReplayClientShellEnv(process.env), + replayShellEnv, + // #1802: the caller owns the flow files, so it reads them here — the same + // client-collects-local-input move `replayShellEnv` already makes — and + // the daemon executes only what arrives in this bundle. + replayScriptSource: buildReplayScriptSourceBundle({ + inputPath, + cwd: readReplayClientCwd(input), + replayBackend, + env: readReplayScriptSourceEnv(input, replayShellEnv), + }), replayFrom: input.resumeFrom, replayPlanDigest: input.resumePlanDigest, replayKeepSession: input.keepSession, saveScript: input.saveScript, }); +}; -export const testDaemonWriter: DaemonWriter = (input) => - request(TEST_COMMAND_NAME, input.paths ?? [], { +export const testDaemonWriter: DaemonWriter = (input) => { + const inputs = input.paths ?? []; + const replayBackend = readReplayBackend(input); + const cwd = readReplayClientCwd(input); + const replayShellEnv = collectReplayClientShellEnv(process.env); + return request(TEST_COMMAND_NAME, inputs, { ...stripReplayTestPresentationInput(input), replayUpdate: input.update, - replayBackend: readReplayBackend(input), + replayBackend, replayEnv: input.env, - replayShellEnv: collectReplayClientShellEnv(process.env), + replayShellEnv, + // #1802: `test` inputs are paths, directories, and globs on the CALLER's + // filesystem. Expanding them here keeps discovery order identical for a + // local and a remote daemon and ships each discovered source's text. + replayScriptSources: discoverReplaySourcePaths({ inputs, cwd, replayBackend }).map( + (inputPath) => + buildReplayScriptSourceBundle({ + inputPath, + cwd, + replayBackend, + env: readReplayScriptSourceEnv(input, replayShellEnv), + }), + ), }); +}; const replayCommandFacet = defineCommandFacet({ name: REPLAY_COMMAND_NAME, @@ -231,6 +268,25 @@ export const replayCommandFamily = defineCommandFamilyFromFacets({ commands: [replayCommandFacet, testCommandFacet], }); +/** + * The `${VAR}` values a Maestro `runFlow` include path can be resolved from before the run + * starts — the same shell (`AD_VAR_*`) and `-e KEY=VALUE` sources the run itself merges into its + * environment, read here so collection sees what the run will see. + */ +function readReplayScriptSourceEnv( + input: CommandInput, + replayShellEnv: Record, +): Record { + return { + ...collectReplayShellEnv(replayShellEnv), + ...parseReplayCliEnvEntries(readReplayCliEnvEntries(input.env)), + }; +} + +function readReplayClientCwd(input: CommandInput): string { + return typeof input.cwd === 'string' && input.cwd.length > 0 ? input.cwd : process.cwd(); +} + function readReplayBackend(input: CommandInput): string | undefined { return input.backend ?? (input.maestro === true ? 'maestro' : undefined); } diff --git a/src/daemon/__tests__/replay-coordinator-ownership.test.ts b/src/daemon/__tests__/replay-coordinator-ownership.test.ts index 1c722bac3b..52afe64029 100644 --- a/src/daemon/__tests__/replay-coordinator-ownership.test.ts +++ b/src/daemon/__tests__/replay-coordinator-ownership.test.ts @@ -10,7 +10,7 @@ import { parseSync } from 'oxc-parser'; * `ReplayCoordinator`, and a `ReplayCoordinator`/`ReplayResumeStamper` manufactured from a bare * `SessionStore` + session name is repair authority conjured by naming a session, not proof of * holding the request's own coordinator. A behavior test cannot distinguish "the one coordinator - * `runReplayScriptFile` created, threaded through" from "a second one built from the same + * `runReplayScriptSource` created, threaded through" from "a second one built from the same * store/name" — both produce identical wire output — so this is a structural check over the * import graph, using the same `oxc-parser` AST approach as `scripts/layering/session-state.ts`. * diff --git a/src/daemon/__tests__/replay-device-selection.test.ts b/src/daemon/__tests__/replay-device-selection.test.ts index 2953a45d6c..f06b2ce433 100644 --- a/src/daemon/__tests__/replay-device-selection.test.ts +++ b/src/daemon/__tests__/replay-device-selection.test.ts @@ -7,6 +7,7 @@ import { buildReplayTargetDeviceResolution, } from '../replay-device-selection.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { replayScriptSourceBundleFor } from '../../__tests__/test-utils/replay-script-source.ts'; test('replay leaves deep-link opens to normal device resolution', () => { const root = mkdtempForTestSync('agent-device-replay-device-selection-'); @@ -19,6 +20,7 @@ test('replay leaves deep-link opens to normal device resolution', () => { session: 'default', command: 'replay', positionals: [replayPath], + flags: { replayScriptSource: replayScriptSourceBundleFor(replayPath) }, meta: { cwd: root }, }), ).toBeUndefined(); @@ -44,13 +46,17 @@ test('native replay uses its authored Android runtime setting without an iOS app 'runtime set --platform android --metro-port 8081\nopen com.example.demo\n', ); - expect( - buildReplayTargetDeviceResolution({ - token: 'test-token', - session: 'default', - command: 'replay', - positionals: [replayPath], - meta: { cwd: root }, - }), - ).toEqual({ flags: { platform: 'android' }, options: undefined }); + const resolution = buildReplayTargetDeviceResolution({ + token: 'test-token', + session: 'default', + command: 'replay', + positionals: [replayPath], + flags: { replayScriptSource: replayScriptSourceBundleFor(replayPath) }, + meta: { cwd: root }, + }); + + // The request's own flags ride through untouched, so compare the decision this function makes: + // the authored platform, and no iOS app-probe options. + expect(resolution?.flags.platform).toBe('android'); + expect(resolution?.options).toBeUndefined(); }); diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index 1b375ee305..5ce399b05e 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -5,6 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import { getResolveTargetDeviceMock } from './request-router-dispatch-mocks.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { replayScriptSourceBundleFor } from '../../__tests__/test-utils/replay-script-source.ts'; vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); vi.mock('../../utils/host-process.ts', async (importOriginal) => { @@ -183,7 +184,7 @@ test('fresh replay reserves its authored app simulator before any replay step', session: 'fresh-replay', command: 'replay', positionals: [replayPath], - flags: {}, + flags: { replayScriptSource: replayScriptSourceBundleFor(replayPath) }, meta: { cwd: root }, }, sessionName: 'fresh-replay', @@ -192,7 +193,7 @@ test('fresh replay reserves its authored app simulator before any replay step', expect(keys).toEqual(['session:fresh-replay', 'device:SIM-WITH-APP']); expect(mockResolveTargetDevice).toHaveBeenCalledWith( - { platform: 'ios' }, + expect.objectContaining({ platform: 'ios' }), { appleSimulatorAppTarget: 'com.example.demo' }, ); }); @@ -212,7 +213,7 @@ test('fresh replay leaves a first deep-link open unbound when a later app target session: 'fresh-replay-deep-link', command: 'replay', positionals: [replayPath], - flags: {}, + flags: { replayScriptSource: replayScriptSourceBundleFor(replayPath) }, meta: { cwd: root }, }, sessionName: 'fresh-replay-deep-link', @@ -240,7 +241,7 @@ test('fresh replay preserves an authored Android platform before advisory lockin session: 'fresh-replay-android', command: 'replay', positionals: [replayPath], - flags: {}, + flags: { replayScriptSource: replayScriptSourceBundleFor(replayPath) }, meta: { cwd: root }, }, sessionName: 'fresh-replay-android', @@ -248,7 +249,10 @@ test('fresh replay preserves an authored Android platform before advisory lockin }); expect(keys).toEqual(['session:fresh-replay-android', 'device:ANDROID-EMULATOR']); - expect(mockResolveTargetDevice).toHaveBeenCalledWith({ platform: 'android' }, undefined); + expect(mockResolveTargetDevice).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'android' }), + undefined, + ); }); test('open --debug writes bounded open timing diagnostics to requestLogPath', async () => { diff --git a/src/daemon/__tests__/request-router-repair-expired.test.ts b/src/daemon/__tests__/request-router-repair-expired.test.ts index 585e8da152..7acafc8540 100644 --- a/src/daemon/__tests__/request-router-repair-expired.test.ts +++ b/src/daemon/__tests__/request-router-repair-expired.test.ts @@ -135,10 +135,12 @@ test('a replay --from continuation on a reaped repair session gets REPAIR_SESSIO const scriptPath = path.join(root, 'flow.ad'); fs.writeFileSync(scriptPath, 'open "Demo"\nclick id="a"\n'); - // Compute the plan digest exactly as runReplayScriptFile does (a real agent + // Compute the plan digest exactly as runReplayScriptSource does (a real agent // takes it from the divergence report's resume.planDigest). const flags = { platform: 'ios' as const }; - const digest = inspectAdReplay(scriptPath, { platform: flags.platform }).planDigest; + const digest = inspectAdReplay(fs.readFileSync(scriptPath, 'utf8'), { + platform: flags.platform, + }).planDigest; // The repair session was reaped, leaving a tombstone; no live session exists. sessionStore.writeRepairTombstone(tombstonedSession('repair-from')); diff --git a/src/daemon/__tests__/request-router-replay-env.test.ts b/src/daemon/__tests__/request-router-replay-env.test.ts index 01822bf34c..1e71b9dd3e 100644 --- a/src/daemon/__tests__/request-router-replay-env.test.ts +++ b/src/daemon/__tests__/request-router-replay-env.test.ts @@ -6,6 +6,7 @@ import { makeSessionStore } from '../../__tests__/test-utils/index.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { createRequestHandler } from './test-device-runtime-gateway.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { replayScriptSourceBundleFor } from '../../__tests__/test-utils/replay-script-source.ts'; function createHarness() { const root = mkdtempForTestSync('agent-device-router-replay-env-'); @@ -34,7 +35,10 @@ test('malformed replay env returns a normalized INVALID_ARGS response', async () session: 'default', command: 'replay', positionals: [flowPath], - flags: { replayEnv: ['NOEQUAL'] }, + flags: { + replayEnv: ['NOEQUAL'], + replayScriptSource: replayScriptSourceBundleFor(flowPath), + }, meta: { requestId: 'req-invalid-replay-env' }, }), ).resolves.toMatchObject({ @@ -55,7 +59,13 @@ test('ordinary replay env values do not globally corrupt request diagnostics', a session: 'default', command: 'replay', positionals: [missingPath], - flags: { replayEnv: ['RETRIES=2', 'USER=demo'] }, + // #1802: a request whose bundle does not carry its own entry — the shape a daemon sees when + // the caller's script vanished between collection and dispatch. The failure still names the + // path, which is what this test is about. + flags: { + replayEnv: ['RETRIES=2', 'USER=demo'], + replayScriptSource: { entry: missingPath, files: {} }, + }, meta: { requestId: 'req-ordinary-replay-env' }, }); diff --git a/src/daemon/__tests__/request-save-script-transports.test.ts b/src/daemon/__tests__/request-save-script-transports.test.ts index 28a102d6d3..2f491bf1ea 100644 --- a/src/daemon/__tests__/request-save-script-transports.test.ts +++ b/src/daemon/__tests__/request-save-script-transports.test.ts @@ -18,6 +18,7 @@ import net from 'node:net'; import path from 'node:path'; import { afterEach, expect, test } from 'vitest'; import { LeaseRegistry } from '../lease-registry.ts'; +import { REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE } from '../../replay/script-source-bundle.ts'; import { createRequestHandler } from './test-device-runtime-gateway.ts'; import { SessionStore } from '../session-store.ts'; import { createDaemonHttpServer } from '../server/http-server.ts'; @@ -235,8 +236,8 @@ for (const [transport, send] of TRANSPORTS) { if (await skipWhenLoopbackUnavailable(t)) return; const { handleRequest } = setup(); - // `replay` is a flag owner, so the seam lets it through and the request - // fails only on its own missing-path validation, downstream of admission. + // `replay` is a flag owner, so the seam lets it through and the request fails only on its + // own missing-script-sources validation (#1802), downstream of admission. const response = await send(handleRequest, { command: 'replay', positionals: [], @@ -245,7 +246,7 @@ for (const [transport, send] of TRANSPORTS) { expect(response.ok).toBe(false); if (response.ok) return; - expect(response.error.message).toBe('replay requires a path'); + expect(response.error.message).toBe(REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE); }); } diff --git a/src/daemon/__tests__/test-device-runtime-gateway.ts b/src/daemon/__tests__/test-device-runtime-gateway.ts index 37ed1f895b..42f12e44fd 100644 --- a/src/daemon/__tests__/test-device-runtime-gateway.ts +++ b/src/daemon/__tests__/test-device-runtime-gateway.ts @@ -15,6 +15,8 @@ import type { BindDeviceRuntime, BindExactDeviceRuntime } from '../request-runti import type { DeviceInfo } from '@agent-device/kernel/device'; import { applicationLifecycleRuntimeFixture } from './application-lifecycle-runtime-fixture.ts'; import { unavailableDeploymentAndShutdownOperationFacts } from '../../__tests__/test-utils/runtime-operation-facts.ts'; +import { withClientReplayScriptSources } from '../../__tests__/test-utils/replay-script-source.ts'; +import type { DaemonInvokeFn } from '../types.ts'; const unavailable = Object.freeze({ available: false as const, @@ -177,5 +179,10 @@ export function createRequestHandler( Partial>, ) { const { deviceRuntimeGateway = unavailableDeviceRuntimeGateway, ...rest } = deps; - return createProductionRequestHandler({ ...rest, deviceRuntimeGateway }); + const handle = createProductionRequestHandler({ ...rest, deviceRuntimeGateway }); + // #1802: stand in for the client that reads a replay script and sends its content, so router + // cases keep naming a path while the daemon still sees only bundled sources. + const handleWithClientScriptSources: DaemonInvokeFn = async (req) => + await handle(withClientReplayScriptSources(req)); + return handleWithClientScriptSources; } diff --git a/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-observation.test.ts b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-observation.test.ts index 47f9fb7260..b9483bc110 100644 --- a/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-observation.test.ts +++ b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-observation.test.ts @@ -1,3 +1,4 @@ +import { noMaestroIncludeSources } from '../../../../__tests__/test-utils/replay-script-source.ts'; import { expect, test } from 'vitest'; import { executeMaestroFlow, @@ -357,7 +358,7 @@ test('assertVisible and assertNotVisible scope duplicate matching children by ch '/flows/observation.yaml', ); - const result = await executeMaestroFlow(flow, port); + const result = await executeMaestroFlow(flow, port, { readSource: noMaestroIncludeSources }); expect(result).toMatchObject({ ok: true, replayed: 2 }); expect(observations).toHaveLength(2); diff --git a/src/daemon/adapters/maestro/__tests__/daemon-runtime-port.test.ts b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port.test.ts index f81f4b11e8..ec778445cb 100644 --- a/src/daemon/adapters/maestro/__tests__/daemon-runtime-port.test.ts +++ b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port.test.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; +import { noMaestroIncludeSources } from '../../../../__tests__/test-utils/replay-script-source.ts'; import { executeMaestroFlow, inspectMaestroFlow } from '@agent-device/maestro'; import type { DaemonInvokeFn, DaemonRequest } from '../../../types.ts'; import { PNG } from '../../../../utils/png.ts'; @@ -676,7 +677,7 @@ test('waitForAnimationToEnd between two taps does not throw a stability-generati '/flows/settled.yaml', ); - const result = await executeMaestroFlow(flow, port); + const result = await executeMaestroFlow(flow, port, { readSource: noMaestroIncludeSources }); expect(result).toMatchObject({ ok: true, replayed: 3 }); expect(requests.map(({ command }) => command)).toEqual([ @@ -749,7 +750,7 @@ test('timed-out waitForAnimationToEnd retains the pending hierarchy settle', asy '/flows/timed-out.yaml', ); - const result = await executeMaestroFlow(flow, port); + const result = await executeMaestroFlow(flow, port, { readSource: noMaestroIncludeSources }); expect(result).toMatchObject({ ok: true, replayed: 3 }); expect(clock.value).toBe(MAESTRO_OBSERVATION_POLL_MS); diff --git a/src/daemon/handlers/__tests__/interaction-ios-tap-outcome.test.ts b/src/daemon/handlers/__tests__/interaction-ios-tap-outcome.test.ts index fca3e41314..744d755fe0 100644 --- a/src/daemon/handlers/__tests__/interaction-ios-tap-outcome.test.ts +++ b/src/daemon/handlers/__tests__/interaction-ios-tap-outcome.test.ts @@ -14,7 +14,8 @@ import { } from '../../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { SessionScriptWriter } from '../../session-script-writer.ts'; -import { runReplayScriptFile } from '../session-replay-runtime.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; +import { replayScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; import { imageViewerNodes, profileNodes, @@ -637,13 +638,16 @@ test('corroborated runtime taps retain target evidence through save and replay', snapshot: snapshot(profileNodes), }), ); - const replayResponse = await runReplayScriptFile({ + const replayResponse = await runReplayScriptSource({ req: { token: 'test', session: replaySessionName, command: 'replay', positionals: [written.path], - flags: { replayKeepSession: true }, + flags: { + replayKeepSession: true, + replayScriptSource: replayScriptSourceBundleFor(written.path), + }, }, sessionName: replaySessionName, logPath: path.join(root, 'replay-daemon.log'), diff --git a/src/daemon/handlers/__tests__/session-command-harness.ts b/src/daemon/handlers/__tests__/session-command-harness.ts index a429b406d2..e9515e15ea 100644 --- a/src/daemon/handlers/__tests__/session-command-harness.ts +++ b/src/daemon/handlers/__tests__/session-command-harness.ts @@ -23,6 +23,7 @@ import type { TargetShutdownResult } from '@agent-device/contracts/device'; import { deviceShape, isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { beforeEach, vi } from 'vitest'; import { applicationLifecycleRuntimeFixture } from '../../__tests__/application-lifecycle-runtime-fixture.ts'; +import { withClientReplayScriptSources } from '../../../__tests__/test-utils/replay-script-source.ts'; const unavailable = Object.freeze({ available: false, @@ -97,6 +98,7 @@ export function handleSessionCommands( ): ReturnType { return handleProductionSessionCommands({ ...params, + req: withClientReplayScriptSources(params.req), inspectFacts: params.inspectFacts ?? mockInspectDeviceRuntimeFacts, bindDevice: params.bindDevice ?? mockBindDeviceRuntime, reconcileOrphanedDeviceClaim: async () => ({ diff --git a/src/daemon/handlers/__tests__/session-command-replay.test.ts b/src/daemon/handlers/__tests__/session-command-replay.test.ts index 378ffc9402..cf3c9d3152 100644 --- a/src/daemon/handlers/__tests__/session-command-replay.test.ts +++ b/src/daemon/handlers/__tests__/session-command-replay.test.ts @@ -6,6 +6,7 @@ import { makeSessionStore } from './session-test-harness.ts'; import { handleSessionCommands } from './session-command-harness.ts'; import type { DaemonRequest } from '../../types.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { replayScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; test('replay parses open --relaunch flag and replays open with relaunch semantics', async () => { const sessionStore = makeSessionStore(); @@ -20,7 +21,7 @@ test('replay parses open --relaunch flag and replays open with relaunch semantic session: 'default', command: 'replay', positionals: [replayPath], - flags: {}, + flags: { replayScriptSource: replayScriptSourceBundleFor(replayPath) }, }, sessionName: 'default', logPath: path.join(os.tmpdir(), 'daemon.log'), @@ -58,7 +59,7 @@ test('replay parses runtime set flags and replays runtime command', async () => session: 'default', command: 'replay', positionals: [replayPath], - flags: {}, + flags: { replayScriptSource: replayScriptSourceBundleFor(replayPath) }, meta: { cwd: replayRoot }, }, sessionName: 'default', @@ -97,7 +98,7 @@ test('replay parses inline open runtime flags and replays open with runtime payl session: 'default', command: 'replay', positionals: [replayPath], - flags: {}, + flags: { replayScriptSource: replayScriptSourceBundleFor(replayPath) }, meta: { cwd: replayRoot }, }, sessionName: 'default', @@ -121,39 +122,6 @@ test('replay parses inline open runtime flags and replays open with runtime payl }); }); -test('replay resolves relative script path against request cwd', async () => { - const sessionStore = makeSessionStore(); - const replayRoot = mkdtempForTestSync('agent-device-replay-cwd-'); - const replayDir = path.join(replayRoot, 'workflows'); - fs.mkdirSync(replayDir, { recursive: true }); - fs.writeFileSync(path.join(replayDir, 'flow.ad'), 'open "Settings"\n'); - - const invoked: DaemonRequest[] = []; - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'replay', - positionals: ['workflows/flow.ad'], - flags: {}, - meta: { cwd: replayRoot }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: async (req) => { - invoked.push(req); - return { ok: true, data: {} }; - }, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(invoked.length).toBe(1); - expect(invoked[0]?.command).toBe('open'); - expect(invoked[0]?.positionals).toEqual(['Settings']); -}); - test('replay inherits parent device selectors for each invoked step', async () => { const sessionStore = makeSessionStore(); const replayRoot = mkdtempForTestSync('agent-device-replay-parent-selectors-'); @@ -171,6 +139,7 @@ test('replay inherits parent device selectors for each invoked step', async () = platform: 'ios', device: 'thymikee-iphone', udid: '00008150-001849640CF8401C', + replayScriptSource: replayScriptSourceBundleFor(replayPath), }, }, sessionName: 'default', diff --git a/src/daemon/handlers/__tests__/session-replay-dispatch-selector-miss.test.ts b/src/daemon/handlers/__tests__/session-replay-dispatch-selector-miss.test.ts index a37b962924..850238c89f 100644 --- a/src/daemon/handlers/__tests__/session-replay-dispatch-selector-miss.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-dispatch-selector-miss.test.ts @@ -4,7 +4,7 @@ * press/click/fill/longpress THROWS an AppError instead of resolving to * `{ok:false}`. `invokeReplayAction` (session-replay-action-runtime.ts) * previously let that throw escape the per-action `if (!response.ok)` - * handling in `runReplayScriptFile`'s loop, so it hit the outer catch and + * handling in `runReplayScriptSource`'s loop, so it hit the outer catch and * returned a bare `COMMAND_FAILED` with the legacy diagnostics shape instead * of the ADR 0012 `REPLAY_DIVERGENCE` report — breaking the interactive * repair loop (no `resume`, no `screen` refs, no `suggestions`) for the @@ -28,7 +28,7 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { }); import path from 'node:path'; -import { runReplayScriptFile } from '../session-replay-runtime.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { AppError } from '@agent-device/kernel/errors'; import { dispatchCommand } from '../../../core/dispatch.ts'; @@ -66,7 +66,7 @@ function throwSelectorMiss(selector: string): never { } function assertDivergenceShape( - response: Awaited>, + response: Awaited>, expectedResume: { allowed: boolean; from: number } = { allowed: true, from: 1 }, ): { divergence: Record; @@ -116,7 +116,7 @@ test('(a) an ANNOTATED press whose dispatch throws a selector-miss yields REPLAY }); const invoked: string[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -162,7 +162,7 @@ test('(b) an UNANNOTATED press whose dispatch throws a selector-miss yields REPL }); const invoked: string[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -192,7 +192,7 @@ test('(c) a fill selector-miss thrown at dispatch yields REPLAY_DIVERGENCE, not }); const invoked: string[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -224,7 +224,7 @@ test('(d) a thrown NON-AppError at dispatch propagates as an internal error, not }); const invoked: string[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -257,7 +257,7 @@ test('(e) a thrown AppError with retriable/supportedOn preserves them at the top }); const invoked: string[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), diff --git a/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts b/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts index d2038276f0..fd4a3f54ca 100644 --- a/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts @@ -1,3 +1,4 @@ +import { noMaestroIncludeSources } from '../../../__tests__/test-utils/replay-script-source.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; @@ -19,7 +20,7 @@ import { buildTypedMaestroFailureReportProjection, buildTypedMaestroFailureResponse, } from '../session-replay-maestro-failure.ts'; -import { runReplayScriptFile } from '../session-replay-runtime.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; @@ -102,7 +103,10 @@ async function captureMaestroFailure( throw error; }, }; - const outcome = await executeMaestroFlow(flow, port, { platform: 'ios' }); + const outcome = await executeMaestroFlow(flow, port, { + platform: 'ios', + readSource: noMaestroIncludeSources, + }); if (outcome.ok || !outcome.failure) throw new Error('expected typed Maestro failure'); return outcome.failure; } @@ -135,7 +139,7 @@ test('typed Maestro failure diagnostics render expanded selector values without ['appId: com.example.app', '---', '- tapOn: ${TARGET}', ''].join('\n'), ); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [flowPath], flags: { @@ -229,7 +233,7 @@ test('typed Maestro nested scopes retain resolved target values after unwind', a }, ]; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [flowPath], flags: { @@ -294,7 +298,7 @@ test('typed Maestro renders flow-local values when static include resolution fai ['env:', ` FLOW_NAME: ${flowName}`, '---', '- runFlow: ${FLOW_NAME}.yaml', ''].join('\n'), ); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro', platform: 'ios' }, diff --git a/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts index e82f9b030e..0b2d984d4f 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts @@ -16,7 +16,7 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { import fs from 'node:fs'; import path from 'node:path'; -import { runReplayScriptFile } from '../session-replay-runtime.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; @@ -83,7 +83,7 @@ test('a healed script survives repair + fresh-session replay: self-contained ope }); // --- Leg 1: open records; "click id=save" diverges (renamed to save-v2). --- - const leg1 = await runReplayScriptFile({ + const leg1 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -111,7 +111,7 @@ test('a healed script survives repair + fresh-session replay: self-contained ope }); // --- Leg 2: resume past the step the agent just performed. --- - const leg2 = await runReplayScriptFile({ + const leg2 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -143,7 +143,7 @@ test('a healed script survives repair + fresh-session replay: self-contained ope const freshSessionStore = new SessionStore(path.join(freshRoot, 'sessions')); const freshSessionName = 'fresh'; const invokedFresh: DaemonRequest[] = []; - const freshReplay = await runReplayScriptFile({ + const freshReplay = await runReplayScriptSource({ req: baseReq({ session: freshSessionName, positionals: [healedPath] }), sessionName: freshSessionName, logPath: path.join(freshRoot, 'daemon.log'), @@ -184,7 +184,7 @@ function assertHealedScriptStructure(healedScript: string): void { /** The fresh-session replay dispatched every step with the healed open's flags intact. */ function assertFreshReplayReached( - freshReplay: Awaited>, + freshReplay: Awaited>, invokedFresh: DaemonRequest[], ): void { expect(freshReplay.ok).toBe(true); diff --git a/src/daemon/handlers/__tests__/session-replay-repair-empty-tail.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-empty-tail.test.ts index 1984d30152..e37d310769 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-empty-tail.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-empty-tail.test.ts @@ -32,7 +32,7 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { import fs from 'node:fs'; import path from 'node:path'; -import { runReplayScriptFile } from '../session-replay-runtime.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; @@ -92,7 +92,7 @@ test('a record-and-heal divergence on the LAST step resumes with an empty tail a // recorded evidence's real ancestry + the still-present container routes // this to `record-and-heal`, and since it is the plan's LAST step, // resume.from = 3 = actions.length (2) + 1. --- - const leg1 = await runReplayScriptFile({ + const leg1 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -118,7 +118,7 @@ test('a record-and-heal divergence on the LAST step resumes with an empty tail a // --- A blind resume at the reported target BEFORE performing the // corrective press is rejected: no new action was recorded since the // divergence, so nothing proves the diverged step was actually repaired. --- - const blindResume = await runReplayScriptFile({ + const blindResume = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { @@ -153,7 +153,7 @@ test('a record-and-heal divergence on the LAST step resumes with an empty tail a // session.actions, consuming the watermark. Zero steps execute (there is // nothing left in the plan), and the runtime's normal end-of-plan path // flips the transaction COMPLETE. --- - const leg2 = await runReplayScriptFile({ + const leg2 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { @@ -216,7 +216,7 @@ test('a manual divergence (unannotated action-failure) on the LAST step resumes // recorded target evidence, routing to `manual`. It is the plan's LAST // step, so the record-and-heal-shaped alternate target is 3 = actions // (2) + 1 — but `resume.from` itself stays at 2, unshifted. --- - const leg1 = await runReplayScriptFile({ + const leg1 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -249,7 +249,7 @@ test('a manual divergence (unannotated action-failure) on the LAST step resumes // corrective press is rejected: no new action was recorded since the // divergence, so nothing proves the diverged step's intent was actually // performed. --- - const blindResume = await runReplayScriptFile({ + const blindResume = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -279,7 +279,7 @@ test('a manual divergence (unannotated action-failure) on the LAST step resumes // --- Leg 2: the SAME `--from 3` now succeeds — the corrective press grew // session.actions, consuming the watermark. Zero steps execute, and the // runtime's normal end-of-plan path flips the transaction COMPLETE. --- - const leg2 = await runReplayScriptFile({ + const leg2 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -348,7 +348,7 @@ test('a caution (identity-mismatch) divergence on the LAST step resumes with an // identity-mismatch/`caution`. It is the plan's LAST step, so the // record-and-heal-shaped alternate target is 3 = actions (2) + 1 — but // `resume.from` itself stays at 2, unshifted (#1262 item 1). --- - const leg1 = await runReplayScriptFile({ + const leg1 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -376,7 +376,7 @@ test('a caution (identity-mismatch) divergence on the LAST step resumes with an // --- A blind resume at the empty-tail target BEFORE performing the // corrective press is rejected. --- - const blindResume = await runReplayScriptFile({ + const blindResume = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -406,7 +406,7 @@ test('a caution (identity-mismatch) divergence on the LAST step resumes with an // --- Leg 2: the SAME `--from 3` now succeeds — zero steps execute, and // the runtime's normal end-of-plan path flips the transaction COMPLETE. --- - const leg2 = await runReplayScriptFile({ + const leg2 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -466,7 +466,7 @@ test('--from N stays legal for a caution divergence even after the N + 1 empty-t const invoke = makeRecordingReplayInvoke({ sessionStore, sessionName }); - const leg1 = await runReplayScriptFile({ + const leg1 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -507,7 +507,7 @@ test('--from N stays legal for a caution divergence even after the N + 1 empty-t truncated: false, backend: 'xctest', }); - const resumeAtN = await runReplayScriptFile({ + const resumeAtN = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { @@ -566,7 +566,7 @@ test('an unauthorized --from one past the plan end is rejected on an ARMED sessi // reaching `invoke` for "click" — this is the SAME repair-armed // (`--save-script`) lifecycle a record-and-heal repair uses, just a // different repair sub-flow. --- - const leg1 = await runReplayScriptFile({ + const leg1 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -594,7 +594,7 @@ test('an unauthorized --from one past the plan end is rejected on an ARMED sessi // record-and-heal watermark authorizes skipping the unresolved final step, // regardless of how the session's OTHER lifecycle state (armed/held) // looks. --- - const exploitAttempt = await runReplayScriptFile({ + const exploitAttempt = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -630,7 +630,7 @@ test('a stale --plan-digest on an empty-tail resume is rejected WITHOUT consumin failSteps: new Set(['click id="article"']), }); - const leg1 = await runReplayScriptFile({ + const leg1 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -661,7 +661,7 @@ test('a stale --plan-digest on an empty-tail resume is rejected WITHOUT consumin // correct digest would find it already cleared and get rejected as // out-of-range, permanently locking the agent out of completing the // repair. --- - const staleDigestAttempt = await runReplayScriptFile({ + const staleDigestAttempt = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { @@ -687,7 +687,7 @@ test('a stale --plan-digest on an empty-tail resume is rejected WITHOUT consumin }); // --- The retry with the CORRECT digest still succeeds — never locked out. --- - const retry = await runReplayScriptFile({ + const retry = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { diff --git a/src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts index bd29f51d30..5872762ce8 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts @@ -19,7 +19,7 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { }); import path from 'node:path'; -import { runReplayScriptFile } from '../session-replay-runtime.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; import { @@ -99,7 +99,7 @@ test('R1/R2/R6: prefix steps get fresh evidence, corrective + resumed steps land // --- Leg 1: arms recording, records open + the verified prefix step, then // diverges at "click id=delete" — never recorded. --- - const leg1 = await runReplayScriptFile({ + const leg1 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -133,7 +133,7 @@ test('R1/R2/R6: prefix steps get fresh evidence, corrective + resumed steps land // --- Leg 2: a --from resume that redundantly passes --save-script — allowed // (it is a resume, not a full replay), and must NOT re-stamp the boundary. --- - const leg2 = await runReplayScriptFile({ + const leg2 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 4, replayPlanDigest: divergence.resume.planDigest }, @@ -162,7 +162,7 @@ test('R2: a fresh FULL replay --save-script on an already-armed session is rejec const invoke = makeRecordingReplayInvoke({ sessionStore, sessionName }); // First run arms the session (sets saveScriptBoundary). - const first = await runReplayScriptFile({ + const first = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -174,7 +174,7 @@ test('R2: a fresh FULL replay --save-script on an already-armed session is rejec // A SECOND full (non---from) replay --save-script would re-append the prefix. const spy: DaemonRequest[] = []; - const second = await runReplayScriptFile({ + const second = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -197,7 +197,7 @@ test('R2 bypass guard: a PLAIN full replay (no --save-script) on an armed sessio // Arm the repair run — session stays repair-armed (recording + // saveScriptBoundary) after it returns. - const first = await runReplayScriptFile({ + const first = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -214,7 +214,7 @@ test('R2 bypass guard: a PLAIN full replay (no --save-script) on an armed sessio // is still true, so it would re-append the prefix. Reject it, zero dispatches, // healed slice not duplicated. const spy: DaemonRequest[] = []; - const plain = await runReplayScriptFile({ + const plain = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath, @@ -257,7 +257,7 @@ test('R6 no amputation: a pre-populated session whose step-1 open REPLACES the s evidence: (req) => (req.command === 'click' ? freshEvidence('x', 'X') : undefined), }); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -294,7 +294,7 @@ test('R6 preserved session: prior actions are excluded from the healed slice', a evidence: (req) => (req.command === 'click' ? freshEvidence('a', 'A') : undefined), }); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -319,7 +319,7 @@ test('opt-in: without --save-script, replay neither arms recording nor records e evidence: () => freshEvidence('save', 'Save'), }); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath, @@ -349,7 +349,7 @@ test('a thrown/failed dispatch never lands a partial action in session.actions', failSteps: new Set(['click']), }); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -399,7 +399,7 @@ test('R1 bootstrap: a session created by step 1 (open) arms in time for step 2 t evidence: (req) => (req.command === 'click' ? freshEvidence('save', 'Save') : undefined), }); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -424,7 +424,7 @@ test('BLOCKER 4: a minimal [open, terminal close] cold-start script arms the tra const spy: DaemonRequest[] = []; const invoke = makeRecordingReplayInvoke({ sessionStore, sessionName, spy }); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -463,7 +463,7 @@ test("Fix 3: the source plan's terminal close is skipped (never dispatched, neve evidence: (req) => (req.command === 'click' ? freshEvidence('save', 'Save') : undefined), }); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -493,7 +493,7 @@ test('Fix 3: an ordinary (non-repair) replay still dispatches its terminal close const invoke = makeRecordingReplayInvoke({ sessionStore, sessionName, spy }); // No --save-script: this is a plain deterministic replay, not a repair. - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath, @@ -518,7 +518,7 @@ test('Fix 3: only the TERMINAL close is skipped during a repair — a mid-plan c openReplacesSession: true, }); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -550,7 +550,7 @@ test('Fix 3: a --from resume that lands on the terminal close skips it too, lett failSteps: new Set(['click id="save"']), }); - const leg1 = await runReplayScriptFile({ + const leg1 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -575,7 +575,7 @@ test('Fix 3: a --from resume that lands on the terminal close skips it too, lett }); spy.length = 0; - const leg2 = await runReplayScriptFile({ + const leg2 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -614,7 +614,7 @@ test('a --from continuation WITHOUT --save-script that diverges is still held al // Leg 1: `replay --save-script` opens the transaction, arms the session, and // diverges at step 2 — held alive. - const leg1 = await runReplayScriptFile({ + const leg1 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -633,7 +633,7 @@ test('a --from continuation WITHOUT --save-script that diverges is still held al // Leg 2: the `--from 3` continuation carries NO --save-script. It re-diverges // at step 3 — and must STILL be held alive, keyed off the persisted armed // state, so the transaction survives. - const leg2 = await runReplayScriptFile({ + const leg2 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayFrom: 3, replayPlanDigest: leg1Divergence.resume.planDigest }, diff --git a/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts index 3ff2192803..bab66f5c3f 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts @@ -3,7 +3,7 @@ * observation-only commands (`snapshot`/`get`/`is`/a read-only `find`), the * `--record` opt-in for the corrective-read case, and the fail-loud empty- * segment guard. Exercised end to end at the same layer the ADR 0012 decision - * 6 lifecycle tests use — `runReplayScriptFile` + `handleCloseCommand` sharing + * 6 lifecycle tests use — `runReplayScriptSource` + `handleCloseCommand` sharing * a live `SessionStore`, exactly like an agent's separate CLI invocations * against the same daemon session. * @@ -50,7 +50,7 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { }); import fs from 'node:fs'; import path from 'node:path'; -import { runReplayScriptFile } from '../session-replay-runtime.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { handleCloseCommand as handleProductionCloseCommand } from '../session-close.ts'; import { SessionStore } from '../../session-store.ts'; import { LeaseRegistry } from '../../lease-registry.ts'; @@ -150,7 +150,7 @@ async function armAndDiverge(params: ReturnType, filePath: string) sessionName, evidence: (req) => (req.command === 'click' ? freshEvidence('confirm', 'Confirm') : undefined), }); - const leg1 = await runReplayScriptFile({ + const leg1 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -216,7 +216,7 @@ test('diagnostic get/is reads mid-repair are excluded from the healed script by expect(session.actions.map((a) => a.command)).toEqual(['open', 'press']); // --- `replay --from N+1` resumes to completion (terminal close skipped). --- - const leg2 = await runReplayScriptFile({ + const leg2 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -264,7 +264,7 @@ test('diagnostic get/is reads mid-repair are excluded from the healed script by sessionStore: fresh.sessionStore, sessionName: fresh.sessionName, }); - const freshRun = await runReplayScriptFile({ + const freshRun = await runReplayScriptSource({ req: baseReq({ positionals: [healedPath] }), sessionName: fresh.sessionName, logPath: fresh.logPath, @@ -298,7 +298,7 @@ test("a --record'ed diagnostic read lands in the healed script (the diverged-ste }); expect(session.actions.map((a) => a.command)).toEqual(['open', 'get']); - const leg2 = await runReplayScriptFile({ + const leg2 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -343,7 +343,7 @@ test("a --record'ed diagnostic read lands in the healed script (the diverged-ste sessionStore: fresh.sessionStore, sessionName: fresh.sessionName, }); - const freshRun = await runReplayScriptFile({ + const freshRun = await runReplayScriptSource({ req: baseReq({ positionals: [healedPath] }), sessionName: fresh.sessionName, logPath: fresh.logPath, @@ -384,7 +384,7 @@ test('empty-segment guard: a --from resume refuses with an actionable --record h }); expect(session.actions.map((a) => a.command)).toEqual(['open']); - const blindResume = await runReplayScriptFile({ + const blindResume = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, diff --git a/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts index 6741f5cabd..d354b59a59 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts @@ -1,7 +1,7 @@ /** * ADR 0012 decision 6 "repair transaction" lifecycle fixes (Q1/Q2a/Q2b/Q2c): * proves the WHOLE chain end to end, at the layer these fixes actually live — - * `runReplayScriptFile` + `handleCloseCommand` sharing a live `SessionStore`, + * `runReplayScriptSource` + `handleCloseCommand` sharing a live `SessionStore`, * exactly like an agent's separate CLI invocations against the same daemon * session would. `sendToDaemon`'s process-level keep-alive (Fix 1's daemon * teardown guard) is a different architectural layer — a client-side process @@ -52,7 +52,7 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { }); import fs from 'node:fs'; import path from 'node:path'; -import { runReplayScriptFile } from '../session-replay-runtime.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { handleCloseCommand as handleProductionCloseCommand } from '../session-close.ts'; import { SessionStore } from '../../session-store.ts'; import { LeaseRegistry } from '../../lease-registry.ts'; @@ -177,7 +177,7 @@ test('end-to-end repair transaction: cold divergence stays alive, corrective res }); // --- Cold `replay drifted.ad --save-script` diverges on the renamed id. --- - const leg1 = await runReplayScriptFile({ + const leg1 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -222,7 +222,7 @@ test('end-to-end repair transaction: cold divergence stays alive, corrective res // --- `replay --from N+1 --plan-digest ` resumes to the end. The // source plan's own terminal `close` (Fix 3) is skipped, so this completes // instead of diverging on lifecycle. --- - const leg2 = await runReplayScriptFile({ + const leg2 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -295,7 +295,7 @@ test('end-to-end repair transaction: cold divergence stays alive, corrective res sessionName: abandoned.sessionName, }); - const abandonedLeg1 = await runReplayScriptFile({ + const abandonedLeg1 = await runReplayScriptSource({ req: baseReq({ positionals: [abandonedFilePath], flags: { saveScript: true } }), sessionName: abandoned.sessionName, logPath: abandoned.logPath, @@ -343,7 +343,7 @@ test('C5a: an incomplete repair reaped by idle-reap leaves a tombstone (no heale ]); const invoke = makeRecordingReplayInvoke({ sessionStore, sessionName }); - const leg1 = await runReplayScriptFile({ + const leg1 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -367,7 +367,7 @@ test('C5a: an incomplete repair reaped by idle-reap leaves a tombstone (no heale expect(tombstone?.sourcePath).toBe(filePath); // A fresh `replay --save-script` on the same key clears the tombstone. - await runReplayScriptFile({ + await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -391,7 +391,7 @@ test('C5a/BLOCKER 3: teardown of a COMPLETE repair auto-commits a self-contained evidence: (req) => (req.command === 'click' ? freshEvidence('save-v2', 'Save V2') : undefined), }); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -439,7 +439,7 @@ test('BLOCKER 1: a --from continuation on a reaped session returns SESSION_NOT_F const invoke = makeRecordingReplayInvoke({ sessionStore, sessionName }); // Leg 1 arms + diverges (save renamed to save-v2 in the mock tree). - const leg1 = await runReplayScriptFile({ + const leg1 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -459,7 +459,7 @@ test('BLOCKER 1: a --from continuation on a reaped session returns SESSION_NOT_F // A `--from` continuation targeting the (now reaped) session must surface // SESSION_NOT_FOUND — NOT a REPLAY_DIVERGENCE wrapping the first step's // failure — so the router translates it to REPAIR_SESSION_EXPIRED. - const resumed = await runReplayScriptFile({ + const resumed = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayFrom: 3, replayPlanDigest: digest } }), sessionName, logPath, @@ -612,7 +612,7 @@ test('#1258 arm-time preflight: an existing --save-script target rejects BEFORE const spy: DaemonRequest[] = []; const invoke = makeRecordingReplayInvoke({ sessionStore, sessionName, spy }); - const result = await runReplayScriptFile({ + const result = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -643,7 +643,7 @@ test('#1258: --force skips the arm-time preflight and the replay proceeds despit const spy: DaemonRequest[] = []; const invoke = makeRecordingReplayInvoke({ sessionStore, sessionName, spy }); - const result = await runReplayScriptFile({ + const result = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, force: true } }), sessionName, logPath, @@ -684,7 +684,7 @@ test('#1258 preflight honors PERSISTED force: a --from continuation without --fo // Leg 1: `replay --save-script --force` — the LIVE force passes the preflight // (target exists), arms the session, PERSISTS saveScriptForce, then diverges // (nothing published, so the pre-existing target survives). - const leg1 = await runReplayScriptFile({ + const leg1 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, force: true } }), sessionName, logPath, @@ -710,7 +710,7 @@ test('#1258 preflight honors PERSISTED force: a --from continuation without --fo // Leg 2: `replay --from N --save-script` WITHOUT --force — the target still // exists. The persisted saveScriptForce must make the arm-time preflight use // the SAME effective decision publication uses, so this is NOT rejected. - const leg2 = await runReplayScriptFile({ + const leg2 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -758,7 +758,7 @@ test('#1258 preflight is per-target: a --from continuation RETARGETING to an exi // Leg 1: `--save-script= --force` — arms + PERSISTS force for , then // diverges (nothing published). - const leg1 = await runReplayScriptFile({ + const leg1 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: targetA, force: true } }), sessionName, logPath, @@ -792,7 +792,7 @@ test('#1258 preflight is per-target: a --from continuation RETARGETING to an exi // `applySaveScriptRetarget` WOULD clear it for ; the preflight must MATCH // that per-target contract and REFUSE here, before any step dispatches, // instead of executing the leg and only refusing at publish time. - const leg2 = await runReplayScriptFile({ + const leg2 = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { saveScript: targetB, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-artifact-ledger.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-artifact-ledger.test.ts index 58391aa88c..507380f0dd 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-artifact-ledger.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-artifact-ledger.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; -import { runReplayScriptFile } from '../session-replay-runtime.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { @@ -18,7 +18,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; * now the single ledger; `dispatchStep` writes it and returns its contents, and * the engine only threads that value. * - * The ledger's one INDEPENDENT observation point is `runReplayScriptFile`'s + * The ledger's one INDEPENDENT observation point is `runReplayScriptSource`'s * catch block: on a mid-loop throw there is no run outcome to read artifacts * from, so what the failure reports comes from the ledger and nothing else. * That makes this the counterfactual test for the threading — break the ledger @@ -51,7 +51,7 @@ test('a mid-loop throw reports exactly the artifacts of the steps that ran befor ]); const dispatched: string[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -96,7 +96,7 @@ test('a completed run reports every step’s artifacts through the threaded ledg const filePath = writeReplayFile(root, ['click "First"', 'click "Second"']); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-binding.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-binding.test.ts index 45b3446b5c..c14263eb4d 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-binding.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-binding.test.ts @@ -4,7 +4,7 @@ import { beforeEach, expect, test, vi } from 'vitest'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { resolveTargetDevice } from '../../../core/dispatch.ts'; import { SessionStore } from '../../session-store.ts'; -import { runReplayScriptFile } from '../session-replay-runtime.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { baseReplayRequest as baseReq } from './session-replay-runtime.fixtures.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; @@ -27,7 +27,7 @@ test('typed Maestro does not resolve a device before an explicit-platform flow n fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- launchApp\n'); const invoked: string[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro', platform: 'android' }, @@ -54,7 +54,7 @@ test('typed Maestro keeps a port-only runtime digest stable after launch binds t fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- launchApp\n- back\n'); const runtime = { metroPort: 8083 }; - const firstAttempt = await runReplayScriptFile({ + const firstAttempt = await runReplayScriptSource({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro', platform: 'ios' }, @@ -86,7 +86,7 @@ test('typed Maestro keeps a port-only runtime digest stable after launch binds t expect(divergence.resume.from).toBe(2); const invoked: string[] = []; - const resumedAttempt = await runReplayScriptFile({ + const resumedAttempt = await runReplayScriptSource({ req: baseReq({ positionals: [flowPath], flags: { diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-failure-response.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-failure-response.test.ts index d0f554774b..4111cf0ba2 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-failure-response.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-failure-response.test.ts @@ -7,7 +7,7 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { }); import path from 'node:path'; import { buildReplayDivergenceFailureResponseFromDescriptor } from '../session-replay-runtime-failure-response.ts'; -import { runReplayScriptFile } from '../session-replay-runtime.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; @@ -76,7 +76,7 @@ test('divergence cause and action strings pass through the central redactor at c const filePath = writeReplayFile(root, ['click "Save"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -112,7 +112,7 @@ test('a fill divergence never serializes the typed text at any response level', // message is a realistic selector error, not an echo of the typed text. mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], meta: { responseLevel: level } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -165,7 +165,7 @@ test('a capture-failed screen hint redacts a secret in the capture error', async // The post-failure snapshot capture throws with a secret-bearing message. mockDispatchCommand.mockRejectedValue(new Error('snapshot failed: api_key=sk-live-abc123def456')); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -198,7 +198,7 @@ test('an expanded ${VAR} value echoed by a selector error never reaches the publ const filePath = writeReplayFile(root, ['press label="${SECRET}"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayEnv: [`SECRET=${sentinel}`] } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -242,7 +242,7 @@ test('an expanded built-in AD_DEVICE_ID never reaches the public divergence', as const filePath = writeReplayFile(root, ['press label="${AD_DEVICE_ID}"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { serial: deviceId } }), sessionName, logPath: path.join(root, 'daemon.log'), diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts index 6d50c4222f..648081e923 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts @@ -7,7 +7,7 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { }); import fs from 'node:fs'; import path from 'node:path'; -import { runReplayScriptFile } from '../session-replay-runtime.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import type { DaemonResponse } from '../../types.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; @@ -40,7 +40,7 @@ test('a failing replay step returns REPLAY_DIVERGENCE with cause preserved and c return { ok: true }; }); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -116,7 +116,7 @@ test('#1262: a LAST-step failure with NO active session carries NO empty-tail al const filePath = writeReplayFile(root, ['click "Save"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -157,7 +157,7 @@ test('a normalized nested failure preserves typed recovery signals on REPLAY_DIV const filePath = writeReplayFile(root, ['click "Save"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -205,7 +205,7 @@ test('a failing replay step captures an available screen digest with blessed ref backend: 'xctest', }); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -255,7 +255,7 @@ test('a failing replay step ranks a re-resolved suggestion when the recorded sel backend: 'xctest', }); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -287,7 +287,7 @@ test('divergence screen never masks the original cause when the session already // Intentionally no session stored: simulates the session closing mid-replay. const filePath = writeReplayFile(root, ['click "Save"']); - const response: DaemonResponse = await runReplayScriptFile({ + const response: DaemonResponse = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -348,7 +348,7 @@ test('a failure inside a retry-wrapped runFlow include reports the include file ); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [mainPath], flags: { replayBackend: 'maestro' } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -398,7 +398,7 @@ test('a failure inside a runtime runFlow.when-wrapped include reports the includ ); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [mainPath], flags: { replayBackend: 'maestro' } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -457,7 +457,7 @@ test('typed Maestro failures rank suggestions with Maestro regex selector semant ]; mockDispatchCommand.mockResolvedValue({ nodes, truncated: false, backend: 'xctest' }); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro' } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -496,7 +496,7 @@ test('typed Maestro failures never serialize input text', async () => { ); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro' } }), sessionName, logPath: path.join(root, 'daemon.log'), diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts index a98865e54a..6e65de7588 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts @@ -15,7 +15,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; * These six cases stayed daemon-side rather than moving into the package's * `step-loop.test.ts` because they are NOT a test of engine policy through * the façade in isolation — every one drives the full - * `runReplayScriptFile` round trip against a REAL `SessionStore`, and two of + * `runReplayScriptSource` round trip against a REAL `SessionStore`, and two of * the six (`--keep-session fails explicitly when the completed replay has * no live session`, `--keep-session rejects Maestro YAML before engine * dispatch`) exercise daemon-ONLY authority @@ -24,7 +24,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; * OWN terminal-close-suppression decision has its own cheaper, direct * coverage in `packages/ad-replay/src/internal/__tests__/step-loop.test.ts` * (see that file's header). This file's real subject is - * `session-replay-runtime.ts`'s `runReplayScriptFile` — specifically its + * `session-replay-runtime.ts`'s `runReplayScriptSource` — specifically its * `--keep-session` behavior — so it is named and grouped alongside that * file's other `session-replay-runtime-*.test.ts` siblings * (`-plan.test.ts`, `-maestro.test.ts`, `-failure.test.ts`, …) rather than @@ -39,7 +39,7 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { import fs from 'node:fs'; import path from 'node:path'; -import { runReplayScriptFile } from '../session-replay-runtime.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import type { DaemonRequest } from '../../types.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; @@ -56,7 +56,7 @@ test('--keep-session suppresses a close that is terminal among executable action const filePath = writeReplayFile(root, ['open "Demo"', 'close', 'replay "./nested-flow.ad"']); const commands: string[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -81,7 +81,7 @@ test('--keep-session fails explicitly when the completed replay has no live sess sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Log out"']); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -109,7 +109,7 @@ test('--keep-session suppresses only the authored terminal close and reports the const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"', 'close']); const commands: string[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -136,7 +136,7 @@ test('--keep-session preserves an interior close instead of broad command filter const filePath = writeReplayFile(root, ['open "Demo"', 'close', 'open "Next"']); const commands: string[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -159,7 +159,7 @@ test('--keep-session is a no-op for an already close-less script', async () => { const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const invoke = vi.fn(async (_req: DaemonRequest) => ({ ok: true as const, data: {} })); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -182,7 +182,7 @@ test('--keep-session rejects Maestro YAML before engine dispatch', async () => { fs.writeFileSync(filePath, ['appId: com.example.app', '---', '- launchApp'].join('\n')); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayBackend: 'maestro', replayKeepSession: true }, diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts index 32c16f40f1..7709ed3ea3 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts @@ -12,7 +12,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; // // This file carries the Maestro-heavy majority of what used to live in // session-replay-vars.test.ts, plus a handful of generic (non-Maestro) `.ad` -// runReplayScriptFile tests that happen to share the same runReplayFixture +// runReplayScriptSource tests that happen to share the same runReplayFixture // helper and mock configuration below. It is a sibling of // session-replay-runtime.test.ts rather than a merge into it because that // file mocks '../../../core/dispatch.ts' differently (dispatchCommand @@ -56,7 +56,8 @@ import { PNG } from '../../../utils/png.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../types.ts'; import { SessionStore } from '../../session-store.ts'; import { makeAndroidSession, makeIosSession } from '../../../__tests__/test-utils/index.ts'; -import { runReplayScriptFile } from '../session-replay-runtime.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; +import { replayScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; type CapturedInvocation = { command: string; @@ -87,7 +88,7 @@ async function runReplayFixture(params: { const invoke = createFixtureInvoke({ calls, delegate: params.invoke, isMaestro }); const sessionStore = new SessionStore(path.join(root, 'state')); seedFixtureSession(sessionStore, params.sessionPlatform); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: fixtureReplayRequest({ root, scriptPath, flags: params.flags, isMaestro }), sessionName: 's', logPath: path.join(root, 'log'), @@ -144,6 +145,9 @@ function fixtureReplayRequest(params: { flags: { ...(params.flags ?? {}), ...(params.isMaestro && params.flags?.platform === undefined ? { platform: 'ios' } : {}), + replayScriptSource: replayScriptSourceBundleFor(params.scriptPath, { + replayBackend: params.flags?.replayBackend, + }), }, meta: { cwd: params.root }, }; @@ -175,7 +179,7 @@ function assertNoUnresolvedInterpolation(calls: CapturedInvocation[]): void { } } -test('runReplayScriptFile dispatches resolved literals with file env overridden by CLI', async () => { +test('runReplayScriptSource dispatches resolved literals with file env overridden by CLI', async () => { const { response, calls } = await runReplayFixture({ label: 'green', script: @@ -251,7 +255,7 @@ test('.ad replay normalizes resolved gesture and swipe syntax into structured da ); }); -test('runReplayScriptFile reports snapshot diagnostics from per-action session samples', async () => { +test('runReplayScriptSource reports snapshot diagnostics from per-action session samples', async () => { const root = mkdtempForTestSync('agent-device-replay-snapshot-samples-'); const scriptPath = path.join(root, 'flow.ad'); // Four warm captures beyond the cold start: the slow-run warning judges warm @@ -269,12 +273,13 @@ test('runReplayScriptFile reports snapshot diagnostics from per-action session s ); let captures = 0; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: { token: 't', session: 's', command: 'replay', positionals: [scriptPath], + flags: { replayScriptSource: replayScriptSourceBundleFor(scriptPath) }, meta: { cwd: root }, }, sessionName: 's', @@ -314,7 +319,7 @@ test('runReplayScriptFile reports snapshot diagnostics from per-action session s assert.match(String(diagnostics?.warning), /p95 1900ms over 4 captures/); }); -test('runReplayScriptFile reports snapshot diagnostics on replay failure', async () => { +test('runReplayScriptSource reports snapshot diagnostics on replay failure', async () => { const root = mkdtempForTestSync('agent-device-replay-snapshot-failure-'); const scriptPath = path.join(root, 'flow.ad'); // Three warm captures precede the failing click so the failure-path summary @@ -332,12 +337,13 @@ test('runReplayScriptFile reports snapshot diagnostics on replay failure', async ); let captures = 0; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: { token: 't', session: 's', command: 'replay', positionals: [scriptPath], + flags: { replayScriptSource: replayScriptSourceBundleFor(scriptPath) }, meta: { cwd: root }, }, sessionName: 's', @@ -365,7 +371,7 @@ test('runReplayScriptFile reports snapshot diagnostics on replay failure', async assert.match(String(diagnostics?.warning), /p95 2100ms over 4 captures/); }); -test('runReplayScriptFile applies CLI env overrides before Maestro compat mapping', async () => { +test('runReplayScriptSource applies CLI env overrides before Maestro compat mapping', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-env', script: [ @@ -417,7 +423,7 @@ test('runReplayScriptFile applies CLI env overrides before Maestro compat mappin ); }); -test('runReplayScriptFile runs Maestro runScript in replay order and exposes output variables', async () => { +test('runReplayScriptSource runs Maestro runScript in replay order and exposes output variables', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-runscript-runtime', files: { @@ -450,7 +456,7 @@ output.result = SERVER_PATH + ':' + json(res.body).appviewDid ); }); -test('runReplayScriptFile supports successful Maestro runScript http.post calls', async () => { +test('runReplayScriptSource supports successful Maestro runScript http.post calls', async () => { const server = new Worker( ` const http = require('node:http'); @@ -512,7 +518,7 @@ output.result = parsed.method + ':' + json(parsed.body).ok } }); -test('runReplayScriptFile strips prototype pollution keys from runScript json()', async () => { +test('runReplayScriptSource strips prototype pollution keys from runScript json()', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-runscript-json-prototype-keys', files: { @@ -547,7 +553,7 @@ output.result = [ ); }); -test('runReplayScriptFile reports Maestro runScript failures at the runScript step', async () => { +test('runReplayScriptSource reports Maestro runScript failures at the runScript step', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-runscript-fail', files: { @@ -568,7 +574,7 @@ test('runReplayScriptFile reports Maestro runScript failures at the runScript st assert.equal(calls.length, 0); }); -test('runReplayScriptFile reports iOS Maestro openLink setup failures before assertions', async () => { +test('runReplayScriptSource reports iOS Maestro openLink setup failures before assertions', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-ios-openlink-prewarm-fail', script: [ @@ -612,7 +618,7 @@ test('runReplayScriptFile reports iOS Maestro openLink setup failures before ass assert.equal(calls[0]?.flags?.maestro?.prewarmRunnerBeforeOpen, true); }); -test('runReplayScriptFile explains empty Maestro runScript JSON bodies', async () => { +test('runReplayScriptSource explains empty Maestro runScript JSON bodies', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-runscript-empty-json', files: { @@ -633,7 +639,7 @@ test('runReplayScriptFile explains empty Maestro runScript JSON bodies', async ( assert.equal(calls.length, 0); }); -test('runReplayScriptFile rejects Maestro runScript output keys containing dots', async () => { +test('runReplayScriptSource rejects Maestro runScript output keys containing dots', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-runscript-dotted-output', files: { @@ -653,7 +659,7 @@ test('runReplayScriptFile rejects Maestro runScript output keys containing dots' assert.equal(calls.length, 0); }); -test('runReplayScriptFile retries Maestro scrollUntilVisible with scroll probes', async () => { +test('runReplayScriptSource retries Maestro scrollUntilVisible with scroll probes', async () => { const calls: CapturedInvocation[] = []; let snapshotAttempts = 0; const { response } = await runReplayFixture({ @@ -705,7 +711,7 @@ test('runReplayScriptFile retries Maestro scrollUntilVisible with scroll probes' ); }); -test('runReplayScriptFile uses semantic iOS dispatch for an exact text tapOn', async () => { +test('runReplayScriptSource uses semantic iOS dispatch for an exact text tapOn', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-tap-visible-text-atomic-ios', @@ -751,7 +757,7 @@ test('runReplayScriptFile uses semantic iOS dispatch for an exact text tapOn', a ); }); -test('runReplayScriptFile uses matched Android id geometry for tapOn', async () => { +test('runReplayScriptSource uses matched Android id geometry for tapOn', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-tap-id-matched-geometry', @@ -796,7 +802,7 @@ test('runReplayScriptFile uses matched Android id geometry for tapOn', async () ); }); -test('runReplayScriptFile captures fresh geometry for tapOn after assertVisible', async () => { +test('runReplayScriptSource captures fresh geometry for tapOn after assertVisible', async () => { let snapshots = 0; const { response, calls } = await runReplayFixture({ label: 'maestro-assert-visible-tap-fresh-snapshot', @@ -847,7 +853,7 @@ test('runReplayScriptFile captures fresh geometry for tapOn after assertVisible' ); }); -test('runReplayScriptFile scopes duplicate tap targets after native Maestro assertVisible', async () => { +test('runReplayScriptSource scopes duplicate tap targets after native Maestro assertVisible', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-native-assert-context-duplicate-tap', script: ['appId: demo.app', '---', '- assertVisible: Albums', '- tapOn: Push article', ''].join( @@ -915,7 +921,7 @@ test('runReplayScriptFile scopes duplicate tap targets after native Maestro asse ); }); -test('runReplayScriptFile treats absent Maestro assertNotVisible targets as passing', async () => { +test('runReplayScriptSource treats absent Maestro assertNotVisible targets as passing', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-assert-not-visible-absent', @@ -941,7 +947,7 @@ test('runReplayScriptFile treats absent Maestro assertNotVisible targets as pass assert.equal(calls[0]?.flags?.noRecord, true); }); -test('runReplayScriptFile propagates Maestro assertNotVisible infrastructure failures', async () => { +test('runReplayScriptSource propagates Maestro assertNotVisible infrastructure failures', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-assert-not-visible-infra-fail', @@ -964,7 +970,7 @@ test('runReplayScriptFile propagates Maestro assertNotVisible infrastructure fai assert.equal(calls.length, 1); }); -test('runReplayScriptFile waits briefly for Maestro assertNotVisible to stabilize', async () => { +test('runReplayScriptSource waits briefly for Maestro assertNotVisible to stabilize', async () => { const calls: CapturedInvocation[] = []; let snapshots = 0; const { response } = await runReplayFixture({ @@ -1003,7 +1009,7 @@ test('runReplayScriptFile waits briefly for Maestro assertNotVisible to stabiliz assert.equal(calls.length, 2); }); -test('runReplayScriptFile treats absent Maestro extendedWaitUntil.notVisible targets as passing', async () => { +test('runReplayScriptSource treats absent Maestro extendedWaitUntil.notVisible targets as passing', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-extended-wait-not-visible-absent', script: [ @@ -1032,7 +1038,7 @@ test('runReplayScriptFile treats absent Maestro extendedWaitUntil.notVisible tar assert.equal(calls[0]?.flags?.noRecord, true); }); -test('runReplayScriptFile resolves Maestro percentage point taps from the direct viewport', async () => { +test('runReplayScriptSource resolves Maestro percentage point taps from the direct viewport', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-tap-point-percent', @@ -1070,7 +1076,7 @@ test('runReplayScriptFile resolves Maestro percentage point taps from the direct ); }); -test('runReplayScriptFile retries Maestro id tapOn through snapshot coordinates', async () => { +test('runReplayScriptSource retries Maestro id tapOn through snapshot coordinates', async () => { const calls: CapturedInvocation[] = []; let snapshotAttempts = 0; const { response } = await runReplayFixture({ @@ -1117,7 +1123,7 @@ test('runReplayScriptFile retries Maestro id tapOn through snapshot coordinates' ); }); -test('runReplayScriptFile resolves Maestro tapOn index and childOf from snapshots', async () => { +test('runReplayScriptSource resolves Maestro tapOn index and childOf from snapshots', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-tap-index-childof', @@ -1186,7 +1192,7 @@ test('runReplayScriptFile resolves Maestro tapOn index and childOf from snapshot assert.equal(calls[0]?.flags?.noRecord, true); }); -test('runReplayScriptFile lets snapshot id tap handle Maestro one-point edge controls', async () => { +test('runReplayScriptSource lets snapshot id tap handle Maestro one-point edge controls', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-tap-edge-rect', @@ -1222,7 +1228,7 @@ test('runReplayScriptFile lets snapshot id tap handle Maestro one-point edge con ); }); -test('runReplayScriptFile resolves a text-entry target once before typing', async () => { +test('runReplayScriptSource resolves a text-entry target once before typing', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-tap-input-text-snapshot', @@ -1271,7 +1277,7 @@ test('runReplayScriptFile resolves a text-entry target once before typing', asyn ); }); -test('runReplayScriptFile resolves Maestro swipe.label from a labeled element rect', async () => { +test('runReplayScriptSource resolves Maestro swipe.label from a labeled element rect', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-swipe-label', @@ -1328,7 +1334,7 @@ test('runReplayScriptFile resolves Maestro swipe.label from a labeled element re ); }); -test('runReplayScriptFile keeps Maestro swipe.label anchored to the matched label rect', async () => { +test('runReplayScriptSource keeps Maestro swipe.label anchored to the matched label rect', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-swipe-label-child-rect', @@ -1394,7 +1400,7 @@ test('runReplayScriptFile keeps Maestro swipe.label anchored to the matched labe ); }); -test('runReplayScriptFile resolves Maestro screen swipes from the direct viewport', async () => { +test('runReplayScriptSource resolves Maestro screen swipes from the direct viewport', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-screen-swipe', @@ -1465,7 +1471,7 @@ test('runReplayScriptFile resolves Maestro screen swipes from the direct viewpor ); }); -test('runReplayScriptFile delegates Android directional swipes and preserves percentage points', async () => { +test('runReplayScriptSource delegates Android directional swipes and preserves percentage points', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-screen-swipe-android-midpoint-lane', @@ -1536,7 +1542,7 @@ test('runReplayScriptFile delegates Android directional swipes and preserves per ); }); -test('runReplayScriptFile maps Maestro enter to keyboard enter', async () => { +test('runReplayScriptSource maps Maestro enter to keyboard enter', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-press-enter', @@ -1555,7 +1561,7 @@ test('runReplayScriptFile maps Maestro enter to keyboard enter', async () => { ); }); -test('runReplayScriptFile waits for Maestro animation screenshots to stabilize', async () => { +test('runReplayScriptSource waits for Maestro animation screenshots to stabilize', async () => { const calls: CapturedInvocation[] = []; const screenshot = PNG.sync.write(new PNG({ width: 1, height: 1 })); const { response } = await runReplayFixture({ @@ -1581,7 +1587,7 @@ test('runReplayScriptFile waits for Maestro animation screenshots to stabilize', assert.equal(calls[1]?.flags?.screenshotNoStabilize, true); }); -test('runReplayScriptFile propagates unsupported keyboard enter dispatch', async () => { +test('runReplayScriptSource propagates unsupported keyboard enter dispatch', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-press-enter-unsupported', @@ -1607,7 +1613,7 @@ test('runReplayScriptFile propagates unsupported keyboard enter dispatch', async ); }); -test('runReplayScriptFile retries Maestro retry commands until they pass', async () => { +test('runReplayScriptSource retries Maestro retry commands until they pass', async () => { const calls: CapturedInvocation[] = []; let openAttempts = 0; const { response } = await runReplayFixture({ @@ -1670,7 +1676,7 @@ test('runReplayScriptFile retries Maestro retry commands until they pass', async assert.equal(calls.filter((call) => call.command === 'snapshot').length > 1, true); }); -test('runReplayScriptFile propagates Maestro runFlow.when runtime errors', async () => { +test('runReplayScriptSource propagates Maestro runFlow.when runtime errors', async () => { const { response } = await runReplayFixture({ label: 'maestro-run-flow-when-visible-runtime-error', script: [ @@ -1705,7 +1711,7 @@ test('runReplayScriptFile propagates Maestro runFlow.when runtime errors', async } }); -test('runReplayScriptFile runs Maestro runFlow.when.visible commands when present', async () => { +test('runReplayScriptSource runs Maestro runFlow.when.visible commands when present', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-run-flow-when-visible-run', @@ -1767,7 +1773,7 @@ test('runReplayScriptFile runs Maestro runFlow.when.visible commands when presen ); }); -test('runReplayScriptFile runs nested Maestro runtime commands inside runFlow.when', async () => { +test('runReplayScriptSource runs nested Maestro runtime commands inside runFlow.when', async () => { const calls: CapturedInvocation[] = []; let snapshots = 0; const { response } = await runReplayFixture({ @@ -1840,7 +1846,7 @@ test('runReplayScriptFile runs nested Maestro runtime commands inside runFlow.wh ); }); -test('runReplayScriptFile resolves nested Maestro runFlow.when command variables once at execution', async () => { +test('runReplayScriptSource resolves nested Maestro runFlow.when command variables once at execution', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-run-flow-when-nested-vars', @@ -1906,7 +1912,7 @@ test('runReplayScriptFile resolves nested Maestro runFlow.when command variables ); }); -test('runReplayScriptFile reads shell env from request (client-collected), not daemon process.env', async () => { +test('runReplayScriptSource reads shell env from request (client-collected), not daemon process.env', async () => { // Ensure the daemon's own process.env does NOT contain AD_VAR_APP. assert.equal(process.env.AD_VAR_APP, undefined); const { response, calls } = await runReplayFixture({ @@ -1919,7 +1925,7 @@ test('runReplayScriptFile reads shell env from request (client-collected), not d assert.deepEqual(calls[0]?.positionals, ['client-shell-app']); }); -test('runReplayScriptFile falls back to process.env when request omits replayShellEnv', async () => { +test('runReplayScriptSource falls back to process.env when request omits replayShellEnv', async () => { const previous = process.env.AD_VAR_APP; process.env.AD_VAR_APP = 'daemon-env-app'; try { @@ -1935,7 +1941,7 @@ test('runReplayScriptFile falls back to process.env when request omits replayShe } }); -test('runReplayScriptFile writes per-action timing events to active trace', async () => { +test('runReplayScriptSource writes per-action timing events to active trace', async () => { const root = mkdtempForTestSync('agent-device-replay-trace-'); const scriptPath = path.join(root, 'flow.ad'); const tracePath = path.join(root, 'trace.ndjson'); @@ -1957,13 +1963,13 @@ test('runReplayScriptFile writes per-action timing events to active trace', asyn actions: [], }); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: { token: 't', session: 's', command: 'replay', positionals: [scriptPath], - flags: {}, + flags: { replayScriptSource: replayScriptSourceBundleFor(scriptPath) }, meta: { cwd: root }, }, sessionName: 's', diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts index 4d0661e58a..7e16933338 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts @@ -8,7 +8,7 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { import fs from 'node:fs'; import path from 'node:path'; -import { runReplayScriptFile } from '../session-replay-runtime.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { createReplayCoordinator } from '../../session-replay-coordinator.ts'; import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'; @@ -42,7 +42,7 @@ test('resume skips steps 1..from-1 without invoking them and executes only from ]); // First attempt: step 3 fails, capturing a real resume report. - const firstAttempt = await runReplayScriptFile({ + const firstAttempt = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -65,7 +65,7 @@ test('resume skips steps 1..from-1 without invoking them and executes only from // Second attempt: repair app state, resume at the reported step. Steps 1-2 // must never be invoked — the mock throws if they are. const invokedCommands: string[] = []; - const resumedAttempt = await runReplayScriptFile({ + const resumedAttempt = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayFrom: divergence.resume.from, replayPlanDigest: divergence.resume.planDigest }, @@ -96,7 +96,7 @@ test('resume requires both --from and --plan-digest together', async () => { sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); - const fromOnly = await runReplayScriptFile({ + const fromOnly = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayFrom: 2 } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -108,7 +108,7 @@ test('resume requires both --from and --plan-digest together', async () => { expect(fromOnly.ok).toBe(false); if (!fromOnly.ok) expect(fromOnly.error.code).toBe('INVALID_ARGS'); - const digestOnly = await runReplayScriptFile({ + const digestOnly = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayPlanDigest: 'deadbeef' } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -128,7 +128,7 @@ test('resume rejects an out-of-range --from before any action', async () => { sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayFrom: 99, replayPlanDigest: 'deadbeef' }, @@ -181,7 +181,7 @@ test("a rejected --from/--plan-digest resume never reaches prepareReplaySession' const beforeView = coordinator.view(); const beforeActionsLength = sessionStore.get(sessionName)!.actions.length; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayFrom: 2, replayPlanDigest: 'not-the-real-digest' }, @@ -213,7 +213,7 @@ test('resume rejects a stale --plan-digest after the script changed', async () = sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); - const firstAttempt = await runReplayScriptFile({ + const firstAttempt = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -235,7 +235,7 @@ test('resume rejects a stale --plan-digest after the script changed', async () = // longer match. fs.writeFileSync(filePath, 'open "Demo"\nclick "Extra"\nclick "Save"\n'); - const resumedAttempt = await runReplayScriptFile({ + const resumedAttempt = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayFrom: divergence.resume.from, replayPlanDigest: divergence.resume.planDigest }, @@ -265,7 +265,7 @@ test('resume rejects a digest from a different effective platform or target befo ]); const executionFlags = { platform: 'ios' as const, target: 'mobile' as const }; - const firstAttempt = await runReplayScriptFile({ + const firstAttempt = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: executionFlags }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -285,7 +285,7 @@ test('resume rejects a digest from a different effective platform or target befo { ...executionFlags, target: 'desktop' as const }, { ...executionFlags, platform: 'android' as const }, ]) { - const resumedAttempt = await runReplayScriptFile({ + const resumedAttempt = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { @@ -333,7 +333,7 @@ test('resume rejects resuming past a retry-wrapped step in the skipped range', a // directly — the retry block's nested `back` (1st call) succeeds; the // top-level step-2 `back` (2nd call) fails. let backCalls = 0; - const firstAttempt = await runReplayScriptFile({ + const firstAttempt = await runReplayScriptSource({ req: baseReq({ positionals: [mainPath], flags: { replayBackend: 'maestro' } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -356,7 +356,7 @@ test('resume rejects resuming past a retry-wrapped step in the skipped range', a expect(divergence.resume.allowed).toBe(false); expect(divergence.resume.reason).toMatch(/control flow/); - const resumedAttempt = await runReplayScriptFile({ + const resumedAttempt = await runReplayScriptSource({ req: baseReq({ positionals: [mainPath], flags: { @@ -387,7 +387,7 @@ test('typed Maestro resume digest binds an inferred session target', async () => const flowPath = path.join(root, 'flow.yaml'); fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- back\n'); - const firstAttempt = await runReplayScriptFile({ + const firstAttempt = await runReplayScriptSource({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro' } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -407,7 +407,7 @@ test('typed Maestro resume digest binds an inferred session target', async () => ...session, device: { ...session.device, target: 'tv' }, }); - const resumedAttempt = await runReplayScriptFile({ + const resumedAttempt = await runReplayScriptSource({ req: baseReq({ positionals: [flowPath], flags: { @@ -439,7 +439,7 @@ test('typed Maestro rejects selectors that conflict with an active session', asy fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- back\n'); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro', platform: 'android' }, @@ -469,7 +469,7 @@ test('fresh typed Maestro replay resolves its configured app before runtime defa ); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro', platform: 'ios' }, @@ -483,7 +483,7 @@ test('fresh typed Maestro replay resolves its configured app before runtime defa expect(response.ok).toBe(true); expect(mockResolveTargetDevice).toHaveBeenCalledWith( - { replayBackend: 'maestro', platform: 'ios' }, + expect.objectContaining({ replayBackend: 'maestro', platform: 'ios' }), { appleSimulatorAppTarget: 'com.example.demo' }, ); expect(invoke).toHaveBeenCalledWith( @@ -504,7 +504,7 @@ test('native replay applies an authored Android platform to its static app open' ); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [replayPath] }), sessionName: 'default', logPath: path.join(root, 'daemon.log'), @@ -531,7 +531,7 @@ test('platform-less typed Maestro replay preserves a resolved Android device', a mockResolveTargetDevice.mockResolvedValue(androidDevice); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro' }, @@ -544,7 +544,10 @@ test('platform-less typed Maestro replay preserves a resolved Android device', a }); expect(response.ok).toBe(true); - expect(mockResolveTargetDevice).toHaveBeenCalledWith({ replayBackend: 'maestro' }, {}); + expect(mockResolveTargetDevice).toHaveBeenCalledWith( + expect.objectContaining({ replayBackend: 'maestro' }), + {}, + ); expect(invoke).toHaveBeenCalledWith( expect.objectContaining({ command: 'open', @@ -566,7 +569,7 @@ test('typed Maestro resume digest binds effective stored runtime hints', async ( const flowPath = path.join(root, 'flow.yaml'); fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- back\n'); - const firstAttempt = await runReplayScriptFile({ + const firstAttempt = await runReplayScriptSource({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro' } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -587,7 +590,7 @@ test('typed Maestro resume digest binds effective stored runtime hints', async ( metroHost: '127.0.0.1', metroPort: 8084, }); - const resumedAttempt = await runReplayScriptFile({ + const resumedAttempt = await runReplayScriptSource({ req: baseReq({ positionals: [flowPath], flags: { diff --git a/src/daemon/handlers/__tests__/session-replay-runtime.fixtures.ts b/src/daemon/handlers/__tests__/session-replay-runtime.fixtures.ts index f76f6c0f81..e813899a04 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime.fixtures.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime.fixtures.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { replayScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; import type { DaemonRequest } from '../../types.ts'; export function writeReplayFile(root: string, lines: string[]): string { @@ -8,12 +9,32 @@ export function writeReplayFile(root: string, lines: string[]): string { return filePath; } +/** + * A `replay` request shaped the way the CLI actually sends one: #1802 made the + * CALLER read every script file the run needs, so a request whose first + * positional names a script also carries that script's source bundle. Tests + * pass `positionals: [filePath]` as before and the bundle is built from the + * same file, exactly as `replayDaemonWriter` builds it. + */ export function baseReplayRequest(overrides: Partial = {}): DaemonRequest { + const positionals = overrides.positionals ?? []; + const scriptPath = positionals[0]; + const flags = overrides.flags; return { token: 'token', session: 'default', command: 'replay', - positionals: [], ...overrides, + positionals, + ...(scriptPath !== undefined && flags?.replayScriptSource === undefined + ? { + flags: { + ...(flags ?? {}), + replayScriptSource: replayScriptSourceBundleFor(scriptPath, { + replayBackend: flags?.replayBackend, + }), + }, + } + : {}), }; } diff --git a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts index dd1e239344..eb7cd9ebe0 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts @@ -9,7 +9,7 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { import fs from 'node:fs'; import path from 'node:path'; -import { runReplayScriptFile } from '../session-replay-runtime.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; import { @@ -34,7 +34,7 @@ test('a successful replay prints one line with the step count and wall time', as sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -63,7 +63,7 @@ test('a close-less replay reports sessionActive: true (real producer, session st sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -84,7 +84,7 @@ test('a replay whose terminal close removes the session reports sessionActive: f sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'close']); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -123,7 +123,7 @@ test('Maestro YAML uses the typed engine while .ad remains generic', async () => return { ok: true as const, data: {} }; }); - const yamlResponse = await runReplayScriptFile({ + const yamlResponse = await runReplayScriptSource({ req: baseReq({ positionals: [yamlPath], flags: { replayBackend: 'maestro', platform: 'ios', replayKeepSession: false }, @@ -139,7 +139,7 @@ test('Maestro YAML uses the typed engine while .ad remains generic', async () => commands.length = 0; const adPath = writeReplayFile(root, ['open "Generic"']); - const adResponse = await runReplayScriptFile({ + const adResponse = await runReplayScriptSource({ req: baseReq({ positionals: [adPath], flags: { replayBackend: 'maestro', platform: 'ios' }, @@ -161,7 +161,7 @@ test('bare Maestro YAML requires explicit --maestro routing', async () => { const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [yamlPath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -186,7 +186,7 @@ test('ADR 0016 / #1384: a Maestro replay reports sessionActive: true (real produ const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [yamlPath], flags: { replayBackend: 'maestro', platform: 'ios' }, @@ -219,9 +219,9 @@ test('typed Maestro nested commands receive the runtime hints bound into the pla }); const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); - const requests: Parameters[0]['invoke']>[0][] = []; + const requests: Parameters[0]['invoke']>[0][] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [yamlPath], flags: { replayBackend: 'maestro', platform: 'ios' }, @@ -263,7 +263,7 @@ test('typed Maestro writes source-aware redacted step timing traces', async () = fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- inputText: highly-sensitive\n'); fs.writeFileSync(tracePath, ''); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [yamlPath], flags: { replayBackend: 'maestro', platform: 'ios' }, @@ -315,7 +315,7 @@ test('replay trace failures do not change action semantics', async () => { const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- back\n'); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [yamlPath], flags: { replayBackend: 'maestro', platform: 'ios' }, @@ -340,7 +340,7 @@ test('generic replay traces redact typed text', async () => { const tracePath = path.join(root, 'replay-timing.ndjson'); fs.writeFileSync(tracePath, ''); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -365,7 +365,7 @@ test('Maestro YAML rejects .ad repair recording before executing any command', a fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [yamlPath], flags: { replayBackend: 'maestro', platform: 'ios', saveScript: true }, @@ -394,7 +394,7 @@ test('Maestro YAML cannot append commands to an active .ad repair session', asyn fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [yamlPath], flags: { replayBackend: 'maestro', platform: 'ios' }, @@ -419,7 +419,7 @@ test('replay rejects legacy JSON payload files', async () => { const filePath = path.join(root, 'replay.json'); fs.writeFileSync(filePath, JSON.stringify({ optimizedActions: [] }, null, 2)); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -452,7 +452,7 @@ test('replay rejects an unknown --replay-backend value before any step dispatch const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayBackend: 'unknown' } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -482,7 +482,7 @@ test('replay still dispatches a plain .ad script with replayBackend: "maestro"', const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayBackend: 'maestro' } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -503,7 +503,7 @@ test('replay rejects malformed .ad lines with unclosed quotes', async () => { sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['click "id=\\"broken\\"']); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -545,7 +545,7 @@ test('--update never rewrites the .ad file, even when a re-resolvable suggestion backend: 'xctest', }); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayUpdate: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -576,7 +576,7 @@ test('a successful --update replay reports healed: 0 (heal is retired, not just sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayUpdate: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -597,7 +597,7 @@ test('--update no longer refuses env directives (the guard existed only for rewr sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['env NAME=World', 'open "Demo"']); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayUpdate: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -615,7 +615,7 @@ test('--update no longer refuses ${VAR} interpolation (the guard existed only fo sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['click label="${NAME}"']); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath], flags: { replayUpdate: true, replayEnv: ['NAME=World'] }, diff --git a/src/daemon/handlers/__tests__/session-replay-script-source.test.ts b/src/daemon/handlers/__tests__/session-replay-script-source.test.ts new file mode 100644 index 0000000000..05e48b21c6 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-replay-script-source.test.ts @@ -0,0 +1,186 @@ +/** + * #1802: `replay ` used to resolve the caller's path on the DAEMON and open it there. A + * remote daemon shares no filesystem with the caller, so the command failed with `ENOENT` on a + * path the caller could read. These pin the repair at the daemon's own boundary: the run reads + * only the replay script source bundle the request carried, and a request that carries just a + * path is refused with a message that says so. + */ +import { beforeEach, expect, test, vi } from 'vitest'; +import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; + +vi.mock('../../../core/dispatch.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; +}); + +import fs from 'node:fs'; +import path from 'node:path'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; +import { SessionStore } from '../../session-store.ts'; +import { dispatchCommand } from '../../../core/dispatch.ts'; +import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { replayScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; +import { REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE } from '../../../replay/script-source-bundle.ts'; + +beforeEach(() => { + vi.mocked(dispatchCommand).mockReset(); + vi.mocked(dispatchCommand).mockResolvedValue({}); +}); + +test('a bundled replay runs after the script file is gone from the daemon host', async () => { + const root = mkdtempForTestSync('agent-device-replay-source-bundle-'); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + sessionStore.set('default', makeIosSession('default')); + const scriptPath = path.join(root, 'flow.ad'); + fs.writeFileSync(scriptPath, 'open "Demo"\nclick "Save"\n'); + const bundle = replayScriptSourceBundleFor(scriptPath); + // Stand in for the remote daemon: nothing at the caller's path exists on this host. + fs.rmSync(scriptPath); + + const response = await runReplayScriptSource({ + req: { + token: 'token', + session: 'default', + command: 'replay', + positionals: [scriptPath], + flags: { replayScriptSource: bundle }, + meta: { cwd: '/a/directory/that/does/not/exist' }, + }, + sessionName: 'default', + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async () => ({ ok: true, data: {} }), + }); + + expect(response.ok).toBe(true); + if (!response.ok) return; + expect(response.data).toMatchObject({ replayed: 2 }); +}); + +test('a bundled Maestro replay resolves runFlow includes from the bundle, not the daemon disk', async () => { + const root = mkdtempForTestSync('agent-device-replay-maestro-bundle-'); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + sessionStore.set('default', makeIosSession('default')); + const flowPath = path.join(root, 'flow.yaml'); + fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- runFlow: included.yaml\n'); + fs.writeFileSync(path.join(root, 'included.yaml'), '---\n- back\n'); + const bundle = replayScriptSourceBundleFor(flowPath, { replayBackend: 'maestro' }); + fs.rmSync(root, { recursive: true, force: true }); + const invoked: string[] = []; + + const response = await runReplayScriptSource({ + req: { + token: 'token', + session: 'default', + command: 'replay', + positionals: [flowPath], + flags: { replayBackend: 'maestro', platform: 'ios', replayScriptSource: bundle }, + }, + sessionName: 'default', + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async (req) => { + invoked.push(req.command); + return { ok: true, data: {} }; + }, + }); + + expect(response.ok).toBe(true); + expect(invoked).toContain('back'); +}); + +test('a Maestro include missing from the bundle fails naming the include path', async () => { + const root = mkdtempForTestSync('agent-device-replay-maestro-missing-include-'); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + sessionStore.set('default', makeIosSession('default')); + const flowPath = path.join(root, 'flow.yaml'); + const flow = 'appId: com.example.app\n---\n- runFlow: absent.yaml\n'; + fs.writeFileSync(flowPath, flow); + + const response = await runReplayScriptSource({ + req: { + token: 'token', + session: 'default', + command: 'replay', + positionals: [flowPath], + flags: { + replayBackend: 'maestro', + platform: 'ios', + replayScriptSource: { entry: flowPath, files: { [flowPath]: flow } }, + }, + }, + sessionName: 'default', + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async () => ({ ok: true, data: {} }), + }); + + expect(response.ok).toBe(false); + if (response.ok) return; + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toContain(path.join(root, 'absent.yaml')); +}); + +// No path fallback: an older client that sends only a path is told what is missing instead of +// being served an ENOENT for a file that exists on the machine it ran on. +test('a request carrying only a path is refused with the client-must-send-sources message', async () => { + const root = mkdtempForTestSync('agent-device-replay-path-only-'); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + sessionStore.set('default', makeIosSession('default')); + const scriptPath = path.join(root, 'flow.ad'); + fs.writeFileSync(scriptPath, 'open "Demo"\n'); + + const response = await runReplayScriptSource({ + req: { + token: 'token', + session: 'default', + command: 'replay', + positionals: [scriptPath], + flags: {}, + meta: { cwd: root }, + }, + sessionName: 'default', + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async () => ({ ok: true, data: {} }), + }); + + expect(response.ok).toBe(false); + if (response.ok) return; + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toBe(REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE); +}); + +// The bundle's entry is the path the caller resolved, so the divergence report a failing step +// produces still points at the script the operator ran — path and line alike. +test('a failing step reports the caller-resolved script path and line', async () => { + const root = mkdtempForTestSync('agent-device-replay-source-display-'); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + sessionStore.set('default', makeIosSession('default')); + const scriptPath = path.join(root, 'flow.ad'); + fs.writeFileSync(scriptPath, 'open "Demo"\nclick "Nope"\n'); + + const response = await runReplayScriptSource({ + req: { + token: 'token', + session: 'default', + command: 'replay', + positionals: [scriptPath], + flags: { replayScriptSource: replayScriptSourceBundleFor(scriptPath) }, + meta: { cwd: root }, + }, + sessionName: 'default', + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async (req) => + req.command === 'click' + ? { ok: false, error: { code: 'NOT_FOUND', message: 'no match' } } + : { ok: true, data: {} }, + }); + + expect(response.ok).toBe(false); + if (response.ok) return; + expect(response.error.details).toMatchObject({ + divergence: { step: { source: { path: scriptPath, line: 2 } } }, + }); +}); diff --git a/src/daemon/handlers/__tests__/session-replay-selector-routes.test.ts b/src/daemon/handlers/__tests__/session-replay-selector-routes.test.ts index 83abc7e0dd..da23ec6182 100644 --- a/src/daemon/handlers/__tests__/session-replay-selector-routes.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-selector-routes.test.ts @@ -5,7 +5,7 @@ import { makeIosSession } from '../../../__tests__/test-utils/session-factories. import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; import { SessionStore } from '../../session-store.ts'; -import { runReplayScriptFile } from '../session-replay-runtime.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { baseReplayRequest, writeReplayFile } from './session-replay-runtime.fixtures.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { @@ -35,7 +35,7 @@ test('replay executes selector reads before reporting a covered-target divergenc ]); const invoked: string[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReplayRequest({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), diff --git a/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts b/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts index 4dea5e35fa..40b9f73152 100644 --- a/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts @@ -1,5 +1,5 @@ /** - * ADR 0012 migration step 4, end-to-end: `runReplayScriptFile` must consult + * ADR 0012 migration step 4, end-to-end: `runReplayScriptSource` must consult * `verifyReplayActionTarget` for every annotated resolved-target action * BEFORE dispatching it, and never send the device action on a non-verified * outcome. Mirrors the mocking pattern of `session-replay-runtime.test.ts` @@ -24,14 +24,17 @@ vi.mock('../../../utils/timeouts.ts', async (importOriginal) => { return { ...actual, sleep: vi.fn(async () => {}) }; }); -import fs from 'node:fs'; import path from 'node:path'; -import { runReplayScriptFile } from '../session-replay-runtime.ts'; -import { SessionStore } from '../../session-store.ts'; import type { DaemonRequest } from '../../types.ts'; +import { runReplayScriptSource } from '../session-replay-runtime.ts'; +import { SessionStore } from '../../session-store.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; import { AppError } from '@agent-device/kernel/errors'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { + baseReplayRequest as baseReq, + writeReplayFile, +} from './session-replay-runtime.fixtures.ts'; const mockDispatchCommand = vi.mocked(dispatchCommand); @@ -40,16 +43,6 @@ beforeEach(() => { mockDispatchCommand.mockResolvedValue({}); }); -function writeReplayFile(root: string, lines: string[]): string { - const filePath = path.join(root, 'flow.ad'); - fs.writeFileSync(filePath, `${lines.join('\n')}\n`); - return filePath; -} - -function baseReq(overrides: Partial = {}): DaemonRequest { - return { token: 'token', session: 'default', command: 'replay', positionals: [], ...overrides }; -} - const SAVE_ANNOTATION = '# agent-device:target-v1 {"id":"save","role":"button","label":"Save","ancestry":[],"sibling":0,"viewportOrder":0,"verification":"verified"}'; @@ -109,7 +102,7 @@ test('an unannotated action executes unchanged (old-script pass-through)', async const filePath = writeReplayFile(root, ['click id="save"']); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -148,7 +141,7 @@ test('a verified target proceeds to dispatch the action', async () => { }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -174,7 +167,7 @@ test('a verified drag guards both source and destination before dispatch', async mockDispatchCommand.mockResolvedValue({ nodes: DRAG_NODES, backend: 'xctest' }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -206,7 +199,7 @@ test('a shifted drag destination diverges before pointer-down even when the sour }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -237,7 +230,7 @@ test('a destination guard refusal reports destination evidence after both prefli ]); mockDispatchCommand.mockResolvedValue({ nodes: DRAG_NODES, backend: 'xctest' }); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -276,7 +269,7 @@ test('a selector-miss divergence blocks dispatch and never sends the action', as mockDispatchCommand.mockResolvedValue({ nodes: [], truncated: false, backend: 'xctest' }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -322,7 +315,7 @@ test('an identity-mismatch divergence reports matchCount and an observed identit }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -374,7 +367,7 @@ test('a recorded-unverifiable annotation is an identity-unverifiable divergence }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -416,7 +409,7 @@ test('a verified annotated action carries the post-resolution guard on its dispa }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -443,7 +436,7 @@ test('an unannotated action never carries a post-resolution guard', async () => const filePath = writeReplayFile(root, ['click id="save"']); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -480,7 +473,7 @@ test('a dispatch-time guard mismatch converts to an identity-mismatch target-bin }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -549,7 +542,7 @@ test('a same-identity guard mismatch surfaces the structural position difference backend: 'xctest', }); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -604,7 +597,7 @@ test('a target-binding divergence carries a real computed resume (step 5 wiring, ]); mockDispatchCommand.mockResolvedValue({ nodes: [], truncated: false, backend: 'xctest' }); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -698,7 +691,7 @@ test('a transient content-quality capture failure right after launch recovers wi }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -722,7 +715,7 @@ test('a content-quality capture failure that never recovers still fails closed a mockDispatchCommand.mockImplementation(async () => throwContentQualityCaptureFailure()); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -793,7 +786,7 @@ test('an iOS sparse-snapshot verdict right after launch recovers within the boun }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -819,7 +812,7 @@ test('an iOS sparse-snapshot verdict that never recovers still fails closed as i mockDispatchCommand.mockImplementation(async () => iosSparseCapture()); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -855,7 +848,7 @@ test('a permanent (non-content-quality) capture failure fails fast without retry mockDispatchCommand.mockImplementation(async () => throwPermanentCaptureFailure()); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -886,7 +879,7 @@ test('an adb mechanism failure marked retriable at the transport level still fai mockDispatchCommand.mockImplementation(async () => throwAdbMechanismFailureMarkedRetriable()); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -926,7 +919,7 @@ test('an annotated selector wait dispatches with the landmark guard and no eager const filePath = writeReplayFile(root, [WAIT_ANNOTATION, 'wait "label=\\"Screen X\\"" 2000']); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -964,7 +957,7 @@ test('a recorded-unverifiable wait annotation refuses before polling with matchC mockDispatchCommand.mockResolvedValue({ nodes: [], truncated: false, backend: 'xctest' }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -1005,7 +998,7 @@ test("the wait loop's landmark refusal converts into an identity-mismatch diverg }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -1052,7 +1045,7 @@ test('a plain wait timeout on an annotated wait stays an action-failure divergen mockDispatchCommand.mockResolvedValue({ nodes: [], truncated: false, backend: 'xctest' }); - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -1078,7 +1071,7 @@ test('an annotation on a duration wait is inert (no guard, no refusal)', async ( const filePath = writeReplayFile(root, [WAIT_UNVERIFIABLE_ANNOTATION, 'wait 500']); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -1123,7 +1116,7 @@ test('an annotated is step verifies pre-dispatch and threads the post-resolution }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -1162,7 +1155,7 @@ test('an annotated is step whose recorded identity vanished diverges before disp }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptFile({ + const response = await runReplayScriptSource({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), diff --git a/src/daemon/handlers/__tests__/session-replay.test.ts b/src/daemon/handlers/__tests__/session-replay.test.ts index c3c5417040..d439fa8d8d 100644 --- a/src/daemon/handlers/__tests__/session-replay.test.ts +++ b/src/daemon/handlers/__tests__/session-replay.test.ts @@ -10,6 +10,7 @@ import { buildNestedReplayFlags, handleSessionReplayCommands } from '../session- 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, @@ -319,6 +320,7 @@ test('test finalizes replay video exactly once when cancellation arrives after s saveScript: false, force: false, artifactsDir: path.join(root, 'artifacts'), + replayScriptSources: [replayScriptSourceBundleFor(replayPath)], }, meta: { cwd: root, requestId: 'record-video-suite' }, }, diff --git a/src/daemon/handlers/__tests__/session-test-reporter-values-maestro.test.ts b/src/daemon/handlers/__tests__/session-test-reporter-values-maestro.test.ts index 7f4f2c6ea5..5f8fe4e90f 100644 --- a/src/daemon/handlers/__tests__/session-test-reporter-values-maestro.test.ts +++ b/src/daemon/handlers/__tests__/session-test-reporter-values-maestro.test.ts @@ -4,9 +4,9 @@ // Both are needed, and neither substitutes for the other: P3 replaced a request-global // AsyncLocalStorage with an explicit per-attempt `onStep` port, and that port has two // independent forwarding chains. The native chain is -// scheduler sink -> runReplayScriptFile -> executeReplayActions -> onStep?.(...) +// scheduler sink -> runReplayScriptSource -> executeReplayActions -> onStep?.(...) // and the Maestro chain is a different set of links entirely: -// scheduler sink -> runReplayScriptFile -> runTypedMaestroReplayFile +// scheduler sink -> runReplayScriptSource -> runTypedMaestroReplay // -> createMaestroReplayObserver({ onStep }) -> actionStarted -> onStep?.(...) // // An `.ad`-only reporter test stays green if any Maestro link is deleted, so `onTestStep` diff --git a/src/daemon/handlers/__tests__/session-test-source-discovery.test.ts b/src/daemon/handlers/__tests__/session-test-source-discovery.test.ts index f51b5192a1..4c4d3e7a9f 100644 --- a/src/daemon/handlers/__tests__/session-test-source-discovery.test.ts +++ b/src/daemon/handlers/__tests__/session-test-source-discovery.test.ts @@ -1,224 +1,73 @@ -import { test, vi } from 'vitest'; +import { test } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { AppError } from '@agent-device/kernel/errors'; import { buildReplayTestSourceDiscovery } from '../session-test-source-discovery.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; - -// Path expansion, traversal ordering and format routing are host work (#1478 P3b). These pin -// the inspection capability directly; scheduler filtering policy is pinned in the package. -const discoverSources = (replayBackend?: string) => buildReplayTestSourceDiscovery(replayBackend); - -test('replay-test source discovery discovers nested .ad suites through native DFS traversal', () => { - const root = mkdtempForTestSync('agent-device-test-discovery-'); - const nested = path.join(root, 'nested'); - fs.mkdirSync(nested, { recursive: true }); - fs.writeFileSync(path.join(nested, '02-second.ad'), 'context platform=android\nopen "Second"\n'); - fs.writeFileSync(path.join(root, '01-first.ad'), 'context platform=ios\nopen "First"\n'); - - const entries = discoverSources()({ inputs: [root], cwd: root }); - - assert.deepEqual( - new Set(entries.map((entry) => entry.path)), - new Set([path.join(nested, '02-second.ad'), path.join(root, '01-first.ad')]), - ); -}); - -test('replay-test source discovery includes Maestro yaml flows for Maestro test suites', () => { - const root = mkdtempForTestSync('agent-device-test-discovery-maestro-'); - fs.writeFileSync( - path.join(root, '01-flow.yaml'), - 'appId: demo\nname: Bottom Tabs - Dynamic\n---\n- launchApp\n', - ); - fs.writeFileSync(path.join(root, '02-flow.yml'), 'appId: demo\n---\n- launchApp\n'); - fs.writeFileSync(path.join(root, '03-flow.ad'), 'open "Demo"\n'); - - const entries = discoverSources('maestro')({ inputs: [root], cwd: root }); - - assert.deepEqual( - new Set(entries.map((entry) => path.basename(entry.path))), - new Set(['01-flow.yaml', '02-flow.yml', '03-flow.ad']), - ); - // Maestro sources take their platform from the invocation; a native source in Maestro mode - // is still native and declares none. What a --platform filter then does with each is pinned - // in the package's policy tests. - const platformKind = (basename: string) => - entries.find((entry) => path.basename(entry.path) === basename)?.manifest.device.platform.kind; - assert.equal(platformKind('01-flow.yaml'), 'caller-bound'); - assert.equal(platformKind('02-flow.yml'), 'caller-bound'); - assert.equal(platformKind('03-flow.ad'), 'unspecified'); - const namedFlow = entries.find((entry) => path.basename(entry.path) === '01-flow.yaml'); - assert.equal(namedFlow?.manifest.title, 'Bottom Tabs - Dynamic'); +import { replayScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; + +// #1478 P3b: the daemon adapter's inspection capability. #1802 moved expansion and file reading +// to the caller, so what is left here is exactly format routing plus per-engine manifest +// inspection over the script sources the request carried — traversal order is pinned beside the +// caller-side discovery, scheduler filtering policy in the replay-test package. +function inspect(files: string[], replayBackend?: string) { + return buildReplayTestSourceDiscovery( + files.map((filePath) => replayScriptSourceBundleFor(filePath, { replayBackend })), + replayBackend, + )(); +} + +test('replay-test source inspection tags a Maestro flow caller-bound and carries its name', () => { + const root = mkdtempForTestSync('agent-device-test-inspect-maestro-'); + const flowPath = path.join(root, '01-flow.yaml'); + fs.writeFileSync(flowPath, 'appId: demo\nname: Bottom Tabs - Dynamic\n---\n- launchApp\n'); + + const [entry] = inspect([flowPath], 'maestro'); + + assert.equal(entry?.path, flowPath); + assert.equal(entry?.manifest.device.platform.kind, 'caller-bound'); + assert.equal(entry?.manifest.title, 'Bottom Tabs - Dynamic'); }); -test('replay-test source discovery preserves Maestro directory filesystem order', () => { - const root = mkdtempForTestSync('agent-device-test-discovery-maestro-sort-'); - const flowFiles = ['10-legacy.ad', '30-zeta.yaml', '05-compat.ad', '20-beta.yml']; - for (const fileName of flowFiles) { - const body = fileName.endsWith('.ad') ? 'open "Demo"\n' : 'appId: demo\n---\n- launchApp\n'; - fs.writeFileSync(path.join(root, fileName), body); - } - - const opendirSync = vi.spyOn(fs, 'opendirSync').mockImplementation((directory) => { - assert.equal(directory, root); - let index = 0; - return { - readSync: () => { - const name = flowFiles[index++]; - if (!name) return null; - return { - name, - isDirectory: () => false, - isFile: () => true, - } as fs.Dirent; - }, - closeSync: () => {}, - } as fs.Dir; - }); - - try { - const entries = discoverSources('maestro')({ inputs: [root], cwd: root }); - - assert.deepEqual( - entries.map((entry) => path.basename(entry.path)), - ['10-legacy.ad', '30-zeta.yaml', '05-compat.ad', '20-beta.yml'], - ); - } finally { - opendirSync.mockRestore(); - } -}); - -test('replay-test source discovery preserves Maestro nested directory DFS order', () => { - const root = mkdtempForTestSync('agent-device-test-discovery-maestro-dfs-'); - const nested = path.join(root, 'nested'); - fs.mkdirSync(nested, { recursive: true }); - fs.writeFileSync(path.join(root, '30-root-a.yaml'), 'appId: demo\n---\n- launchApp\n'); - fs.writeFileSync(path.join(nested, '10-child.yml'), 'appId: demo\n---\n- launchApp\n'); - fs.writeFileSync(path.join(root, '20-root-c.ad'), 'open "Demo"\n'); - - type MockDirEntry = { name: string; directory: boolean }; - const opendirSync = vi.spyOn(fs, 'opendirSync').mockImplementation((directory) => { - let entries: MockDirEntry[] = []; - if (directory === root) { - entries = [ - { name: '30-root-a.yaml', directory: false }, - { name: 'nested', directory: true }, - { name: '20-root-c.ad', directory: false }, - ]; - } else if (directory === nested) { - entries = [{ name: '10-child.yml', directory: false }]; - } - let index = 0; - return { - readSync: () => { - const entry = entries[index++]; - if (!entry) return null; - return { - name: entry.name, - isDirectory: () => entry.directory, - isFile: () => !entry.directory, - } as fs.Dirent; - }, - closeSync: () => {}, - } as fs.Dir; - }); +test('replay-test source inspection leaves a native .ad without metadata unspecified', () => { + const root = mkdtempForTestSync('agent-device-test-inspect-native-'); + const scriptPath = path.join(root, '03-flow.ad'); + fs.writeFileSync(scriptPath, 'open "Demo"\n'); - try { - const entries = discoverSources('maestro')({ inputs: [root], cwd: root }); + // Maestro mode routes by extension: a native source in a Maestro suite is still native, so it + // declares no platform rather than inheriting the invocation's. + const [entry] = inspect([scriptPath], 'maestro'); - assert.deepEqual( - entries.map((entry) => path.relative(root, entry.path)), - ['30-root-a.yaml', path.join('nested', '10-child.yml'), '20-root-c.ad'], - ); - } finally { - opendirSync.mockRestore(); - } + assert.equal(entry?.manifest.device.platform.kind, 'unspecified'); + assert.equal(entry?.manifest.title, undefined); }); -test('replay-test source discovery preserves explicit Maestro file order', () => { - const root = mkdtempForTestSync('agent-device-test-discovery-maestro-order-'); - const second = path.join(root, '02-second.yaml'); - const first = path.join(root, '01-first.yaml'); - fs.writeFileSync(first, 'appId: demo\n---\n- launchApp\n'); - fs.writeFileSync(second, 'appId: demo\n---\n- launchApp\n'); - - const entries = discoverSources('maestro')({ inputs: [second, first], cwd: root }); - - assert.deepEqual( - entries.map((entry) => path.basename(entry.path)), - ['02-second.yaml', '01-first.yaml'], +test('replay-test source inspection reads declared platform, target and attempt defaults', () => { + const root = mkdtempForTestSync('agent-device-test-inspect-metadata-'); + const scriptPath = path.join(root, 'flow.ad'); + fs.writeFileSync( + scriptPath, + ['context platform=ios target=mobile timeout=4000 retries=2', 'open "Demo"', ''].join('\n'), ); -}); -test('replay-test source discovery orders Maestro file inputs before expanded flows', () => { - const root = mkdtempForTestSync('agent-device-test-discovery-maestro-files-'); - const suite = path.join(root, 'suite'); - const globSuite = path.join(root, 'glob-suite'); - fs.mkdirSync(suite); - fs.mkdirSync(globSuite); - const explicit = path.join(root, '99-explicit.yaml'); - fs.writeFileSync(explicit, 'appId: demo\n---\n- launchApp\n'); - fs.writeFileSync(path.join(suite, '01-directory.yaml'), 'appId: demo\n---\n- launchApp\n'); - fs.writeFileSync(path.join(globSuite, '02-glob.yaml'), 'appId: demo\n---\n- launchApp\n'); + const [entry] = inspect([scriptPath]); - const entries = discoverSources('maestro')({ - inputs: [suite, path.join(globSuite, '*.yaml'), explicit], - cwd: root, + assert.deepEqual(entry?.manifest.device, { + platform: { kind: 'declared', value: 'ios' }, + target: 'mobile', }); - - assert.deepEqual( - entries.map((entry) => path.basename(entry.path)), - ['99-explicit.yaml', '01-directory.yaml', '02-glob.yaml'], - ); + assert.deepEqual(entry?.manifest.attemptDefaults, { timeoutMs: 4000, retries: 2 }); }); -test('replay-test source discovery de-duplicates overlapping Maestro file and glob inputs', () => { - const root = mkdtempForTestSync('agent-device-test-discovery-overlap-'); - const explicit = path.join(root, '02-explicit.yaml'); - fs.writeFileSync(explicit, 'appId: demo\n---\n- launchApp\n'); - fs.writeFileSync(path.join(root, '01-expanded.yaml'), 'appId: demo\n---\n- launchApp\n'); - - const entries = discoverSources('maestro')({ - inputs: [explicit, path.join(root, '*.yaml')], - cwd: root, - }); +test('replay-test source inspection keeps the order the caller discovered', () => { + const root = mkdtempForTestSync('agent-device-test-inspect-order-'); + const second = path.join(root, '02-second.ad'); + const first = path.join(root, '01-first.ad'); + fs.writeFileSync(first, 'open "First"\n'); + fs.writeFileSync(second, 'open "Second"\n'); assert.deepEqual( - entries.map((entry) => path.basename(entry.path)), - ['02-explicit.yaml', '01-expanded.yaml'], - ); -}); - -test('replay-test source discovery sorts mixed Maestro glob matches by YAML-first compatibility order', () => { - const root = mkdtempForTestSync('agent-device-test-discovery-mixed-glob-'); - fs.writeFileSync(path.join(root, '20-zeta.yaml'), 'appId: demo\n---\n- launchApp\n'); - fs.writeFileSync(path.join(root, '10-alpha.yml'), 'appId: demo\n---\n- launchApp\n'); - fs.writeFileSync(path.join(root, '00-native.ad'), 'open "Demo"\n'); - fs.writeFileSync(path.join(root, '30-native.ad'), 'open "Demo"\n'); - - const entries = discoverSources('maestro')({ - inputs: [path.join(root, '*.{yaml,yml,ad}')], - cwd: root, - }); - - assert.deepEqual( - entries.map((entry) => path.basename(entry.path)), - ['10-alpha.yml', '20-zeta.yaml', '00-native.ad', '30-native.ad'], - ); -}); - -test('replay-test source discovery rejects YAML without explicit Maestro routing', () => { - const root = mkdtempForTestSync('agent-device-test-discovery-yaml-route-'); - const flowPath = path.join(root, 'flow.yaml'); - fs.writeFileSync(flowPath, 'appId: demo\n---\n- launchApp\n'); - - assert.throws( - () => discoverSources()({ inputs: [flowPath], cwd: root }), - (error: unknown) => - error instanceof AppError && - error.code === 'INVALID_ARGS' && - error.message === - `Maestro YAML requires explicit --maestro routing: test ${flowPath} --maestro`, + inspect([second, first]).map((entry) => path.basename(entry.path)), + ['02-second.ad', '01-first.ad'], ); }); diff --git a/src/daemon/handlers/session-replay-maestro-response.ts b/src/daemon/handlers/session-replay-maestro-response.ts index 7f22130a16..5ad0a89cdc 100644 --- a/src/daemon/handlers/session-replay-maestro-response.ts +++ b/src/daemon/handlers/session-replay-maestro-response.ts @@ -3,7 +3,7 @@ import type { MaestroExecutionOutcome } from '@agent-device/maestro'; import { normalizeError } from '@agent-device/kernel/errors'; import { summarizeSnapshotTimingSamples } from '@agent-device/contracts/capture'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; -import { SessionStore } from '../session-store.ts'; +import type { SessionStore } from '../session-store.ts'; import { buildTypedMaestroFailureResponse } from './session-replay-maestro-failure.ts'; import { errorResponse } from './response.ts'; @@ -34,7 +34,8 @@ export function buildTypedMaestroSuccessResponse(params: { export async function buildTypedMaestroReplayErrorResponse(params: { req: DaemonRequest; - requestedPath: string; + /** The entry flow's caller-resolved display path (the replay script source bundle's entry). */ + replayPath: string; state: { snapshotStart: number; }; @@ -49,7 +50,7 @@ export async function buildTypedMaestroReplayErrorResponse(params: { return await buildTypedMaestroFailureResponse({ error: normalizedError, failure, - replayPath: SessionStore.expandHome(params.requestedPath, params.req.meta?.cwd), + replayPath: params.replayPath, req: params.req, sessionName: params.sessionName, sessionStore: params.sessionStore, diff --git a/src/daemon/handlers/session-replay-maestro-runtime.ts b/src/daemon/handlers/session-replay-maestro-runtime.ts index 74ce71d051..ec0b49ecde 100644 --- a/src/daemon/handlers/session-replay-maestro-runtime.ts +++ b/src/daemon/handlers/session-replay-maestro-runtime.ts @@ -1,5 +1,4 @@ import type { CommandFlags } from '@agent-device/contracts/command'; -import fs from 'node:fs'; import { setTimeout as sleep } from 'node:timers/promises'; import { executeMaestroFlow, @@ -33,6 +32,11 @@ import { import { resolveEffectiveOpenRuntimeHints } from './session-runtime.ts'; import { contextFromFlags } from '../context.ts'; import { buildMaestroReplayTargetDeviceResolutionOptions } from '../replay-device-selection.ts'; +import { + readReplayScriptSourceFile, + REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE, +} from '../../replay/script-source-bundle.ts'; +import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; type TypedMaestroReplayParams = { req: DaemonRequest; @@ -65,12 +69,12 @@ type MaestroReplayBinding = Pick< 'device' | 'platform' | 'target' | 'runtimeHints' >; -export async function runTypedMaestroReplayFile( +export async function runTypedMaestroReplay( params: TypedMaestroReplayParams, ): Promise { const { req } = params; - const requestedPath = req.positionals?.[0]; - if (!requestedPath) return errorResponse('INVALID_ARGS', 'replay requires a path'); + const bundle = req.flags?.replayScriptSource; + if (!bundle) return errorResponse('INVALID_ARGS', REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE); if (req.flags?.saveScript !== undefined) { return errorResponse( 'INVALID_ARGS', @@ -82,14 +86,14 @@ export async function runTypedMaestroReplayFile( try { return await executeTypedMaestroReplay({ ...params, - requestedPath, + bundle, startedAt, state, }); } catch (error) { return await buildTypedMaestroReplayErrorResponse({ ...params, - requestedPath, + replayPath: bundle.entry, state, outcome: { ok: false, error }, }); @@ -98,7 +102,7 @@ export async function runTypedMaestroReplayFile( async function executeTypedMaestroReplay( params: TypedMaestroReplayParams & { - requestedPath: string; + bundle: ReplayScriptSourceBundle; startedAt: number; state: TypedMaestroReplayState; }, @@ -126,6 +130,9 @@ async function executeTypedMaestroReplay( signal: context.signal, from: req.flags?.replayFrom, planDigest: req.flags?.replayPlanDigest, + // #1802: `runFlow` includes resolve out of the caller's bundle, so a local + // and a remote run compile the same flow closure. + readSource: (includePath) => readReplayScriptSourceFile(params.bundle, includePath), observer: createMaestroReplayObserver({ filePath: context.filePath, tracePath, @@ -135,7 +142,7 @@ async function executeTypedMaestroReplay( if (!outcome.ok) { return await buildTypedMaestroReplayErrorResponse({ ...params, - requestedPath: params.requestedPath, + replayPath: params.bundle.entry, state, outcome, }); @@ -150,11 +157,11 @@ async function executeTypedMaestroReplay( } async function prepareTypedMaestroReplay( - params: TypedMaestroReplayParams & { requestedPath: string }, + params: TypedMaestroReplayParams & { bundle: ReplayScriptSourceBundle }, ): Promise { - const { req, requestedPath, sessionName, sessionStore } = params; - const filePath = SessionStore.expandHome(requestedPath, req.meta?.cwd); - const flow = inspectMaestroFlow(fs.readFileSync(filePath, 'utf8'), filePath); + const { req, bundle, sessionName, sessionStore } = params; + const filePath = bundle.entry; + const flow = inspectMaestroFlow(readReplayScriptSourceFile(bundle, filePath), filePath); const session = sessionStore.get(sessionName); if (session) assertSessionSelectorMatches(session, req.flags); const binding = await resolveMaestroReplayBinding({ diff --git a/src/daemon/handlers/session-replay-resume.ts b/src/daemon/handlers/session-replay-resume.ts index 0dae79333d..5cee032d16 100644 --- a/src/daemon/handlers/session-replay-resume.ts +++ b/src/daemon/handlers/session-replay-resume.ts @@ -50,7 +50,7 @@ export function buildAndPersistReplayDivergenceResume(params: { * `from` is at most `actions.length + 1` — never further out of range. That * boundary case (`record-and-heal` diverged on the plan's LAST step) is a * legal EMPTY-TAIL resume, not an error: the runtime loop - * (`runReplayScriptFile`) executes zero steps and reaches the normal + * (`runReplayScriptSource`) executes zero steps and reaches the normal * end-of-plan completion path, correctly flipping a repair transaction * COMPLETE. Rejecting it would send the agent to `close` instead — * which discards the just-recorded corrective action, since commit is gated diff --git a/src/daemon/handlers/session-replay-runtime-engine-adapter.ts b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts index c5b5acdf47..931b791c60 100644 --- a/src/daemon/handlers/session-replay-runtime-engine-adapter.ts +++ b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts @@ -32,7 +32,7 @@ import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; /** * #1555 P5 (decomposition): the daemon's `AdReplayStepRuntime` adapter — extracted verbatim out * of `session-replay-runtime.ts`, which now only constructs a `ReplayStepContext` and calls - * `createAdReplayStepRuntime`. See that file's `runReplayScriptFile` for the request-level + * `createAdReplayStepRuntime`. See that file's `runReplayScriptSource` for the request-level * orchestration this adapter plugs into. * * The wire-narrowing concern (guard threading, `details` bag -> typed @@ -63,7 +63,7 @@ import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; * can end a step (`dispatchStep`, the three `build*Failure` capabilities, and * `handleActionFailure`) records the wire response it just built here before * projecting it down to the neutral `AdReplayStepOutcome`/`AdReplayStepFailure` - * the engine actually sees; `readLastResponse` lets `runReplayScriptFile` + * the engine actually sees; `readLastResponse` lets `runReplayScriptSource` * recover the exact final response once `runAdReplay` reports which step * failed, so the client-visible wire output never changes even though the * engine itself never touches it. @@ -91,11 +91,11 @@ export function createAdReplayStepRuntime(params: { ctx: ReplayStepContext; req: DaemonRequest; /** - * The run's ONE artifact ledger, owned by `runReplayScriptFile`. `dispatchStep` + * The run's ONE artifact ledger, owned by `runReplayScriptSource`. `dispatchStep` * is its only writer, and returns its contents for the engine to thread as a * plain value — the engine keeps no accumulator of its own (#1478 P5 * follow-up; see `@agent-device/ad-replay`'s `step-loop.ts` header). Also what - * `runReplayScriptFile`'s catch block reports, so a mid-loop throw still names + * `runReplayScriptSource`'s catch block reports, so a mid-loop throw still names * the artifacts collected up to that point. */ artifactPaths: Set; diff --git a/src/daemon/handlers/session-replay-runtime-plan.ts b/src/daemon/handlers/session-replay-runtime-plan.ts index 990a226d70..da8fe8314e 100644 --- a/src/daemon/handlers/session-replay-runtime-plan.ts +++ b/src/daemon/handlers/session-replay-runtime-plan.ts @@ -19,11 +19,11 @@ import { } from '@agent-device/ad-script'; import { resolveReplayFormat } from '../../replay/format.ts'; import { buildReplayBuiltinVars } from './session-replay-vars.ts'; -import { runTypedMaestroReplayFile } from './session-replay-maestro-runtime.ts'; +import { runTypedMaestroReplay } from './session-replay-maestro-runtime.ts'; import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; /** - * #1555 P5 (decomposition): `runReplayScriptFile`'s (`session-replay-runtime.ts`) plan-side + * #1555 P5 (decomposition): `runReplayScriptSource`'s (`session-replay-runtime.ts`) plan-side * helpers — everything that inspects the script, resolves its `--from`/`--plan-digest` entry * point, and routes a Maestro-format request, before any session-mutating work begins. Extracted * verbatim; `buildReplayMetadataFlags` (below) was already here from the #1555 review pass — see @@ -31,13 +31,13 @@ import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; */ /** - * `runReplayScriptFile`'s own parameter shape, named here (rather than derived at the call site - * via `Parameters`) so `routeMaestroReplay` below can reference it + * `runReplayScriptSource`'s own parameter shape, named here (rather than derived at the call site + * via `Parameters`) so `routeMaestroReplay` below can reference it * without importing back from `session-replay-runtime.ts` — that direction would be a cycle now * that the Maestro routing decision lives in this module instead of alongside the function it * routes for. `session-replay-runtime.ts` imports this type instead of restating the params. */ -export type ReplayScriptFileParams = { +export type ReplayScriptSourceRunParams = { req: DaemonRequest; sessionName: string; logPath: string; @@ -56,8 +56,8 @@ export type ReplayScriptFileParams = { * Routes a Maestro-format request to the typed Maestro engine, rejecting * `--keep-session` (native-`.ad`-only lifecycle) and an active `.ad` * `--save-script` repair boundary first. Returns `undefined` for a non-Maestro - * request so `runReplayScriptFile` continues down the native `.ad` path — - * extracted from `runReplayScriptFile` itself (fallow complexity) rather than + * request so `runReplayScriptSource` continues down the native `.ad` path — + * extracted from `runReplayScriptSource` itself (fallow complexity) rather than * split further, since every branch here is this one routing decision. */ export async function routeMaestroReplay(params: { @@ -65,7 +65,7 @@ export async function routeMaestroReplay(params: { req: DaemonRequest; keepSession: boolean; coordinator: ReplayCoordinator; - maestroParams: ReplayScriptFileParams; + maestroParams: ReplayScriptSourceRunParams; }): Promise { const { resolved, req, keepSession, coordinator, maestroParams } = params; if (resolveReplayFormat(resolved, req.flags?.replayBackend) !== 'maestro') return undefined; @@ -81,7 +81,7 @@ export async function routeMaestroReplay(params: { 'This session has an active .ad --save-script repair run; finish it with replay --from or close before running Maestro YAML.', ); } - return await runTypedMaestroReplayFile(maestroParams); + return await runTypedMaestroReplay(maestroParams); } export type PreparedReplayPlan = { @@ -108,13 +108,15 @@ export function prepareReplayPlan(params: { sessionStore: SessionStore; tracePath: string | undefined; resolved: string; + /** The entry script's text, taken from the request's replay script source bundle (#1802). */ + script: string; coordinator: ReplayCoordinator; }): { ok: true; value: PreparedReplayPlan } | { ok: false; response: DaemonResponse } { - const { req, sessionName, sessionStore, tracePath, resolved, coordinator } = params; + const { req, sessionName, sessionStore, tracePath, resolved, script, coordinator } = params; const backendRejection = validateReplayBackendFlag(req); if (backendRejection) return { ok: false, response: backendRejection }; - const { manifest, replayReq } = inspectReplayPlanManifest(req, resolved); + const { manifest, replayReq } = inspectReplayPlanManifest(req, script); const { metadata, actions, actionLines, actionSourcePaths, planDigest } = manifest; const preEntrySession = sessionStore.get(sessionName); const entryIndexResult = resolveReplayPlanEntryIndex({ @@ -154,8 +156,8 @@ export function prepareReplayPlan(params: { * `inspectAdReplay` that reaches this point with a non-Maestro request) * matches `src/compat/replay-input.ts`'s `parseReplayInput` exactly, byte for * byte, before any plan/session work begins. `replayBackend: 'maestro'` still - * passes here because `runReplayScriptFile` has already routed a real - * Maestro-format request to `runTypedMaestroReplayFile` above; only a + * passes here because `runReplayScriptSource` has already routed a real + * Maestro-format request to `runTypedMaestroReplay` above; only a * stray/unknown value reaches this branch. */ function validateReplayBackendFlag(req: DaemonRequest): DaemonResponse | undefined { @@ -177,9 +179,9 @@ function validateReplayBackendFlag(req: DaemonRequest): DaemonResponse | undefin */ function inspectReplayPlanManifest( req: DaemonRequest, - resolved: string, + script: string, ): { manifest: AdReplayManifest; replayReq: DaemonRequest } { - const manifest = inspectAdReplay(resolved, { + const manifest = inspectAdReplay(script, { platform: req.flags?.platform, target: req.flags?.target, }); @@ -255,7 +257,7 @@ function buildPreparedReplayVarSources(params: { * resume math as a `resolveEntryIndex` closure, both computed from the SAME * effective platform/target precedence this file used to apply itself. Only * `buildReplayMetadataFlags` stays here: it builds the REQUEST's flags (used - * throughout `runReplayScriptFile`, not just for the digest), which is a + * throughout `runReplayScriptSource`, not just for the digest), which is a * daemon/wire concern the manifest has no reason to own. * * Module-private as of the #1555 P5 decomposition: its one caller, diff --git a/src/daemon/handlers/session-replay-runtime-session.ts b/src/daemon/handlers/session-replay-runtime-session.ts index 1b643157ad..5e8876edd5 100644 --- a/src/daemon/handlers/session-replay-runtime-session.ts +++ b/src/daemon/handlers/session-replay-runtime-session.ts @@ -12,7 +12,7 @@ import { import { healedScriptSiblingPath, type ReplayCoordinator } from '../session-replay-coordinator.ts'; /** - * #1555 P5 (decomposition): `runReplayScriptFile`'s (`session-replay-runtime.ts`) session + * #1555 P5 (decomposition): `runReplayScriptSource`'s (`session-replay-runtime.ts`) session * preparation — the repair-preflight/resume-consumption/save-script-arming work that runs after * `prepareReplayPlan` (`session-replay-runtime-plan.ts`) accepts a plan but before the engine step * loop dispatches step 1. Extracted verbatim. `prepareReplaySession` is the one entry point; diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index b53455a1fa..425e4d1e5f 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -1,6 +1,6 @@ import { asAppError } from '@agent-device/kernel/errors'; import type { DaemonResponse, SessionState } from '../types.ts'; -import { SessionStore } from '../session-store.ts'; +import type { SessionStore } from '../session-store.ts'; import { errorResponse } from './response.ts'; import { runAdReplay } from '@agent-device/ad-replay'; import type { SnapshotTimingSample } from '@agent-device/contracts/capture'; @@ -16,8 +16,12 @@ import { import { prepareReplayPlan, routeMaestroReplay, - type ReplayScriptFileParams, + type ReplayScriptSourceRunParams, } from './session-replay-runtime-plan.ts'; +import { + readReplayScriptSourceFile, + REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE, +} from '../../replay/script-source-bundle.ts'; import { prepareReplaySession } from './session-replay-runtime-session.ts'; /** @@ -33,7 +37,7 @@ import { prepareReplaySession } from './session-replay-runtime-session.ts'; * - the `AdReplayStepRuntime` engine adapter (`createAdReplayStepRuntime`, its `build*Failure` * capability implementations, and the `lastResponse`/`lastObservation` side-map mechanics) * lives in `session-replay-runtime-engine-adapter.ts`. - * This file is what remains: the one place `runReplayScriptFile` composes them, and the run's + * This file is what remains: the one place `runReplayScriptSource` composes them, and the run's * completion (`completeReplayRun`/`requireLiveSessionForKeepSession`), which runs after the engine * loop returns and never touches the step runtime itself. * @@ -43,11 +47,21 @@ import { prepareReplaySession } from './session-replay-runtime-session.ts'; * constructing its own. */ -export async function runReplayScriptFile(params: ReplayScriptFileParams): Promise { +/** + * #1802: the run's script text arrives IN THE REQUEST (a replay script source + * bundle the caller read and resolved), never as a path this process opens. + * That is why this is `runReplayScriptSource` and no longer the old + * `…ScriptFile`: a remote daemon shares no filesystem with the caller, + * so a handler that opened `req.positionals[0]` could only ever work when the + * two happened to be the same host. + */ +export async function runReplayScriptSource( + params: ReplayScriptSourceRunParams, +): Promise { const { req, sessionName, logPath, sessionStore, tracePath, onStep, invoke } = params; - const filePath = req.positionals?.[0]; - if (!filePath) { - return errorResponse('INVALID_ARGS', 'replay requires a path'); + const bundle = req.flags?.replayScriptSource; + if (!bundle) { + return errorResponse('INVALID_ARGS', REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE); } const startedAt = Date.now(); @@ -63,9 +77,9 @@ export async function runReplayScriptFile(params: ReplayScriptFileParams): Promi // transaction and resume watermark through. const coordinator = createReplayCoordinator({ sessionStore, sessionName }); try { - resolved = SessionStore.expandHome(filePath, req.meta?.cwd); + resolved = bundle.entry; if (isMaestroYamlPath(resolved) && req.flags?.replayBackend !== 'maestro') { - return errorResponse('INVALID_ARGS', maestroBackendRequiredMessage('replay', filePath)); + return errorResponse('INVALID_ARGS', maestroBackendRequiredMessage('replay', resolved)); } const maestroResponse = await routeMaestroReplay({ resolved, @@ -81,6 +95,7 @@ export async function runReplayScriptFile(params: ReplayScriptFileParams): Promi sessionStore, tracePath, resolved, + script: readReplayScriptSourceFile(bundle, resolved), coordinator, }); if (!planPreparation.ok) return planPreparation.response; diff --git a/src/daemon/handlers/session-replay.ts b/src/daemon/handlers/session-replay.ts index eaf12b6a5e..95408b7e06 100644 --- a/src/daemon/handlers/session-replay.ts +++ b/src/daemon/handlers/session-replay.ts @@ -1,10 +1,12 @@ 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 { runReplayScriptFile } from './session-replay-runtime.ts'; +import { runReplayScriptSource } from './session-replay-runtime.ts'; import { collectReplayActionArtifactPaths } from './session-replay-runtime-artifacts.ts'; import { errorResponse } from './response.ts'; import { asAppError } from '@agent-device/kernel/errors'; @@ -94,14 +96,17 @@ export function buildNestedReplayFlags(params: { 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 } = params; + const { platform, target, artifactsDir, shard, sourceBundle } = params; const parentFlags = stripReplayTestHarnessFlags(params.parentFlags); if ( platform === undefined && target === undefined && artifactsDir === undefined && - shard === undefined + shard === undefined && + sourceBundle === undefined ) { return parentFlags; } @@ -111,15 +116,23 @@ export function buildNestedReplayFlags(params: { ...(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?.recordVideo !== true) return flags; + 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; } @@ -141,7 +154,7 @@ export async function handleSessionReplayCommands(params: { const { req, sessionName, logPath, sessionStore, leaseRegistry, invoke } = params; if (req.command === 'replay') { - return await runReplayScriptFile({ + return await runReplayScriptSource({ req, sessionName, logPath, @@ -177,6 +190,14 @@ export async function handleSessionReplayCommands(params: { const appErr = asAppError(err); return errorResponse(appErr.code, appErr.message); } + // #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. + const sourceBundles = req.flags?.replayScriptSources; + if (!sourceBundles) { + return errorResponse('INVALID_ARGS', REPLAY_SCRIPT_SOURCE_REQUIRED_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 @@ -210,6 +231,7 @@ export async function handleSessionReplayCommands(params: { target, artifactsDir, shard, + sourceBundle: sourceBundlesByPath.get(filePath), }); const videoRecordingParams = replayVideoRuntime @@ -225,7 +247,7 @@ export async function handleSessionReplayCommands(params: { const openLifecycle = videoRecordingParams ? buildReplayTestVideoOpenLifecycle(videoRecordingParams) : undefined; - const replayResponse = await runReplayScriptFile({ + const replayResponse = await runReplayScriptSource({ req: { ...req, command: 'replay', @@ -280,7 +302,7 @@ export async function handleSessionReplayCommands(params: { }), ); }, - discoverSources: buildReplayTestSourceDiscovery(req.flags?.replayBackend), + discoverSources: buildReplayTestSourceDiscovery(sourceBundles, req.flags?.replayBackend), resolveShardTargets: buildReplayTestShardTargetResolver(req.flags), cleanupSession: async (testSessionName) => { if (!sessionStore.get(testSessionName)) return; diff --git a/src/daemon/handlers/session-test-source-discovery.ts b/src/daemon/handlers/session-test-source-discovery.ts index 05d80ae905..f6c243ad7e 100644 --- a/src/daemon/handlers/session-test-source-discovery.ts +++ b/src/daemon/handlers/session-test-source-discovery.ts @@ -1,8 +1,8 @@ -import fs from 'node:fs'; import { inspectMaestroFlow } from '@agent-device/maestro'; +import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; import { resolveReplayFormat } from '../../replay/format.ts'; import { readReplayScriptMetadata } from '@agent-device/ad-script'; -import { discoverReplaySourcePaths } from '../replay-source-discovery.ts'; +import { readReplayScriptSourceFile } from '../../replay/script-source-bundle.ts'; import type { ReplayTestDiscoverSources, ReplayTestManifest, @@ -12,28 +12,31 @@ import type { /** * The daemon adapter's source-inspection capability (#1478 P3b). * - * Path expansion, file reading, format routing, and per-engine inspection live here; the - * scheduler receives neutral manifests and keeps discovery policy. + * Format routing and per-engine inspection live here; the scheduler receives neutral manifests + * and keeps discovery policy. + * + * #1802: path expansion and file reading are no longer part of this. `test ` names + * files on the CALLER's filesystem, so the caller expands its inputs and ships one replay script + * source bundle per discovered source; this capability inspects those bundles in the order they + * arrived and opens nothing. * * This is the one place that knows a source can be `.ad` or Maestro. That knowledge converts * into the manifest's platform tag and then disappears: `caller-bound` is what Maestro looks * like from the scheduler's side, and nothing downstream can recover the format from it. */ export function buildReplayTestSourceDiscovery( + sources: readonly ReplayScriptSourceBundle[], replayBackend: string | undefined, ): ReplayTestDiscoverSources { - return ({ inputs, cwd }) => { - const resolvedCwd = cwd ?? process.cwd(); - const filePaths = discoverReplaySourcePaths({ inputs, cwd: resolvedCwd, replayBackend }); - return filePaths.map((filePath) => inspectReplayTestSource(filePath, replayBackend)); - }; + return () => sources.map((bundle) => inspectReplayTestSource(bundle, replayBackend)); } function inspectReplayTestSource( - filePath: string, + bundle: ReplayScriptSourceBundle, replayBackend: string | undefined, ): ReplayTestSource { - const script = fs.readFileSync(filePath, 'utf8'); + const filePath = bundle.entry; + const script = readReplayScriptSourceFile(bundle, filePath); const isMaestro = resolveReplayFormat(filePath, replayBackend) === 'maestro'; const metadata = readReplayScriptMetadata(script); const manifest: ReplayTestManifest = { diff --git a/src/daemon/replay-device-selection.ts b/src/daemon/replay-device-selection.ts index 2af8021424..185f46936b 100644 --- a/src/daemon/replay-device-selection.ts +++ b/src/daemon/replay-device-selection.ts @@ -1,13 +1,12 @@ import type { SessionAction } from '@agent-device/contracts/session'; -import fs from 'node:fs'; import { inspectMaestroFlow } from '@agent-device/maestro'; import { resolveDeclaredScriptPlatform } from '@agent-device/ad-script'; import { parseReplayInput } from '../compat/replay-input.ts'; import type { ResolveTargetDeviceOptions } from '../core/dispatch-resolve.ts'; import { isDeepLinkTarget, type CommandFlags } from '@agent-device/contracts/command'; import { resolveReplayFormat } from '../replay/format.ts'; +import { readReplayScriptSourceFile } from '../replay/script-source-bundle.ts'; import { appleSimulatorAppTargetForOpenTarget } from './open-device-selection.ts'; -import { SessionStore } from './session-store.ts'; import type { DaemonRequest } from './types.ts'; export type ReplayTargetDeviceResolution = { @@ -18,17 +17,22 @@ export type ReplayTargetDeviceResolution = { /** * Finds a static first app target for fresh replay binding. Request binding * must leave a first deep-link or dynamic open to normal device resolution. + * + * #1802: reads the request's replay script source bundle, never the + * filesystem. Before that this opened the caller's path here too, so against a + * remote daemon it silently no-opped (the read threw and the catch below + * swallowed it) and every remote replay lost its pre-binding. */ export function buildReplayTargetDeviceResolution( req: DaemonRequest, ): ReplayTargetDeviceResolution | undefined { if (req.command !== 'replay' || req.flags?.replayFrom !== undefined) return undefined; - const filePath = req.positionals?.[0]; - if (!filePath) return undefined; + const bundle = req.flags?.replayScriptSource; + if (!bundle) return undefined; try { - const resolved = SessionStore.expandHome(filePath, req.meta?.cwd); - const source = fs.readFileSync(resolved, 'utf8'); + const resolved = bundle.entry; + const source = readReplayScriptSourceFile(bundle, resolved); if (resolveReplayFormat(resolved, req.flags?.replayBackend) === 'maestro') { const flow = inspectMaestroFlow(source, resolved); return { diff --git a/src/daemon/session-replay-coordinator.ts b/src/daemon/session-replay-coordinator.ts index 7d7708a45c..a94a2fea3e 100644 --- a/src/daemon/session-replay-coordinator.ts +++ b/src/daemon/session-replay-coordinator.ts @@ -43,7 +43,7 @@ export type ReplaySessionView = Readonly<{ /** * The one capability the divergence-report chain (`session-replay-resume.ts`, * `session-replay-divergence.ts`, `session-replay-target-verification.ts`) needs from a - * `ReplayCoordinator`, bound to the SAME instance the owning `runReplayScriptFile` call + * `ReplayCoordinator`, bound to the SAME instance the owning `runReplayScriptSource` call * created — never a second construction from a bare session name. Carries no `SessionStore` * and cannot name or reacquire a different session. */ diff --git a/src/remote/__tests__/daemon-artifacts-save-script.test.ts b/src/remote/__tests__/daemon-artifacts-save-script.test.ts new file mode 100644 index 0000000000..bfc95b65a1 --- /dev/null +++ b/src/remote/__tests__/daemon-artifacts-save-script.test.ts @@ -0,0 +1,58 @@ +/** + * #1802, the write half of the caller/daemon file split. + * + * Reading was fixed by sending script sources with the request. Writing has no symmetric path: + * `--save-script` publishes a healed `.ad` through the daemon's own `SessionScriptWriter`, at + * session teardown, on the daemon's disk — next to a source path that does not exist there. A + * remote request that would produce a file the caller can never see is refused here instead of + * appearing to succeed. + */ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { prepareRemoteRequestArtifacts } from '../daemon-artifacts.ts'; + +const REMOTE = { baseUrl: 'http://remote-mac.example.test:7777/agent-device', token: 'secret' }; +const LOCAL = { token: 'secret' }; + +function replayRequest(saveScript?: boolean | string) { + return { + session: 'default', + command: 'replay', + positionals: ['./flows/login.ad'], + flags: { ...(saveScript === undefined ? {} : { saveScript }) }, + meta: { cwd: '/repo' }, + }; +} + +test('a remote daemon refuses --save-script, naming the caller/daemon split', async () => { + await assert.rejects( + async () => await prepareRemoteRequestArtifacts(replayRequest(true), REMOTE), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message.includes('written on the daemon host'), + ); +}); + +test('a remote daemon refuses an explicit --save-script output path too', async () => { + await assert.rejects( + async () => + await prepareRemoteRequestArtifacts(replayRequest('./flows/login.healed.ad'), REMOTE), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS', + ); +}); + +test('a local daemon leaves --save-script untouched', async () => { + const prepared = await prepareRemoteRequestArtifacts(replayRequest(true), LOCAL); + + assert.equal(prepared.flags?.saveScript, true); + assert.deepEqual(prepared.positionals, ['./flows/login.ad']); +}); + +test('a remote replay without --save-script passes through unchanged', async () => { + const prepared = await prepareRemoteRequestArtifacts(replayRequest(), REMOTE); + + assert.deepEqual(prepared.positionals, ['./flows/login.ad']); + assert.equal(prepared.uploadedArtifactId, undefined); +}); diff --git a/src/remote/daemon-artifacts.ts b/src/remote/daemon-artifacts.ts index 32f2510329..ec83c32b43 100644 --- a/src/remote/daemon-artifacts.ts +++ b/src/remote/daemon-artifacts.ts @@ -52,6 +52,7 @@ export async function prepareRemoteRequestArtifacts( }); } + assertRemoteDaemonSupportsSaveScript(req); flags = applyRemoteArtifactCommand(req, positionals, flags, clientArtifactPaths); const remoteInstallSource = await prepareRemoteInstallSource(req, info, uploadProgress); if (remoteInstallSource) { @@ -79,6 +80,27 @@ export async function prepareRemoteRequestArtifacts( return baseResult(); } +/** + * #1802 (the other half of the caller/daemon file split): `--save-script` WRITES a `.ad` file, + * and the writer is the daemon's `SessionScriptWriter` — on the daemon's own disk, next to a + * source path that does not exist there. Reading was fixed by sending script sources with the + * request; writing has no such symmetric path yet (the healed script commits at session teardown, + * not in the replay response), so a remote request that would produce a file the caller can never + * see is refused here instead of appearing to succeed. Tracked for the return-the-healed-script + * response plumbing. + */ +function assertRemoteDaemonSupportsSaveScript(req: Omit): void { + if (req.flags?.saveScript === undefined) return; + throw new AppError( + 'INVALID_ARGS', + '--save-script is not supported against a remote daemon: the healed script would be written on the daemon host, not on this machine.', + { + hint: 'Run the script against a local daemon to capture a healed .ad, or re-run without --save-script.', + command: req.command, + }, + ); +} + async function prepareRemoteInstallPackage( req: Omit, info: DaemonArtifactEndpoint, diff --git a/src/replay/__tests__/script-source-bundle.test.ts b/src/replay/__tests__/script-source-bundle.test.ts new file mode 100644 index 0000000000..862efb8b2c --- /dev/null +++ b/src/replay/__tests__/script-source-bundle.test.ts @@ -0,0 +1,92 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { + buildReplayScriptSourceBundle, + readReplayScriptSourceFile, + REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES, +} from '../script-source-bundle.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; + +// #1802: the caller reads every script file a replay run needs and sends the text. These pin the +// collection itself; that the daemon consumes only the result is pinned beside the replay handler. + +test('a native .ad bundle carries the entry script resolved against the caller cwd', () => { + const root = mkdtempForTestSync('agent-device-bundle-native-'); + const script = 'open "Demo"\nclick "Save"\n'; + fs.writeFileSync(path.join(root, 'flow.ad'), script); + + const bundle = buildReplayScriptSourceBundle({ inputPath: './flow.ad', cwd: root }); + + expect(bundle.entry).toBe(path.join(root, 'flow.ad')); + expect(bundle.files).toEqual({ [path.join(root, 'flow.ad')]: script }); +}); + +test('a Maestro bundle carries the entry flow and its transitive runFlow includes', () => { + const root = mkdtempForTestSync('agent-device-bundle-maestro-'); + fs.mkdirSync(path.join(root, 'shared'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'flow.yaml'), + 'appId: demo\n---\n- runFlow: shared/login.yaml\n', + ); + fs.writeFileSync(path.join(root, 'shared', 'login.yaml'), '---\n- runFlow: ../tail.yaml\n'); + fs.writeFileSync(path.join(root, 'tail.yaml'), '---\n- back\n'); + + const bundle = buildReplayScriptSourceBundle({ + inputPath: './flow.yaml', + cwd: root, + replayBackend: 'maestro', + }); + + expect(Object.keys(bundle.files).sort()).toEqual( + [ + path.join(root, 'flow.yaml'), + path.join(root, 'shared', 'login.yaml'), + path.join(root, 'tail.yaml'), + ].sort(), + ); +}); + +// The failure the reporter saw was an `ENOENT` for a path that exists on the caller. Reading on +// the caller turns it into a normal INVALID_ARGS that names the path as typed, before any daemon +// round-trip. +test('a missing entry script fails as INVALID_ARGS naming the path as typed', () => { + const root = mkdtempForTestSync('agent-device-bundle-missing-'); + + expect(() => buildReplayScriptSourceBundle({ inputPath: './flows/login.ad', cwd: root })).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: 'replay script not found on this machine: ./flows/login.ad', + }), + ); +}); + +test('an oversized bundle is refused with the limit named', () => { + const root = mkdtempForTestSync('agent-device-bundle-oversize-'); + fs.writeFileSync( + path.join(root, 'flow.ad'), + `open "Demo"\n${'#'.repeat(REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES)}\n`, + ); + + expect(() => buildReplayScriptSourceBundle({ inputPath: './flow.ad', cwd: root })).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining(`over the ${REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES}-byte`), + }), + ); +}); + +test('reading a file the bundle does not carry names the file and the entry', () => { + const bundle = { entry: '/flows/login.yaml', files: { '/flows/login.yaml': '---\n- back\n' } }; + + try { + readReplayScriptSourceFile(bundle, '/flows/missing.yaml'); + expect.unreachable('expected a missing bundled source to throw'); + } catch (error) { + expect(error).toBeInstanceOf(AppError); + expect((error as AppError).code).toBe('INVALID_ARGS'); + expect((error as AppError).message).toContain('/flows/missing.yaml'); + expect((error as AppError).details).toMatchObject({ entry: '/flows/login.yaml' }); + } +}); diff --git a/src/replay/__tests__/source-discovery.test.ts b/src/replay/__tests__/source-discovery.test.ts new file mode 100644 index 0000000000..d7d1151fed --- /dev/null +++ b/src/replay/__tests__/source-discovery.test.ts @@ -0,0 +1,198 @@ +import { test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { discoverReplaySourcePaths } from '../source-discovery.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; + +// #1802: path expansion, traversal ordering and file-type routing are CALLER work — `test` +// inputs name files on the machine that typed the command. These pin the traversal itself; +// manifest inspection is pinned beside the daemon capability that does it, and scheduler +// filtering policy is pinned in the replay-test package. +const discover = (inputs: string[], cwd: string, replayBackend?: string) => + discoverReplaySourcePaths({ inputs, cwd, replayBackend }); + +test('replay source discovery discovers nested .ad suites through native DFS traversal', () => { + const root = mkdtempForTestSync('agent-device-test-discovery-'); + const nested = path.join(root, 'nested'); + fs.mkdirSync(nested, { recursive: true }); + fs.writeFileSync(path.join(nested, '02-second.ad'), 'context platform=android\nopen "Second"\n'); + fs.writeFileSync(path.join(root, '01-first.ad'), 'context platform=ios\nopen "First"\n'); + + assert.deepEqual( + new Set(discover([root], root)), + new Set([path.join(nested, '02-second.ad'), path.join(root, '01-first.ad')]), + ); +}); + +test('replay source discovery includes Maestro yaml flows for Maestro test suites', () => { + const root = mkdtempForTestSync('agent-device-test-discovery-maestro-'); + fs.writeFileSync( + path.join(root, '01-flow.yaml'), + 'appId: demo\nname: Bottom Tabs - Dynamic\n---\n- launchApp\n', + ); + fs.writeFileSync(path.join(root, '02-flow.yml'), 'appId: demo\n---\n- launchApp\n'); + fs.writeFileSync(path.join(root, '03-flow.ad'), 'open "Demo"\n'); + + assert.deepEqual( + new Set(discover([root], root, 'maestro').map((entry) => path.basename(entry))), + new Set(['01-flow.yaml', '02-flow.yml', '03-flow.ad']), + ); +}); + +test('replay source discovery preserves Maestro directory filesystem order', () => { + const root = mkdtempForTestSync('agent-device-test-discovery-maestro-sort-'); + const flowFiles = ['10-legacy.ad', '30-zeta.yaml', '05-compat.ad', '20-beta.yml']; + for (const fileName of flowFiles) { + const body = fileName.endsWith('.ad') ? 'open "Demo"\n' : 'appId: demo\n---\n- launchApp\n'; + fs.writeFileSync(path.join(root, fileName), body); + } + + const opendirSync = vi.spyOn(fs, 'opendirSync').mockImplementation((directory) => { + assert.equal(directory, root); + let index = 0; + return { + readSync: () => { + const name = flowFiles[index++]; + if (!name) return null; + return { + name, + isDirectory: () => false, + isFile: () => true, + } as fs.Dirent; + }, + closeSync: () => {}, + } as fs.Dir; + }); + + try { + assert.deepEqual( + discover([root], root, 'maestro').map((entry) => path.basename(entry)), + ['10-legacy.ad', '30-zeta.yaml', '05-compat.ad', '20-beta.yml'], + ); + } finally { + opendirSync.mockRestore(); + } +}); + +test('replay source discovery preserves Maestro nested directory DFS order', () => { + const root = mkdtempForTestSync('agent-device-test-discovery-maestro-dfs-'); + const nested = path.join(root, 'nested'); + fs.mkdirSync(nested, { recursive: true }); + fs.writeFileSync(path.join(root, '30-root-a.yaml'), 'appId: demo\n---\n- launchApp\n'); + fs.writeFileSync(path.join(nested, '10-child.yml'), 'appId: demo\n---\n- launchApp\n'); + fs.writeFileSync(path.join(root, '20-root-c.ad'), 'open "Demo"\n'); + + type MockDirEntry = { name: string; directory: boolean }; + const opendirSync = vi.spyOn(fs, 'opendirSync').mockImplementation((directory) => { + let entries: MockDirEntry[] = []; + if (directory === root) { + entries = [ + { name: '30-root-a.yaml', directory: false }, + { name: 'nested', directory: true }, + { name: '20-root-c.ad', directory: false }, + ]; + } else if (directory === nested) { + entries = [{ name: '10-child.yml', directory: false }]; + } + let index = 0; + return { + readSync: () => { + const entry = entries[index++]; + if (!entry) return null; + return { + name: entry.name, + isDirectory: () => entry.directory, + isFile: () => !entry.directory, + } as fs.Dirent; + }, + closeSync: () => {}, + } as fs.Dir; + }); + + try { + assert.deepEqual( + discover([root], root, 'maestro').map((entry) => path.relative(root, entry)), + ['30-root-a.yaml', path.join('nested', '10-child.yml'), '20-root-c.ad'], + ); + } finally { + opendirSync.mockRestore(); + } +}); + +test('replay source discovery preserves explicit Maestro file order', () => { + const root = mkdtempForTestSync('agent-device-test-discovery-maestro-order-'); + const second = path.join(root, '02-second.yaml'); + const first = path.join(root, '01-first.yaml'); + fs.writeFileSync(first, 'appId: demo\n---\n- launchApp\n'); + fs.writeFileSync(second, 'appId: demo\n---\n- launchApp\n'); + + assert.deepEqual( + discover([second, first], root, 'maestro').map((entry) => path.basename(entry)), + ['02-second.yaml', '01-first.yaml'], + ); +}); + +test('replay source discovery orders Maestro file inputs before expanded flows', () => { + const root = mkdtempForTestSync('agent-device-test-discovery-maestro-files-'); + const suite = path.join(root, 'suite'); + const globSuite = path.join(root, 'glob-suite'); + fs.mkdirSync(suite); + fs.mkdirSync(globSuite); + const explicit = path.join(root, '99-explicit.yaml'); + fs.writeFileSync(explicit, 'appId: demo\n---\n- launchApp\n'); + fs.writeFileSync(path.join(suite, '01-directory.yaml'), 'appId: demo\n---\n- launchApp\n'); + fs.writeFileSync(path.join(globSuite, '02-glob.yaml'), 'appId: demo\n---\n- launchApp\n'); + + assert.deepEqual( + discover([suite, path.join(globSuite, '*.yaml'), explicit], root, 'maestro').map((entry) => + path.basename(entry), + ), + ['99-explicit.yaml', '01-directory.yaml', '02-glob.yaml'], + ); +}); + +test('replay source discovery de-duplicates overlapping Maestro file and glob inputs', () => { + const root = mkdtempForTestSync('agent-device-test-discovery-overlap-'); + const explicit = path.join(root, '02-explicit.yaml'); + fs.writeFileSync(explicit, 'appId: demo\n---\n- launchApp\n'); + fs.writeFileSync(path.join(root, '01-expanded.yaml'), 'appId: demo\n---\n- launchApp\n'); + + assert.deepEqual( + discover([explicit, path.join(root, '*.yaml')], root, 'maestro').map((entry) => + path.basename(entry), + ), + ['02-explicit.yaml', '01-expanded.yaml'], + ); +}); + +test('replay source discovery sorts mixed Maestro glob matches by YAML-first compatibility order', () => { + const root = mkdtempForTestSync('agent-device-test-discovery-mixed-glob-'); + fs.writeFileSync(path.join(root, '20-zeta.yaml'), 'appId: demo\n---\n- launchApp\n'); + fs.writeFileSync(path.join(root, '10-alpha.yml'), 'appId: demo\n---\n- launchApp\n'); + fs.writeFileSync(path.join(root, '00-native.ad'), 'open "Demo"\n'); + fs.writeFileSync(path.join(root, '30-native.ad'), 'open "Demo"\n'); + + assert.deepEqual( + discover([path.join(root, '*.{yaml,yml,ad}')], root, 'maestro').map((entry) => + path.basename(entry), + ), + ['10-alpha.yml', '20-zeta.yaml', '00-native.ad', '30-native.ad'], + ); +}); + +test('replay source discovery rejects YAML without explicit Maestro routing', () => { + const root = mkdtempForTestSync('agent-device-test-discovery-yaml-route-'); + const flowPath = path.join(root, 'flow.yaml'); + fs.writeFileSync(flowPath, 'appId: demo\n---\n- launchApp\n'); + + assert.throws( + () => discover([flowPath], root), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message === + `Maestro YAML requires explicit --maestro routing: test ${flowPath} --maestro`, + ); +}); diff --git a/src/replay/script-source-bundle.ts b/src/replay/script-source-bundle.ts new file mode 100644 index 0000000000..bfd7b9e6e9 --- /dev/null +++ b/src/replay/script-source-bundle.ts @@ -0,0 +1,112 @@ +import fs from 'node:fs'; +import { AppError } from '@agent-device/kernel/errors'; +import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; +import { collectMaestroFlowSources } from '@agent-device/maestro'; +import { resolveUserPath } from '../utils/path-resolution.ts'; +import { resolveReplayFormat } from './format.ts'; + +/** + * The caller-side half of the replay script source bundle (#1802): the CLI, + * the Node client, and the MCP tools all reach the daemon through the replay + * command family's daemon writers, and every one of them builds its bundle + * here — there is no separate remote path, so "works locally, ENOENT in CI" + * cannot come back. + * + * Reading is the caller's job because only the caller has the files. That also + * moves the "no such script" failure to where the path means something: it is + * raised against the caller's own filesystem, naming the path as typed, before + * any daemon round-trip. + */ + +/** + * Total bundled text a single replay request may carry. `.ad` scripts and + * Maestro flows are kilobytes; a bundle this large is a mistake (a directory + * of generated flows, a binary renamed to `.ad`) and is worth refusing with a + * clear message rather than pushing megabytes through the RPC envelope. + */ +export const REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES = 2 * 1024 * 1024; + +/** + * What a daemon says when a request carries only a path. There is no path + * fallback: a pre-#1802 client sending a bare path against a newer daemon is + * told what is missing and what to do, instead of getting an `ENOENT` for a + * file that exists on the machine it typed the command on. + */ +export const REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE = + 'This replay request carries no script sources. Replay scripts are read by the client and sent with the request; upgrade the agent-device client that issued it to a version that sends script sources.'; + +export function buildReplayScriptSourceBundle(params: { + /** The script path as the caller typed it — used verbatim in a not-found error. */ + inputPath: string; + cwd: string; + replayBackend?: string; + /** `${VAR}` values that can resolve a Maestro `runFlow` include path before the run starts. */ + env?: Readonly>; +}): ReplayScriptSourceBundle { + const entry = resolveUserPath(params.inputPath, { cwd: params.cwd }); + const entrySource = readReplayEntryScript(entry, params.inputPath); + const files = + resolveReplayFormat(entry, params.replayBackend) === 'maestro' + ? collectMaestroFlowSources({ + entryPath: entry, + entrySource, + env: params.env, + readSource: tryReadScriptFile, + }) + : { [entry]: entrySource }; + assertBundleWithinLimit(files, params.inputPath); + return { entry, files }; +} + +function readReplayEntryScript(resolvedPath: string, inputPath: string): string { + try { + return fs.readFileSync(resolvedPath, 'utf8'); + } catch (error) { + throw new AppError( + 'INVALID_ARGS', + `replay script not found on this machine: ${inputPath}`, + { + path: resolvedPath, + hint: 'Replay scripts are read by the client and sent to the daemon, so the path must resolve where you ran the command.', + }, + error instanceof Error ? error : undefined, + ); + } +} + +function tryReadScriptFile(resolvedPath: string): string | undefined { + try { + return fs.readFileSync(resolvedPath, 'utf8'); + } catch { + return undefined; + } +} + +function assertBundleWithinLimit(files: Record, inputPath: string): void { + const totalBytes = Object.values(files).reduce( + (total, script) => total + Buffer.byteLength(script, 'utf8'), + 0, + ); + if (totalBytes <= REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES) return; + throw new AppError( + 'INVALID_ARGS', + `replay script sources for ${inputPath} total ${totalBytes} bytes, over the ${REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES}-byte limit; split the flow or drop unused runFlow includes.`, + { limitBytes: REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES, totalBytes }, + ); +} + +/** Reads one bundled file, failing with the same vocabulary on both sides of the wire. */ +export function readReplayScriptSourceFile( + bundle: ReplayScriptSourceBundle, + resolvedPath: string, +): string { + const script = bundle.files[resolvedPath]; + if (script === undefined) { + throw new AppError( + 'INVALID_ARGS', + `replay script source is missing ${resolvedPath}; it was not part of the script sources sent with this request.`, + { path: resolvedPath, entry: bundle.entry }, + ); + } + return script; +} diff --git a/src/daemon/replay-source-discovery.ts b/src/replay/source-discovery.ts similarity index 91% rename from src/daemon/replay-source-discovery.ts rename to src/replay/source-discovery.ts index 87cf986b27..fbcafd673f 100644 --- a/src/daemon/replay-source-discovery.ts +++ b/src/replay/source-discovery.ts @@ -1,8 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; -import { isMaestroYamlPath, maestroBackendRequiredMessage } from '../replay/format.ts'; -import { SessionStore } from './session-store.ts'; +import { resolveUserPath } from '../utils/path-resolution.ts'; +import { isMaestroYamlPath, maestroBackendRequiredMessage } from './format.ts'; const GLOB_PATTERN_CHARS = /[*?[\]{}]/; type ReplayInputSource = 'directory' | 'file' | 'glob'; @@ -11,6 +11,11 @@ type ReplayInputSource = 'directory' | 'file' | 'glob'; * One traversal and ordering policy for both replay engines. It intentionally * matches Maestro: explicit files lead, directories use native DFS order, and * glob groups are extension-ranked then lexicographic. + * + * #1802: this runs on the CALLER. `test ` names files on the + * machine that typed the command, so expanding them daemon-side resolved + * globs against the wrong filesystem entirely against a remote daemon; the + * caller expands and then ships each source's text. */ export function discoverReplaySourcePaths(params: { inputs: string[]; @@ -42,7 +47,7 @@ function expandReplayInput( cwd: string, extensions: ReadonlySet, ): { paths: string[]; source: ReplayInputSource } { - const expandedInput = SessionStore.expandHome(input, cwd); + const expandedInput = resolveUserPath(input, { cwd }); if (fs.existsSync(expandedInput)) { const stat = fs.statSync(expandedInput); if (stat.isDirectory()) { diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 69f9f6d769..1340ad51c8 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -492,6 +492,7 @@ agent-device replay ./session.ad --keep-session # Suppress its terminal close ``` - `replay` runs deterministic `.ad` scripts. +- Script paths belong to the caller: `replay ` and `test ` are resolved and read by the client, which sends the script content (and any Maestro `runFlow` includes) with the request. The same command therefore works against a local daemon and against a remote one (`AGENT_DEVICE_DAEMON_BASE_URL`) with no copy step, and a script missing on the calling machine fails immediately, naming the path you typed. - `replay --keep-session` suppresses exactly an authored terminal `close` in native `.ad`; interior closes still run, and a close-less script is unchanged. The option is rejected by `test` and Maestro YAML. - `test` runs one or more `.ad` scripts as a serial suite from files, directories, or glob inputs. - `test --platform ` filters suite files by `context platform=...` metadata instead of overriding the script target. @@ -500,7 +501,7 @@ agent-device replay ./session.ad --keep-session # Suppress its terminal close - `test` prints a short `Running replay suite...` line before dispatch, then streams one-line `pass`, `fail`, or `skip` progress on stderr as each suite entry finishes or retries. Each line includes current/total suite position and elapsed seconds such as `pass 3/6 ... duration=12.34s`. The final summary still prints failures and flaky passed-on-retry tests by default; add `--verbose` to print every final result. - A failing step returns a `REPLAY_DIVERGENCE` report (screen digest, ranked selector suggestions, and a `resume` field); `replay --from --plan-digest ` resumes at and executes plan step `n` without re-running `1..n-1`. If the failed action was completed manually, resume from the next safe plan index using the matching digest. `replay`-only; `test` rejects `--from`. - `replay -u`/`--update` no longer rewrites the script (retired — see [Replay & E2E](/docs/replay-e2e)); it is a no-op kept for compatibility, since every divergence already carries the same ranked suggestions. -- `--save-script` records a replay script on `close`; optional path is a file path and parent directories are created. +- `--save-script` records a replay script on `close`; optional path is a file path and parent directories are created. It writes on the daemon host, so it is rejected against a remote daemon. See [Replay & E2E](/docs/replay-e2e) for recording, Maestro compatibility, and CI workflow details. From 2ca275a74c36b6ab2685c39b25adc45b80fba717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 18 Aug 2026 10:22:21 +0200 Subject: [PATCH 2/4] test: assert the caller-side replay path as a substring, not a hand-escaped regex --- src/__tests__/cli-client-commands.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index e7e19af326..6f267346db 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -756,7 +756,7 @@ test('replay refuses a script missing on the caller before any daemon request', assert.equal(result.code, 1); assert.equal(result.calls.length, 0); assert.match(result.stderr, /replay script not found on this machine/); - assert.match(result.stderr, new RegExp(missingPath.replace(/[.]/g, '\\.'))); + assert.ok(result.stderr.includes(missingPath), result.stderr); }); test('replay --timeout reaches the daemon request envelope through the public CLI', async () => { From 2dadb476166fd3af5857ed59f4870ea07a988655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 18 Aug 2026 11:20:41 +0200 Subject: [PATCH 3/4] perf(cli): load the Maestro engine only when a replay entry is a flow The command registry evaluates every command family on CLI startup, so the replay script-source builder's static @agent-device/maestro import put the YAML parser on the --help path. It now loads on demand behind the format check, and the startup import-closure guard covers the engine the way it already covers node:http. --- .../cli-startup-import-closure.test.ts | 26 +++++++ .../test-utils/replay-script-source.ts | 78 +++++++++++-------- src/agent-device-client.ts | 2 +- src/commands/batch/projection.ts | 34 ++++---- src/commands/cli-grammar/types.ts | 11 +++ src/commands/command-projection.ts | 26 +++---- src/commands/family/registry.ts | 6 +- src/commands/family/types.ts | 8 +- src/commands/replay/index.test.ts | 32 ++++---- src/commands/replay/index.ts | 24 +++--- .../__tests__/test-device-runtime-gateway.ts | 2 +- .../__tests__/session-command-harness.ts | 6 +- .../session-replay-runtime-failure.test.ts | 17 +++- .../session-replay-runtime-maestro.test.ts | 16 ++-- .../session-replay-runtime.fixtures.ts | 10 ++- .../session-replay-script-source.test.ts | 7 +- .../session-test-source-discovery.test.ts | 35 +++++---- .../__tests__/script-source-bundle.test.ts | 20 ++--- src/replay/script-source-bundle.ts | 68 ++++++++++++---- .../integration/provider-scenarios/harness.ts | 5 +- 20 files changed, 286 insertions(+), 147 deletions(-) diff --git a/src/__tests__/cli-startup-import-closure.test.ts b/src/__tests__/cli-startup-import-closure.test.ts index c6575996ec..d68b499736 100644 --- a/src/__tests__/cli-startup-import-closure.test.ts +++ b/src/__tests__/cli-startup-import-closure.test.ts @@ -12,6 +12,12 @@ import { parseSync } from 'oxc-parser'; * socket). The HTTP daemon transport, remote-artifact upload and download all * load these modules on demand instead. * + * `@agent-device/maestro` is held to the same line for the same reason. The command registry + * evaluates every command family's module on startup -- `--help` included -- so a replay-side + * static import of the Maestro engine (and the YAML parser behind it) put a 131 kB chunk and + * ~28ms on every warm command, for a format most invocations never touch (#1802). The replay + * script-source builder loads it on demand, only once an entry actually resolves to a flow. + * * "On demand" is a claim about SCOPE, not about syntax, which is why this reads * the AST rather than matching import forms. Two shapes evaluate the module * during module evaluation while looking lazy or looking like nothing at all: @@ -29,6 +35,8 @@ import { parseSync } from 'oxc-parser'; const srcRoot = path.resolve(import.meta.dirname, '..'); const LAZY_HTTP_MODULES = new Set(['node:http', 'node:https']); +/** Engines heavy enough that evaluating them on startup is a measurable regression. */ +const LAZY_ENGINE_MODULES = new Set(['@agent-device/maestro']); const FUNCTION_NODES = new Set([ 'FunctionDeclaration', 'FunctionExpression', @@ -259,6 +267,24 @@ test('the CLI startup import closure never evaluates node:http or node:https', ( ).toEqual([]); }); +test('the CLI startup import closure never evaluates the Maestro engine', () => { + const offenders: string[] = []; + for (const file of eagerClosureOfCli()) { + for (const specifier of eagerlyEvaluatedModules(file, fs.readFileSync(file, 'utf8'))) { + if (LAZY_ENGINE_MODULES.has(specifier)) { + offenders.push(`${path.relative(srcRoot, file)} -> ${specifier}`); + } + } + } + + expect( + offenders, + 'Load @agent-device/maestro on demand instead: the command registry evaluates every command ' + + 'family on startup, so importing the engine here costs every warm CLI command ~28ms of ' + + 'YAML-parser evaluation and a 131 kB startup chunk, for a format most runs never use.', + ).toEqual([]); +}); + test('the CLI startup import closure is reachable and crosses the package boundary', () => { // Guards the test above from silently passing because the walk found nothing: // a resolver that returned null for everything would leave both the src side diff --git a/src/__tests__/test-utils/replay-script-source.ts b/src/__tests__/test-utils/replay-script-source.ts index 15c308a58f..b8b5c5847c 100644 --- a/src/__tests__/test-utils/replay-script-source.ts +++ b/src/__tests__/test-utils/replay-script-source.ts @@ -1,22 +1,30 @@ import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; import type { MaestroSourceReader } from '@agent-device/maestro'; import type { DaemonRequest } from '../../daemon/types.ts'; -import { buildReplayScriptSourceBundle } from '../../replay/script-source-bundle.ts'; +import { + loadReplayScriptSourceBundle, + readAdScriptSourceBundle, +} from '../../replay/script-source-bundle.ts'; import { discoverReplaySourcePaths } from '../../replay/source-discovery.ts'; /** - * Builds a replay script source bundle from a file on disk the way a real - * caller does (#1802) — through the client's own builder, so a test never - * hand-rolls a bundle shape the CLI would not actually send. + * A native `.ad` script's bundle, built from a file on disk through the client's own reader + * (#1802) so a test never hand-rolls a shape the CLI would not actually send. Synchronous + * because an `.ad` script has no include grammar and needs no engine; use + * `maestroScriptSourceBundleFor` for a flow. */ -export function replayScriptSourceBundleFor( +export function replayScriptSourceBundleFor(filePath: string): ReplayScriptSourceBundle { + return readAdScriptSourceBundle({ inputPath: filePath, cwd: process.cwd() }); +} + +/** A Maestro flow's bundle — async because the engine that walks its includes loads on demand. */ +export async function maestroScriptSourceBundleFor( filePath: string, - options: { replayBackend?: string } = {}, -): ReplayScriptSourceBundle { - return buildReplayScriptSourceBundle({ +): Promise { + return await loadReplayScriptSourceBundle({ inputPath: filePath, cwd: process.cwd(), - replayBackend: options.replayBackend, + replayBackend: 'maestro', }); } @@ -35,36 +43,44 @@ export const noMaestroIncludeSources: MaestroSourceReader = (resolvedPath) => { * Test harnesses that stand in for "a request as it arrives at the daemon" call this so their * cases keep expressing what they are about; a request that already carries its own sources (or * deliberately carries none, to pin the daemon's refusal) is returned untouched. + * + * Collection that cannot succeed — a case driving `replay` at a path that deliberately does not + * exist — leaves the request alone rather than throwing: the real client raises that failure at + * the CLI boundary (pinned in `cli-client-commands.test.ts`), and a daemon-level harness must + * still deliver the request so the daemon's own response is what the case observes. */ -export function withClientReplayScriptSources(req: DaemonRequest): DaemonRequest { +export async function withClientReplayScriptSources(req: DaemonRequest): Promise { if (req.command !== 'replay' && req.command !== 'test') return req; const inputs = req.positionals ?? []; - if (inputs.length === 0) return req; + const entryPath = inputs[0]; + if (entryPath === undefined) return req; const cwd = req.meta?.cwd ?? process.cwd(); const replayBackend = req.flags?.replayBackend; - const entryPath = inputs[0]; - if (req.command === 'replay') { - if (req.flags?.replayScriptSource || entryPath === undefined) return req; - return { - ...req, - flags: { - ...(req.flags ?? {}), - replayScriptSource: buildReplayScriptSourceBundle({ + try { + if (req.command === 'replay') { + if (req.flags?.replayScriptSource) return req; + return withFlags(req, { + replayScriptSource: await loadReplayScriptSourceBundle({ inputPath: entryPath, cwd, replayBackend, }), - }, - }; - } - if (req.flags?.replayScriptSources) return req; - return { - ...req, - flags: { - ...(req.flags ?? {}), - replayScriptSources: discoverReplaySourcePaths({ inputs, cwd, replayBackend }).map( - (inputPath) => buildReplayScriptSourceBundle({ inputPath, cwd, replayBackend }), + }); + } + if (req.flags?.replayScriptSources) return req; + return withFlags(req, { + replayScriptSources: await Promise.all( + discoverReplaySourcePaths({ inputs, cwd, replayBackend }).map( + async (inputPath) => + await loadReplayScriptSourceBundle({ inputPath, cwd, replayBackend }), + ), ), - }, - }; + }); + } catch { + return req; + } +} + +function withFlags(req: DaemonRequest, flags: Partial): DaemonRequest { + return { ...req, flags: { ...(req.flags ?? {}), ...flags } }; } diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 49616f9cdb..c134607d7f 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -131,7 +131,7 @@ export function createAgentDeviceClient( command: DaemonCommandName, options: InternalRequestOptions = {}, ): Promise => { - const request = prepareDaemonCommandRequest(command, options); + const request = await prepareDaemonCommandRequest(command, options); return (await execute( request.command, request.positionals, diff --git a/src/commands/batch/projection.ts b/src/commands/batch/projection.ts index 966556965a..e0a8051212 100644 --- a/src/commands/batch/projection.ts +++ b/src/commands/batch/projection.ts @@ -11,7 +11,11 @@ import { } from '@agent-device/contracts/command'; import { AppError } from '@agent-device/kernel/errors'; import { request } from '../cli-grammar/common.ts'; -import type { CommandInput, DaemonCommandRequest, DaemonWriter } from '../cli-grammar/types.ts'; +import type { + AsyncDaemonWriter, + CommandInput, + DaemonCommandRequest, +} from '../cli-grammar/types.ts'; import { buildRequestFlags } from '../command-flags.ts'; import type { DaemonCommandName } from '../command-projection.ts'; @@ -23,42 +27,46 @@ type PrepareDaemonCommandRequest = ( command: string, input: CommandInput, stepNumber: number, -) => DaemonCommandRequest; +) => Promise; export function createBatchDaemonWriter( prepareDaemonCommandRequest: PrepareDaemonCommandRequest, -): DaemonWriter { - return (input) => +): AsyncDaemonWriter { + return async (input) => request(PUBLIC_COMMANDS.batch, [], { ...input, - batchSteps: readBatchDaemonSteps(input.steps, prepareDaemonCommandRequest), + batchSteps: await readBatchDaemonSteps(input.steps, prepareDaemonCommandRequest), batchOnError: input.onError, batchMaxSteps: input.maxSteps, }); } -function readBatchDaemonSteps( +async function readBatchDaemonSteps( steps: unknown, prepareDaemonCommandRequest: PrepareDaemonCommandRequest, -): DaemonBatchStep[] { +): Promise { if (!Array.isArray(steps) || steps.length === 0) { throw new AppError('INVALID_ARGS', 'batch requires a non-empty steps array.'); } - return steps.map((step, index) => - readBatchDaemonStep(step, index + 1, prepareDaemonCommandRequest), - ); + // Sequential, not `Promise.all`: a step's rejection is reported with its own step number, and + // preparing step N+1 after N keeps that number the first failure a caller sees. + const prepared: DaemonBatchStep[] = []; + for (const [index, step] of steps.entries()) { + prepared.push(await readBatchDaemonStep(step, index + 1, prepareDaemonCommandRequest)); + } + return prepared; } -function readBatchDaemonStep( +async function readBatchDaemonStep( step: unknown, stepNumber: number, prepareDaemonCommandRequest: PrepareDaemonCommandRequest, -): DaemonBatchStep { +): Promise { const record = readBatchStepRecord(step, stepNumber); const command = readBatchStepCommand(record, stepNumber); const input = readBatchStepInputObject(record, stepNumber) as CommandInput; const runtime = parseBatchStepRuntime(record.runtime, stepNumber); - const prepared = prepareDaemonCommandRequest(command, input, stepNumber); + const prepared = await prepareDaemonCommandRequest(command, input, stepNumber); return { command: prepared.command, positionals: prepared.positionals, diff --git a/src/commands/cli-grammar/types.ts b/src/commands/cli-grammar/types.ts index 743d1ce21e..62abc8cd15 100644 --- a/src/commands/cli-grammar/types.ts +++ b/src/commands/cli-grammar/types.ts @@ -85,4 +85,15 @@ export type SelectionOptions = { export type CliInput = Record; export type CliReader = (positionals: string[], flags: CliFlags) => CliInput; +/** Builds the daemon request for one command. Most writers are pure projections of their input. */ export type DaemonWriter = (input: CommandInput) => DaemonCommandRequest; + +/** + * A writer that has caller-side work to do before the request exists: reading the replay script + * files the request carries (#1802), which for a Maestro flow also loads the engine that walks its + * `runFlow` includes on demand. The registry awaits whichever kind a command declares, so the + * asynchrony stays inside the one writer that needs it. + */ +export type AsyncDaemonWriter = (input: CommandInput) => Promise; + +export type AnyDaemonWriter = DaemonWriter | AsyncDaemonWriter; diff --git a/src/commands/command-projection.ts b/src/commands/command-projection.ts index 563c729c49..a012848a3f 100644 --- a/src/commands/command-projection.ts +++ b/src/commands/command-projection.ts @@ -1,23 +1,23 @@ import { createBatchDaemonWriter } from './batch/index.ts'; -import type { CommandInput, DaemonCommandRequest, DaemonWriter } from './cli-grammar/types.ts'; +import type { AnyDaemonWriter, CommandInput, DaemonCommandRequest } from './cli-grammar/types.ts'; import { findCommandMetadata } from './command-metadata.ts'; import { readMetadataCommandFlags } from './command-flags.ts'; import { listCommandFamilyDaemonWriters } from './family/registry.ts'; import { AppError } from '@agent-device/kernel/errors'; -const daemonWriters: Record = { +const daemonWriters: Record = { ...listCommandFamilyDaemonWriters(), batch: createBatchDaemonWriter(prepareBatchDaemonCommandRequest), }; export type DaemonCommandName = keyof typeof daemonWriters; -function prepareBatchDaemonCommandRequest( +async function prepareBatchDaemonCommandRequest( command: string, input: CommandInput, stepNumber: number, -): DaemonCommandRequest { - const writer = (daemonWriters as Readonly>)[command]; +): Promise { + const writer = (daemonWriters as Readonly>)[command]; if (!writer) { throw new Error(`Missing daemon writer for batch command: ${command}`); } @@ -26,7 +26,7 @@ function prepareBatchDaemonCommandRequest( throw new Error(`Missing command metadata for batch command: ${command}`); } try { - return prepareRequestWithMetadataFlags( + return await prepareRequestWithMetadataFlags( writer, metadata, metadata.readInput(input) as CommandInput, @@ -42,24 +42,24 @@ function prepareBatchDaemonCommandRequest( } } -export function prepareDaemonCommandRequest( +export async function prepareDaemonCommandRequest( command: DaemonCommandName, input: CommandInput, -): DaemonCommandRequest { +): Promise { const writer = daemonWriters[command]; if (!writer) { throw new Error(`Missing daemon writer for command: ${command}`); } const metadata = findCommandMetadata(command); - return prepareRequestWithMetadataFlags(writer, metadata, input); + return await prepareRequestWithMetadataFlags(writer, metadata, input); } -function prepareRequestWithMetadataFlags( - writer: DaemonWriter, +async function prepareRequestWithMetadataFlags( + writer: AnyDaemonWriter, metadata: ReturnType, input: CommandInput, -): DaemonCommandRequest { - const request = writer(input); +): Promise { + const request = await writer(input); return { ...request, ...(metadata ? { metadataFlags: readMetadataCommandFlags(metadata, request.options) } : {}), diff --git a/src/commands/family/registry.ts b/src/commands/family/registry.ts index 484a84370a..7e012e77f7 100644 --- a/src/commands/family/registry.ts +++ b/src/commands/family/registry.ts @@ -1,6 +1,6 @@ import { batchCommandFamily } from '../batch/index.ts'; import { captureCommandFamily } from '../capture/index.ts'; -import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; +import type { AnyDaemonWriter, CliReader } from '../cli-grammar/types.ts'; import { debuggingCommandFamily } from '../debugging/index.ts'; import { interactionCommandFamily } from '../interaction/index.ts'; import { managementCommandFamily } from '../management/index.ts'; @@ -18,7 +18,7 @@ import { type CommandFamilyFacet } from './types.ts'; type CommandFamilyRecordMap = { cliSchemas: CommandSchema; cliReaders: CliReader; - daemonWriters: DaemonWriter; + daemonWriters: AnyDaemonWriter; cliOutputFormatters: CliOutputFormatter; }; @@ -57,7 +57,7 @@ export function listCommandFamilyCliReaders(): Record; } -export function listCommandFamilyDaemonWriters(): Record { +export function listCommandFamilyDaemonWriters(): Record { return mergeFamilyRecords('daemonWriters'); } diff --git a/src/commands/family/types.ts b/src/commands/family/types.ts index bf71c9c91b..f4e7ac987a 100644 --- a/src/commands/family/types.ts +++ b/src/commands/family/types.ts @@ -1,6 +1,6 @@ import type { AgentDeviceClient } from '../../client/client-types.ts'; import type { CommandSchema, CommandSchemaOverride } from '../../cli-schema/types.ts'; -import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; +import type { AnyDaemonWriter, CliReader } from '../cli-grammar/types.ts'; import type { CommandMetadata, ExecutableCommandProjection, @@ -28,7 +28,7 @@ export type CommandFamilyFacet = { clientCommandMethods?: Readonly>; cliSchemas?: Readonly>>; cliReaders: Readonly>; - daemonWriters?: Readonly>; + daemonWriters?: Readonly>; cliOutputFormatters?: Readonly>>; }; @@ -43,7 +43,7 @@ export type CommandFacetInput = { cliSchema?: CommandSchemaOverride; clientMethod?: string; cliReader: CliReader; - daemonWriter?: DaemonWriter; + daemonWriter?: AnyDaemonWriter; cliOutputFormatter?: CliOutputFormatter; text: FacetCommandText; }; @@ -96,7 +96,7 @@ export function defineCommandFamilyFromFacets< const cliSchemas: Record = {}; const clientCommandMethods: Record = {}; const cliReaders: Record = {}; - const daemonWriters: Record = {}; + const daemonWriters: Record = {}; const cliOutputFormatters: Record = {}; for (const command of family.commands) { diff --git a/src/commands/replay/index.test.ts b/src/commands/replay/index.test.ts index 9a42ca69d3..2ef11d25f9 100644 --- a/src/commands/replay/index.test.ts +++ b/src/commands/replay/index.test.ts @@ -122,10 +122,10 @@ describe('replay command interface', () => { }); }); - test('writes daemon replay and test requests with replay flags', () => { + test('writes daemon replay and test requests with replay flags', async () => { process.env.AD_VAR_REPLAY_TEST = 'enabled'; expect( - replayDaemonWriter({ + await replayDaemonWriter({ path: './checkout.ad', cwd: SCRIPT_ROOT, update: true, @@ -143,7 +143,7 @@ describe('replay command interface', () => { }, }); - const testRequest = testDaemonWriter({ + const testRequest = await testDaemonWriter({ paths: ['./suite.ad'], cwd: SCRIPT_ROOT, maestro: true, @@ -163,11 +163,11 @@ describe('replay command interface', () => { expect(testRequest.options).not.toHaveProperty('reportJunit'); }); - test('projects replay --timeout into the daemon request envelope', () => { + test('projects replay --timeout into the daemon request envelope', async () => { const input = replayCliReader(['./probe-hang.ad'], flags({ timeoutMs: 1_000 })); expect(input).toMatchObject({ path: './probe-hang.ad', timeoutMs: 1_000 }); - expect(replayDaemonWriter({ ...input, cwd: SCRIPT_ROOT })).toMatchObject({ + expect(await replayDaemonWriter({ ...input, cwd: SCRIPT_ROOT })).toMatchObject({ command: 'replay', positionals: ['./probe-hang.ad'], options: { timeoutMs: 1_000 }, @@ -231,9 +231,9 @@ describe('replay resume (ADR 0012 decision 4 / migration step 5)', () => { expect(input).not.toHaveProperty('resumePlanDigest'); }); - test('writes resumeFrom/resumePlanDigest onto the daemon request as replayFrom/replayPlanDigest', () => { + test('writes resumeFrom/resumePlanDigest onto the daemon request as replayFrom/replayPlanDigest', async () => { expect( - replayDaemonWriter({ + await replayDaemonWriter({ path: './checkout.ad', cwd: SCRIPT_ROOT, resumeFrom: 3, @@ -249,18 +249,18 @@ describe('replay resume (ADR 0012 decision 4 / migration step 5)', () => { }); }); - test('test daemon writer never emits replayFrom/replayPlanDigest', () => { - const request = testDaemonWriter({ paths: ['./suite.ad'], cwd: SCRIPT_ROOT }); + test('test daemon writer never emits replayFrom/replayPlanDigest', async () => { + const request = await testDaemonWriter({ paths: ['./suite.ad'], cwd: SCRIPT_ROOT }); expect(request.options).not.toHaveProperty('replayFrom'); expect(request.options).not.toHaveProperty('replayPlanDigest'); }); }); describe('replay --keep-session', () => { - test('projects the CLI flag through structured replay input and the daemon request', () => { + test('projects the CLI flag through structured replay input and the daemon request', async () => { const input = replayCliReader(['./checkout.ad'], flags({ replayKeepSession: true })); expect(input).toMatchObject({ path: './checkout.ad', keepSession: true }); - expect(replayDaemonWriter({ ...input, cwd: SCRIPT_ROOT })).toMatchObject({ + expect(await replayDaemonWriter({ ...input, cwd: SCRIPT_ROOT })).toMatchObject({ command: 'replay', positionals: ['./checkout.ad'], options: { replayKeepSession: true }, @@ -268,11 +268,11 @@ describe('replay --keep-session', () => { expect(replayCommandMetadata.inputSchema.properties).toHaveProperty('keepSession'); }); - test('test exposes and forwards no keep-session option', () => { + test('test exposes and forwards no keep-session option', async () => { const input = testCliReader(['./suite.ad'], flags({ replayKeepSession: true } as never)); expect(input).not.toHaveProperty('keepSession'); expect(testCommandMetadata.inputSchema.properties).not.toHaveProperty('keepSession'); - expect(testDaemonWriter({ ...input, cwd: SCRIPT_ROOT }).options).not.toHaveProperty( + expect((await testDaemonWriter({ ...input, cwd: SCRIPT_ROOT })).options).not.toHaveProperty( 'replayKeepSession', ); }); @@ -295,16 +295,16 @@ describe('replay --save-script arming (ADR 0012 decision 6, R1/R6)', () => { }); }); - test('writes saveScript onto the daemon request unchanged', () => { + test('writes saveScript onto the daemon request unchanged', async () => { expect( - replayDaemonWriter({ path: './checkout.ad', cwd: SCRIPT_ROOT, saveScript: true }), + await replayDaemonWriter({ path: './checkout.ad', cwd: SCRIPT_ROOT, saveScript: true }), ).toMatchObject({ command: 'replay', positionals: ['./checkout.ad'], options: { saveScript: true }, }); expect( - replayDaemonWriter({ path: './checkout.ad', cwd: SCRIPT_ROOT, saveScript: './out.ad' }), + await replayDaemonWriter({ path: './checkout.ad', cwd: SCRIPT_ROOT, saveScript: './out.ad' }), ).toMatchObject({ command: 'replay', positionals: ['./checkout.ad'], diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts index de8fecf04c..93868fc45b 100644 --- a/src/commands/replay/index.ts +++ b/src/commands/replay/index.ts @@ -18,7 +18,7 @@ import { requiredDaemonString, requiredString, } from '../cli-grammar/common.ts'; -import type { CliReader, CommandInput, DaemonWriter } from '../cli-grammar/types.ts'; +import type { AsyncDaemonWriter, CliReader, CommandInput } from '../cli-grammar/types.ts'; import { METRO_RELOAD_FLAGS, REPLAY_FLAGS } from '../cli-grammar/flag-groups.ts'; import { withCommandRuntimeHints } from '../runtime-hints.ts'; import { @@ -26,7 +26,7 @@ import { parseReplayCliEnvEntries, readReplayCliEnvEntries, } from '@agent-device/ad-script'; -import { buildReplayScriptSourceBundle } from '../../replay/script-source-bundle.ts'; +import { loadReplayScriptSourceBundle } from '../../replay/script-source-bundle.ts'; import { discoverReplaySourcePaths } from '../../replay/source-discovery.ts'; const REPLAY_COMMAND_NAME = 'replay'; @@ -185,7 +185,7 @@ export const testCliReader: CliReader = (positionals, flags) => ({ shardSplit: flags.shardSplit, }); -export const replayDaemonWriter: DaemonWriter = (input) => { +export const replayDaemonWriter: AsyncDaemonWriter = async (input) => { const inputPath = requiredDaemonString(input.path, 'replay requires path'); const replayBackend = readReplayBackend(input); const replayShellEnv = collectReplayClientShellEnv(process.env); @@ -198,7 +198,7 @@ export const replayDaemonWriter: DaemonWriter = (input) => { // #1802: the caller owns the flow files, so it reads them here — the same // client-collects-local-input move `replayShellEnv` already makes — and // the daemon executes only what arrives in this bundle. - replayScriptSource: buildReplayScriptSourceBundle({ + replayScriptSource: await loadReplayScriptSourceBundle({ inputPath, cwd: readReplayClientCwd(input), replayBackend, @@ -211,11 +211,12 @@ export const replayDaemonWriter: DaemonWriter = (input) => { }); }; -export const testDaemonWriter: DaemonWriter = (input) => { +export const testDaemonWriter: AsyncDaemonWriter = async (input) => { const inputs = input.paths ?? []; const replayBackend = readReplayBackend(input); const cwd = readReplayClientCwd(input); const replayShellEnv = collectReplayClientShellEnv(process.env); + const env = readReplayScriptSourceEnv(input, replayShellEnv); return request(TEST_COMMAND_NAME, inputs, { ...stripReplayTestPresentationInput(input), replayUpdate: input.update, @@ -225,14 +226,11 @@ export const testDaemonWriter: DaemonWriter = (input) => { // #1802: `test` inputs are paths, directories, and globs on the CALLER's // filesystem. Expanding them here keeps discovery order identical for a // local and a remote daemon and ships each discovered source's text. - replayScriptSources: discoverReplaySourcePaths({ inputs, cwd, replayBackend }).map( - (inputPath) => - buildReplayScriptSourceBundle({ - inputPath, - cwd, - replayBackend, - env: readReplayScriptSourceEnv(input, replayShellEnv), - }), + replayScriptSources: await Promise.all( + discoverReplaySourcePaths({ inputs, cwd, replayBackend }).map( + async (inputPath) => + await loadReplayScriptSourceBundle({ inputPath, cwd, replayBackend, env }), + ), ), }); }; diff --git a/src/daemon/__tests__/test-device-runtime-gateway.ts b/src/daemon/__tests__/test-device-runtime-gateway.ts index 42f12e44fd..24053eed09 100644 --- a/src/daemon/__tests__/test-device-runtime-gateway.ts +++ b/src/daemon/__tests__/test-device-runtime-gateway.ts @@ -183,6 +183,6 @@ export function createRequestHandler( // #1802: stand in for the client that reads a replay script and sends its content, so router // cases keep naming a path while the daemon still sees only bundled sources. const handleWithClientScriptSources: DaemonInvokeFn = async (req) => - await handle(withClientReplayScriptSources(req)); + await handle(await withClientReplayScriptSources(req)); return handleWithClientScriptSources; } diff --git a/src/daemon/handlers/__tests__/session-command-harness.ts b/src/daemon/handlers/__tests__/session-command-harness.ts index e9515e15ea..fbefb79cc0 100644 --- a/src/daemon/handlers/__tests__/session-command-harness.ts +++ b/src/daemon/handlers/__tests__/session-command-harness.ts @@ -93,12 +93,12 @@ beforeEach(() => { }); /** Unit-handler default is explicitly fail-closed; production must inject exact-owner recovery. */ -export function handleSessionCommands( +export async function handleSessionCommands( params: Omit, ): ReturnType { - return handleProductionSessionCommands({ + return await handleProductionSessionCommands({ ...params, - req: withClientReplayScriptSources(params.req), + req: await withClientReplayScriptSources(params.req), inspectFacts: params.inspectFacts ?? mockInspectDeviceRuntimeFacts, bindDevice: params.bindDevice ?? mockBindDeviceRuntime, reconcileOrphanedDeviceClaim: async () => ({ diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts index 648081e923..1c50e4ed35 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts @@ -13,6 +13,7 @@ import type { DaemonResponse } from '../../types.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { formatReplayDivergenceReport } from '@agent-device/contracts/divergence'; +import { maestroScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; import { baseReplayRequest as baseReq, writeReplayFile, @@ -349,7 +350,13 @@ test('a failure inside a retry-wrapped runFlow include reports the include file mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); const response = await runReplayScriptSource({ - req: baseReq({ positionals: [mainPath], flags: { replayBackend: 'maestro' } }), + req: baseReq({ + positionals: [mainPath], + flags: { + replayBackend: 'maestro', + replayScriptSource: await maestroScriptSourceBundleFor(mainPath), + }, + }), sessionName, logPath: path.join(root, 'daemon.log'), sessionStore, @@ -399,7 +406,13 @@ test('a failure inside a runtime runFlow.when-wrapped include reports the includ mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); const response = await runReplayScriptSource({ - req: baseReq({ positionals: [mainPath], flags: { replayBackend: 'maestro' } }), + req: baseReq({ + positionals: [mainPath], + flags: { + replayBackend: 'maestro', + replayScriptSource: await maestroScriptSourceBundleFor(mainPath), + }, + }), sessionName, logPath: path.join(root, 'daemon.log'), sessionStore, diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts index 7709ed3ea3..fa2a902d98 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts @@ -57,7 +57,11 @@ import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../types. import { SessionStore } from '../../session-store.ts'; import { makeAndroidSession, makeIosSession } from '../../../__tests__/test-utils/index.ts'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { replayScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; +import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; +import { + maestroScriptSourceBundleFor, + replayScriptSourceBundleFor, +} from '../../../__tests__/test-utils/replay-script-source.ts'; type CapturedInvocation = { command: string; @@ -88,8 +92,11 @@ async function runReplayFixture(params: { const invoke = createFixtureInvoke({ calls, delegate: params.invoke, isMaestro }); const sessionStore = new SessionStore(path.join(root, 'state')); seedFixtureSession(sessionStore, params.sessionPlatform); + const scriptSource = isMaestro + ? await maestroScriptSourceBundleFor(scriptPath) + : replayScriptSourceBundleFor(scriptPath); const response = await runReplayScriptSource({ - req: fixtureReplayRequest({ root, scriptPath, flags: params.flags, isMaestro }), + req: fixtureReplayRequest({ root, scriptPath, flags: params.flags, isMaestro, scriptSource }), sessionName: 's', logPath: path.join(root, 'log'), sessionStore, @@ -136,6 +143,7 @@ function fixtureReplayRequest(params: { scriptPath: string; flags: CommandFlags | undefined; isMaestro: boolean; + scriptSource: ReplayScriptSourceBundle; }): DaemonRequest { return { token: 't', @@ -145,9 +153,7 @@ function fixtureReplayRequest(params: { flags: { ...(params.flags ?? {}), ...(params.isMaestro && params.flags?.platform === undefined ? { platform: 'ios' } : {}), - replayScriptSource: replayScriptSourceBundleFor(params.scriptPath, { - replayBackend: params.flags?.replayBackend, - }), + replayScriptSource: params.scriptSource, }, meta: { cwd: params.root }, }; diff --git a/src/daemon/handlers/__tests__/session-replay-runtime.fixtures.ts b/src/daemon/handlers/__tests__/session-replay-runtime.fixtures.ts index e813899a04..d2b4f79e7a 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime.fixtures.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime.fixtures.ts @@ -15,6 +15,12 @@ export function writeReplayFile(root: string, lines: string[]): string { * positional names a script also carries that script's source bundle. Tests * pass `positionals: [filePath]` as before and the bundle is built from the * same file, exactly as `replayDaemonWriter` builds it. + * + * The bundle carries the ENTRY FILE only, which is the whole of a native `.ad` + * script and enough for a self-contained flow. A Maestro fixture with `runFlow` + * includes must pass its own `flags.replayScriptSource` from + * `maestroScriptSourceBundleFor` — collecting includes needs the engine, which + * loads on demand and so cannot happen in a synchronous fixture. */ export function baseReplayRequest(overrides: Partial = {}): DaemonRequest { const positionals = overrides.positionals ?? []; @@ -30,9 +36,7 @@ export function baseReplayRequest(overrides: Partial = {}): Daemo ? { flags: { ...(flags ?? {}), - replayScriptSource: replayScriptSourceBundleFor(scriptPath, { - replayBackend: flags?.replayBackend, - }), + replayScriptSource: replayScriptSourceBundleFor(scriptPath), }, } : {}), diff --git a/src/daemon/handlers/__tests__/session-replay-script-source.test.ts b/src/daemon/handlers/__tests__/session-replay-script-source.test.ts index 05e48b21c6..46bc805bb0 100644 --- a/src/daemon/handlers/__tests__/session-replay-script-source.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-script-source.test.ts @@ -19,7 +19,10 @@ import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; -import { replayScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; +import { + maestroScriptSourceBundleFor, + replayScriptSourceBundleFor, +} from '../../../__tests__/test-utils/replay-script-source.ts'; import { REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE } from '../../../replay/script-source-bundle.ts'; beforeEach(() => { @@ -64,7 +67,7 @@ test('a bundled Maestro replay resolves runFlow includes from the bundle, not th const flowPath = path.join(root, 'flow.yaml'); fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- runFlow: included.yaml\n'); fs.writeFileSync(path.join(root, 'included.yaml'), '---\n- back\n'); - const bundle = replayScriptSourceBundleFor(flowPath, { replayBackend: 'maestro' }); + const bundle = await maestroScriptSourceBundleFor(flowPath); fs.rmSync(root, { recursive: true, force: true }); const invoked: string[] = []; diff --git a/src/daemon/handlers/__tests__/session-test-source-discovery.test.ts b/src/daemon/handlers/__tests__/session-test-source-discovery.test.ts index 4c4d3e7a9f..77cc030680 100644 --- a/src/daemon/handlers/__tests__/session-test-source-discovery.test.ts +++ b/src/daemon/handlers/__tests__/session-test-source-discovery.test.ts @@ -4,45 +4,52 @@ import fs from 'node:fs'; import path from 'node:path'; import { buildReplayTestSourceDiscovery } from '../session-test-source-discovery.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; -import { replayScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; +import { + maestroScriptSourceBundleFor, + replayScriptSourceBundleFor, +} from '../../../__tests__/test-utils/replay-script-source.ts'; // #1478 P3b: the daemon adapter's inspection capability. #1802 moved expansion and file reading // to the caller, so what is left here is exactly format routing plus per-engine manifest // inspection over the script sources the request carried — traversal order is pinned beside the // caller-side discovery, scheduler filtering policy in the replay-test package. -function inspect(files: string[], replayBackend?: string) { - return buildReplayTestSourceDiscovery( - files.map((filePath) => replayScriptSourceBundleFor(filePath, { replayBackend })), - replayBackend, - )(); +async function inspect(files: string[], replayBackend?: string) { + const bundles = await Promise.all( + files.map(async (filePath) => + replayBackend === 'maestro' + ? await maestroScriptSourceBundleFor(filePath) + : replayScriptSourceBundleFor(filePath), + ), + ); + return buildReplayTestSourceDiscovery(bundles, replayBackend)(); } -test('replay-test source inspection tags a Maestro flow caller-bound and carries its name', () => { +test('replay-test source inspection tags a Maestro flow caller-bound and carries its name', async () => { const root = mkdtempForTestSync('agent-device-test-inspect-maestro-'); const flowPath = path.join(root, '01-flow.yaml'); fs.writeFileSync(flowPath, 'appId: demo\nname: Bottom Tabs - Dynamic\n---\n- launchApp\n'); - const [entry] = inspect([flowPath], 'maestro'); + const [entry] = await inspect([flowPath], 'maestro'); assert.equal(entry?.path, flowPath); assert.equal(entry?.manifest.device.platform.kind, 'caller-bound'); assert.equal(entry?.manifest.title, 'Bottom Tabs - Dynamic'); }); -test('replay-test source inspection leaves a native .ad without metadata unspecified', () => { +test('replay-test source inspection leaves a native .ad without metadata unspecified', async () => { const root = mkdtempForTestSync('agent-device-test-inspect-native-'); const scriptPath = path.join(root, '03-flow.ad'); fs.writeFileSync(scriptPath, 'open "Demo"\n'); // Maestro mode routes by extension: a native source in a Maestro suite is still native, so it // declares no platform rather than inheriting the invocation's. - const [entry] = inspect([scriptPath], 'maestro'); + const [entry] = await inspect([scriptPath], 'maestro'); assert.equal(entry?.manifest.device.platform.kind, 'unspecified'); assert.equal(entry?.manifest.title, undefined); }); -test('replay-test source inspection reads declared platform, target and attempt defaults', () => { +test('replay-test source inspection reads declared platform, target and attempt defaults', async () => { const root = mkdtempForTestSync('agent-device-test-inspect-metadata-'); const scriptPath = path.join(root, 'flow.ad'); fs.writeFileSync( @@ -50,7 +57,7 @@ test('replay-test source inspection reads declared platform, target and attempt ['context platform=ios target=mobile timeout=4000 retries=2', 'open "Demo"', ''].join('\n'), ); - const [entry] = inspect([scriptPath]); + const [entry] = await inspect([scriptPath]); assert.deepEqual(entry?.manifest.device, { platform: { kind: 'declared', value: 'ios' }, @@ -59,7 +66,7 @@ test('replay-test source inspection reads declared platform, target and attempt assert.deepEqual(entry?.manifest.attemptDefaults, { timeoutMs: 4000, retries: 2 }); }); -test('replay-test source inspection keeps the order the caller discovered', () => { +test('replay-test source inspection keeps the order the caller discovered', async () => { const root = mkdtempForTestSync('agent-device-test-inspect-order-'); const second = path.join(root, '02-second.ad'); const first = path.join(root, '01-first.ad'); @@ -67,7 +74,7 @@ test('replay-test source inspection keeps the order the caller discovered', () = fs.writeFileSync(second, 'open "Second"\n'); assert.deepEqual( - inspect([second, first]).map((entry) => path.basename(entry.path)), + (await inspect([second, first])).map((entry) => path.basename(entry.path)), ['02-second.ad', '01-first.ad'], ); }); diff --git a/src/replay/__tests__/script-source-bundle.test.ts b/src/replay/__tests__/script-source-bundle.test.ts index 862efb8b2c..f798ad0bad 100644 --- a/src/replay/__tests__/script-source-bundle.test.ts +++ b/src/replay/__tests__/script-source-bundle.test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { expect, test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; import { - buildReplayScriptSourceBundle, + loadReplayScriptSourceBundle, readReplayScriptSourceFile, REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES, } from '../script-source-bundle.ts'; @@ -12,18 +12,18 @@ import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; // #1802: the caller reads every script file a replay run needs and sends the text. These pin the // collection itself; that the daemon consumes only the result is pinned beside the replay handler. -test('a native .ad bundle carries the entry script resolved against the caller cwd', () => { +test('a native .ad bundle carries the entry script resolved against the caller cwd', async () => { const root = mkdtempForTestSync('agent-device-bundle-native-'); const script = 'open "Demo"\nclick "Save"\n'; fs.writeFileSync(path.join(root, 'flow.ad'), script); - const bundle = buildReplayScriptSourceBundle({ inputPath: './flow.ad', cwd: root }); + const bundle = await loadReplayScriptSourceBundle({ inputPath: './flow.ad', cwd: root }); expect(bundle.entry).toBe(path.join(root, 'flow.ad')); expect(bundle.files).toEqual({ [path.join(root, 'flow.ad')]: script }); }); -test('a Maestro bundle carries the entry flow and its transitive runFlow includes', () => { +test('a Maestro bundle carries the entry flow and its transitive runFlow includes', async () => { const root = mkdtempForTestSync('agent-device-bundle-maestro-'); fs.mkdirSync(path.join(root, 'shared'), { recursive: true }); fs.writeFileSync( @@ -33,7 +33,7 @@ test('a Maestro bundle carries the entry flow and its transitive runFlow include fs.writeFileSync(path.join(root, 'shared', 'login.yaml'), '---\n- runFlow: ../tail.yaml\n'); fs.writeFileSync(path.join(root, 'tail.yaml'), '---\n- back\n'); - const bundle = buildReplayScriptSourceBundle({ + const bundle = await loadReplayScriptSourceBundle({ inputPath: './flow.yaml', cwd: root, replayBackend: 'maestro', @@ -51,10 +51,12 @@ test('a Maestro bundle carries the entry flow and its transitive runFlow include // The failure the reporter saw was an `ENOENT` for a path that exists on the caller. Reading on // the caller turns it into a normal INVALID_ARGS that names the path as typed, before any daemon // round-trip. -test('a missing entry script fails as INVALID_ARGS naming the path as typed', () => { +test('a missing entry script fails as INVALID_ARGS naming the path as typed', async () => { const root = mkdtempForTestSync('agent-device-bundle-missing-'); - expect(() => buildReplayScriptSourceBundle({ inputPath: './flows/login.ad', cwd: root })).toThrow( + await expect( + loadReplayScriptSourceBundle({ inputPath: './flows/login.ad', cwd: root }), + ).rejects.toThrow( expect.objectContaining({ code: 'INVALID_ARGS', message: 'replay script not found on this machine: ./flows/login.ad', @@ -62,14 +64,14 @@ test('a missing entry script fails as INVALID_ARGS naming the path as typed', () ); }); -test('an oversized bundle is refused with the limit named', () => { +test('an oversized bundle is refused with the limit named', async () => { const root = mkdtempForTestSync('agent-device-bundle-oversize-'); fs.writeFileSync( path.join(root, 'flow.ad'), `open "Demo"\n${'#'.repeat(REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES)}\n`, ); - expect(() => buildReplayScriptSourceBundle({ inputPath: './flow.ad', cwd: root })).toThrow( + await expect(loadReplayScriptSourceBundle({ inputPath: './flow.ad', cwd: root })).rejects.toThrow( expect.objectContaining({ code: 'INVALID_ARGS', message: expect.stringContaining(`over the ${REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES}-byte`), diff --git a/src/replay/script-source-bundle.ts b/src/replay/script-source-bundle.ts index bfd7b9e6e9..07ba00bfe3 100644 --- a/src/replay/script-source-bundle.ts +++ b/src/replay/script-source-bundle.ts @@ -1,7 +1,6 @@ import fs from 'node:fs'; import { AppError } from '@agent-device/kernel/errors'; import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; -import { collectMaestroFlowSources } from '@agent-device/maestro'; import { resolveUserPath } from '../utils/path-resolution.ts'; import { resolveReplayFormat } from './format.ts'; @@ -16,6 +15,14 @@ import { resolveReplayFormat } from './format.ts'; * moves the "no such script" failure to where the path means something: it is * raised against the caller's own filesystem, naming the path as typed, before * any daemon round-trip. + * + * `@agent-device/maestro` is loaded ON DEMAND, inside `loadReplayScriptSourceBundle`, and only + * when the entry resolves to a flow. The replay command family is part of the CLI's startup import + * closure (the command registry evaluates it for `--help` and every other invocation), and the + * Maestro engine drags its YAML parser in with it — statically importing it here cost ~28ms on + * every warm command and a 131 kB startup chunk. `src/__tests__/cli-startup-import-closure.test.ts` + * holds that line; ADR 0019 and #1681/#1641 are the same rule applied to `node:http` and the help + * fast path. */ /** @@ -35,26 +42,61 @@ export const REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES = 2 * 1024 * 1024; export const REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE = 'This replay request carries no script sources. Replay scripts are read by the client and sent with the request; upgrade the agent-device client that issued it to a version that sends script sources.'; -export function buildReplayScriptSourceBundle(params: { +export type ReplayScriptSourceRequest = { /** The script path as the caller typed it — used verbatim in a not-found error. */ inputPath: string; cwd: string; replayBackend?: string; /** `${VAR}` values that can resolve a Maestro `runFlow` include path before the run starts. */ env?: Readonly>; -}): ReplayScriptSourceBundle { +}; + +/** + * The one bundle constructor every client surface goes through (CLI, Node client, MCP — they all + * reach the daemon via the replay command family's writers). Async because the Maestro branch + * loads its engine on demand; the native `.ad` branch touches nothing beyond the entry file. + */ +export async function loadReplayScriptSourceBundle( + params: ReplayScriptSourceRequest, +): Promise { const entry = resolveUserPath(params.inputPath, { cwd: params.cwd }); const entrySource = readReplayEntryScript(entry, params.inputPath); - const files = - resolveReplayFormat(entry, params.replayBackend) === 'maestro' - ? collectMaestroFlowSources({ - entryPath: entry, - entrySource, - env: params.env, - readSource: tryReadScriptFile, - }) - : { [entry]: entrySource }; - assertBundleWithinLimit(files, params.inputPath); + if (resolveReplayFormat(entry, params.replayBackend) !== 'maestro') { + return finishBundle(entry, { [entry]: entrySource }, params.inputPath); + } + const { collectMaestroFlowSources } = await import('@agent-device/maestro'); + const files = collectMaestroFlowSources({ + entryPath: entry, + entrySource, + env: params.env, + readSource: tryReadScriptFile, + }); + return finishBundle(entry, files, params.inputPath); +} + +/** + * A native `.ad` script IS its own whole bundle — it has no include grammar — so this needs + * neither the Maestro engine nor an await. `loadReplayScriptSourceBundle` is the entry point + * callers use; this is the `.ad` half of it, exported so a test fixture can build the shape the + * client really sends without hand-rolling it. + */ +export function readAdScriptSourceBundle( + params: Pick, +): ReplayScriptSourceBundle { + const entry = resolveUserPath(params.inputPath, { cwd: params.cwd }); + return finishBundle( + entry, + { [entry]: readReplayEntryScript(entry, params.inputPath) }, + params.inputPath, + ); +} + +function finishBundle( + entry: string, + files: Record, + inputPath: string, +): ReplayScriptSourceBundle { + assertBundleWithinLimit(files, inputPath); return { entry, files }; } diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index aa775c1b6c..9b259787f7 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -16,6 +16,7 @@ import { LeaseRegistry } from '../../../src/daemon/lease-registry.ts'; import { SessionStore } from '../../../src/daemon/session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../../../src/daemon/types.ts'; import { runCmdBackground } from '../../../src/utils/exec.ts'; +import { withClientReplayScriptSources } from '../../../src/__tests__/test-utils/replay-script-source.ts'; import type { DeviceInventoryProvider, ProviderDeviceRuntime, @@ -131,7 +132,9 @@ export async function createProviderScenarioHarness( ...routerDeps, }); const handleRequest: typeof requestHandler = async (request) => { - const response = await requestHandler(request); + // #1802: a raw `callCommand('replay', [path])` stands in for a client request, and every + // request that reaches a daemon carries the script sources the CLIENT read. + const response = await requestHandler(await withClientReplayScriptSources(request)); assertNoInternalChromeProvenance(response); return response; }; From 1dcd0fcfe1e9fc660ccee146ab2bbc60095d9c85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 18 Aug 2026 14:24:44 +0200 Subject: [PATCH 4/4] refactor: share the replay request field vocabulary across the CLI and client views The new replay script-source flags appear in both CliFlags and CommandExecutionOptions, which fallow flagged as a clone; ReplayRequestFields declares them once. The test-suite handler's missing-sources rejection now travels the typed-error path its sibling rejections already use, so the fix adds no branch to handleSessionReplayCommands. --- packages/contracts/src/cli-flags.ts | 26 +---- packages/contracts/src/client-request.ts | 102 ++++++++---------- .../contracts/src/replay-request-fields.ts | 34 ++++++ src/daemon/handlers/session-replay.ts | 26 +++-- 4 files changed, 98 insertions(+), 90 deletions(-) create mode 100644 packages/contracts/src/replay-request-fields.ts diff --git a/packages/contracts/src/cli-flags.ts b/packages/contracts/src/cli-flags.ts index 1f37c9e793..811ee304f3 100644 --- a/packages/contracts/src/cli-flags.ts +++ b/packages/contracts/src/cli-flags.ts @@ -20,11 +20,12 @@ import type { } from './remote-config-fields.ts'; import type { ScreenshotRequestFlags } from './screenshot.ts'; import type { RecordingScope } from './recording-scope.ts'; -import type { ReplayScriptSourceBundle } from './replay.ts'; +import type { ReplayRequestFields } from './replay-request-fields.ts'; export type CliFlags = CloudProviderProfileFields & RemoteConfigMetroOptions & - ScreenshotRequestFlags & { + ScreenshotRequestFlags & + ReplayRequestFields & { json: boolean; config?: string; remoteConfig?: string; @@ -140,30 +141,9 @@ export type CliFlags = CloudProviderProfileFields & record?: boolean; retainPaths?: boolean; retentionMs?: number; - replayUpdate?: boolean; replayMaestro?: boolean; - replayEnv?: string[]; - replayShellEnv?: Record; - /** - * #1802: the caller-read script text `replay` executes. The daemon never - * opens a caller path, so this is the ONLY source a replay run reads. - */ - replayScriptSource?: ReplayScriptSourceBundle; - /** #1802: `test`'s caller-side discovery result — one bundle per discovered source, in run order. */ - replayScriptSources?: ReplayScriptSourceBundle[]; - replayFrom?: number; - replayPlanDigest?: string; - /** Replay: leave the session active by suppressing an authored terminal close in native .ad. */ - replayKeepSession?: boolean; - failFast?: boolean; - timeoutMs?: number; - retries?: number; - recordVideo?: boolean; - artifactsDir?: string; reporter?: string[]; reportJunit?: string; - shardAll?: number; - shardSplit?: number; steps?: string; stepsFile?: string; findFirst?: boolean; diff --git a/packages/contracts/src/client-request.ts b/packages/contracts/src/client-request.ts index b8706318cf..94f56bbac5 100644 --- a/packages/contracts/src/client-request.ts +++ b/packages/contracts/src/client-request.ts @@ -15,67 +15,51 @@ import type { SessionRuntimeHints, } from '@agent-device/kernel/contracts'; import type { DaemonBatchStep } from './batch-step.ts'; -import type { ReplayScriptSourceBundle } from './replay.ts'; +import type { ReplayRequestFields } from './replay-request-fields.ts'; import type { AgentDeviceClientConfig, AgentDeviceSelectionOptions } from './client-connection.ts'; -export type CommandExecutionOptions = Partial & { - positionals?: string[]; - kind?: string; - out?: string; - artifact?: string; - dsym?: string; - searchPath?: string; - interactiveOnly?: boolean; - depth?: number; - scope?: string; - raw?: boolean; - customActions?: boolean; - forceFull?: boolean; - count?: number; - fps?: number; - recordingScope?: RecordingScope; - quality?: RecordingExportQuality; - hideTouches?: boolean; - intervalMs?: number; - delayMs?: number; - durationMs?: number; - holdMs?: number; - jitterPx?: number; - pixels?: number; - doubleTap?: boolean; - verify?: boolean; - settle?: boolean; - settleQuietMs?: number; - clickButton?: ClickButton; - pauseMs?: number; - pattern?: SwipePattern; - headless?: boolean; - restart?: boolean; - replayUpdate?: boolean; - replayBackend?: string; - replayEnv?: string[]; - replayShellEnv?: Record; - /** #1802: caller-read script text for `replay`; the daemon never opens a caller path. */ - replayScriptSource?: ReplayScriptSourceBundle; - /** #1802: caller-side `test` discovery result — one bundle per discovered source, in run order. */ - replayScriptSources?: ReplayScriptSourceBundle[]; - replayFrom?: number; - replayPlanDigest?: string; - replayKeepSession?: boolean; - failFast?: boolean; - timeoutMs?: number; - retries?: number; - recordVideo?: boolean; - artifactsDir?: string; - shardAll?: number; - shardSplit?: number; - findFirst?: boolean; - findLast?: boolean; - networkInclude?: NetworkIncludeMode; - batchOnError?: 'stop'; - batchMaxSteps?: number; - batchSteps?: DaemonBatchStep[]; -}; +export type CommandExecutionOptions = Partial & + ReplayRequestFields & { + positionals?: string[]; + kind?: string; + out?: string; + artifact?: string; + dsym?: string; + searchPath?: string; + interactiveOnly?: boolean; + depth?: number; + scope?: string; + raw?: boolean; + customActions?: boolean; + forceFull?: boolean; + count?: number; + fps?: number; + recordingScope?: RecordingScope; + quality?: RecordingExportQuality; + hideTouches?: boolean; + intervalMs?: number; + delayMs?: number; + durationMs?: number; + holdMs?: number; + jitterPx?: number; + pixels?: number; + doubleTap?: boolean; + verify?: boolean; + settle?: boolean; + settleQuietMs?: number; + clickButton?: ClickButton; + pauseMs?: number; + pattern?: SwipePattern; + headless?: boolean; + restart?: boolean; + replayBackend?: string; + findFirst?: boolean; + findLast?: boolean; + networkInclude?: NetworkIncludeMode; + batchOnError?: 'stop'; + batchMaxSteps?: number; + batchSteps?: DaemonBatchStep[]; + }; export type InternalRequestOptions = AgentDeviceClientConfig & AgentDeviceSelectionOptions & diff --git a/packages/contracts/src/replay-request-fields.ts b/packages/contracts/src/replay-request-fields.ts new file mode 100644 index 0000000000..3c27d3ba6e --- /dev/null +++ b/packages/contracts/src/replay-request-fields.ts @@ -0,0 +1,34 @@ +import type { ReplayScriptSourceBundle } from './replay.ts'; + +/** + * The replay/test request vocabulary, declared once. + * + * `CliFlags` and `CommandExecutionOptions` are two views of the same request — the flags a CLI + * invocation parses and the options a programmatic call passes — and every replay/suite field + * appears in both. Stating them here keeps the two views from drifting field by field; each view + * adds only what is genuinely its own (`replayMaestro` and the reporter flags are CLI-side, + * `replayBackend` is the resolved engine the client sends). + */ +export type ReplayRequestFields = { + replayUpdate?: boolean; + replayEnv?: string[]; + replayShellEnv?: Record; + /** + * #1802: the caller-read script text `replay` executes. The daemon never opens a caller path, + * so this is the ONLY source a replay run reads. + */ + replayScriptSource?: ReplayScriptSourceBundle; + /** #1802: `test`'s caller-side discovery result — one bundle per discovered source, in run order. */ + replayScriptSources?: ReplayScriptSourceBundle[]; + replayFrom?: number; + replayPlanDigest?: string; + /** Replay: leave the session active by suppressing an authored terminal close in native .ad. */ + replayKeepSession?: boolean; + failFast?: boolean; + timeoutMs?: number; + retries?: number; + recordVideo?: boolean; + artifactsDir?: string; + shardAll?: number; + shardSplit?: number; +}; diff --git a/src/daemon/handlers/session-replay.ts b/src/daemon/handlers/session-replay.ts index 95408b7e06..28292add94 100644 --- a/src/daemon/handlers/session-replay.ts +++ b/src/daemon/handlers/session-replay.ts @@ -9,7 +9,7 @@ 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 { asAppError } from '@agent-device/kernel/errors'; +import { AppError, asAppError } from '@agent-device/kernel/errors'; import { emitRequestProgress } from '../../request/progress.ts'; import { clearRequestCanceled, @@ -184,19 +184,17 @@ export async function handleSessionReplayCommands(params: { // 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); } - // #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. - const sourceBundles = req.flags?.replayScriptSources; - if (!sourceBundles) { - return errorResponse('INVALID_ARGS', REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE); - } const sourceBundlesByPath = new Map(sourceBundles.map((bundle) => [bundle.entry, bundle])); const outcome = await runReplayTestSuite({ request: suiteRequest, @@ -375,6 +373,18 @@ function resolveReplayVideoRuntime(params: { * `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;