Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a545ded
refactor(replay-test): source the manifest device vocabulary from the…
claude Jul 31, 2026
2316361
refactor(replay-test): inject the progress sink instead of reading a …
claude Jul 31, 2026
9522911
refactor(replay-test): ask the host whether the suite is canceled
claude Jul 31, 2026
17b5855
refactor(replay-test): drop the dead request-tracking call from attem…
claude Jul 31, 2026
fd9cd27
refactor(replay-test): move cancellation binding and diagnostics to t…
claude Jul 31, 2026
2e291a5
refactor(replay-test): split discovery into host inspection and sched…
claude Jul 31, 2026
cf9e203
refactor(replay-test): build attempt ids from named segments; trim co…
claude Jul 31, 2026
a3c5550
refactor(replay-test): move shard device binding to the host
claude Jul 31, 2026
b3cbfb4
refactor(replay-test): extract packages/replay-test behind a façade
claude Jul 31, 2026
1c08be7
refactor(daemon): simplify replay-test request translation
claude Jul 31, 2026
7a4a691
test(live): share the replay test-suite harness across iOS and Android
claude Jul 31, 2026
35cb842
test: pin directory enumeration in the platform-binding suite test
claude Jul 31, 2026
0691345
test: scope the enumeration spy and restore it in a finally
claude Jul 31, 2026
2939e1b
fix(replay-test): put package tests where they are actually run
claude Jul 31, 2026
fab645f
style: format the facade after removing the unused export
claude Jul 31, 2026
e0380f1
fix(replay-test): typecheck the package and fix a type-only import
claude Jul 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
"check:unit": "pnpm check:contention-retry && pnpm test:unit && pnpm test:smoke",
"check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit",
"prepack": "pnpm check:mcp-metadata && pnpm package:npm",
"typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/maestro packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json",
"typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json",
"test-app:install": "pnpm install --dir examples/test-app",
"test-app:start": "pnpm --dir examples/test-app start",
"test-app:ios": "pnpm --dir examples/test-app ios",
Expand Down Expand Up @@ -250,6 +250,7 @@
"@agent-device/maestro": "workspace:*",
"@agent-device/provider-limrun": "workspace:*",
"@agent-device/provider-webdriver": "workspace:*",
"@agent-device/replay-test": "workspace:*",
"@agent-device/xml": "workspace:*",
"@chenglou/freerange": "^0.0.1",
"@stryker-mutator/core": "9.6.1",
Expand Down
18 changes: 18 additions & 0 deletions packages/replay-test/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "@agent-device/replay-test",
"version": "0.0.0",
"private": true,
"sideEffects": false,
"type": "module",
"description": "Private format-neutral replay-test scheduler for agent-device.",
"dependencies": {
"@agent-device/contracts": "workspace:*",
"@agent-device/kernel": "workspace:*"
},
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
}
}
47 changes: 47 additions & 0 deletions packages/replay-test/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* The replay-test package façade (#1478 P3).
*
* One function plus the values crossing its seam. Everything else — scheduling, retries,
* sharding distribution, attempt identity, timeout policy, finalization/cleanup ordering, and
* result aggregation — is private to `internal/`.
*
* The scheduler is format-agnostic: it imports neither engine, and a source reaches it only as
* a `ReplayTestManifest`. Host authority arrives as narrow capabilities on
* `ReplayTestRuntimeDependencies`; none of them hands over a daemon request, a session store,
* mutable session state, or an engine.
*/
export { runReplayTestSuite } from './internal/session-test.ts';

export type {
ReplayTestAttemptError,
ReplayTestAttemptFailed,
ReplayTestAttemptOutcome,
ReplayTestAttemptPassed,
ReplayTestAttemptStep,
ReplayTestAttemptStepSink,
ReplayTestBindAttemptCancellation,
ReplayTestAttemptCancellation,
ReplayTestCleanupSession,
ReplayTestDiscoverSources,
ReplayTestEmitDiagnostic,
ReplayTestEmitProgress,
ReplayTestExecutionDependencies,
ReplayTestFinalizeAttempt,
ReplayTestIsCanceled,
ReplayTestManifest,
ReplayTestPlatform,
ReplayTestRunReplay,
ReplayTestRunReplayParams,
ReplayTestRuntimeDependencies,
ReplayTestSource,
ReplayTestSuiteOutcome,
ReplayTestSuiteRequest,
ReplayTestTarget,
} from './internal/session-test-types.ts';

export type {
ReplayTestResolveShardTargets,
ReplayTestShardContext,
ReplayTestShardMode,
ReplayTestShardTarget,
} from './internal/session-test-sharding.ts';
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,20 @@ import {
materializeReplayTestAttemptArtifacts,
prepareReplayTestAttemptArtifacts,
} from '../session-test-artifacts.ts';
import { toReplayTestAttemptOutcome } from '../session-test-outcome.ts';
import type { DaemonResponse } from '../../types.ts';
import type { ReplayTestAttemptOutcome } from '../session-test-types.ts';

// Building outcomes from a DaemonResponse is the adapter's job and is pinned on that side; a
// package test states the neutral outcome directly (#1478 P3b).
const passedOutcome = (
overrides: Partial<Extract<ReplayTestAttemptOutcome, { status: 'passed' }>> = {},
): ReplayTestAttemptOutcome => ({
status: 'passed',
replayed: 1,
healed: 0,
warnings: [],
artifactPaths: [],
...overrides,
});

test('materializeReplayTestAttemptArtifacts writes replay and result manifests for passing attempts', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-artifacts-pass-'));
Expand All @@ -19,16 +31,8 @@ test('materializeReplayTestAttemptArtifacts writes replay and result manifests f
fs.writeFileSync(screenshotPath, 'png');

prepareReplayTestAttemptArtifacts(replayPath, attemptDir);
const response: DaemonResponse = {
ok: true,
data: {
replayed: 4,
healed: 1,
artifactPaths: [screenshotPath],
},
};
materializeReplayTestAttemptArtifacts({
outcome: toReplayTestAttemptOutcome(response),
outcome: passedOutcome({ replayed: 4, healed: 1, artifactPaths: [screenshotPath] }),
filePath: replayPath,
sessionName: 'default:test:suite:1',
attempts: 1,
Expand Down Expand Up @@ -70,21 +74,19 @@ test('materializeReplayTestAttemptArtifacts writes failure manifest and copies l
fs.writeFileSync(logPath, 'log');

prepareReplayTestAttemptArtifacts(replayPath, attemptDir);
const response: DaemonResponse = {
ok: false,
error: {
code: 'COMMAND_FAILED',
message: 'TIMEOUT after 5000ms',
hint: 'Replay test timeouts are cooperative.',
logPath,
details: {
reason: 'timeout',
artifactPaths: [screenshotPath],
materializeReplayTestAttemptArtifacts({
outcome: {
status: 'failed',
error: {
code: 'COMMAND_FAILED',
message: 'Replay test timed out',
hint: 'Replay test timeouts are cooperative.',
logPath,
details: { reason: 'timeout', artifactPaths: [screenshotPath] },
},
artifactPaths: [screenshotPath],
infrastructure: false,
},
};
materializeReplayTestAttemptArtifacts({
outcome: toReplayTestAttemptOutcome(response),
filePath: replayPath,
sessionName: 'default:test:suite:2',
attempts: 2,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { AppError } from '@agent-device/kernel/errors';
import { discoverReplayTestEntries } from '../session-test-discovery.ts';
import type { ReplayTestManifest, ReplayTestSource } from '../session-test-types.ts';

// Scheduler-owned discovery policy (#1478 P3b): which sources a --platform filter runs, which
// it skips and with what message, and rejecting a suite that matched nothing. Inspection is the
// host's, so these drive fake sources rather than the filesystem — the ordering and routing
// behavior they used to share a file with is pinned host-side.
const declared = (value: 'ios' | 'android'): ReplayTestManifest => ({
device: { platform: { kind: 'declared', value } },
});
const unspecified: ReplayTestManifest = { device: { platform: { kind: 'unspecified' } } };
const callerBound: ReplayTestManifest = { device: { platform: { kind: 'caller-bound' } } };

const sourcesOf =
(...entries: ReplayTestSource[]) =>
() =>
entries;

test('platform filter skips sources that declared no platform', () => {
const entries = discoverReplayTestEntries({
inputs: ['suite'],
platformFilter: 'android',
discoverSources: sourcesOf(
{ path: '01-untyped.ad', manifest: unspecified },
{ path: '02-android.ad', manifest: declared('android') },
),
});

const untyped = entries.find((entry) => entry.path === '01-untyped.ad');
assert.equal(untyped?.kind, 'skip');
if (untyped?.kind === 'skip') {
assert.match(untyped.message, /missing platform metadata for --platform android/);
}
assert.equal(entries.find((entry) => entry.path === '02-android.ad')?.kind, 'run');
});

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 }),
});

assert.equal(entries.length, 1);
assert.equal(entries[0]?.kind, 'run');
});

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') },
{ path: '02-android.ad', manifest: declared('android') },
),
});

assert.deepEqual(
entries.map((entry) => entry.path),
['02-android.ad'],
);
});

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') }),
}),
(error: unknown) => error instanceof AppError && /No replay tests matched/.test(error.message),
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,36 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, expect, test, vi } from 'vitest';
import { isRequestCanceled } from '../../../request/cancel.ts';

import { runReplayTestAttempt } from '../session-test-runtime.ts';

import type { ReplayTestAttemptOutcome } from '../session-test-types.ts';

// What the scheduler owes its host around cancellation (#1478 P3b): cancel exactly once when
// an attempt times out, and always release when it settles. How the daemon then maps that onto
// its request registry is the adapter's contract, pinned in
// `src/daemon/handlers/__tests__/session-replay-cancellation.test.ts`.
const cancellations: Array<{ attemptId: string; canceled: number; released: number }> = [];

function trackCancellation() {
cancellations.length = 0;
return {
emitDiagnostic: () => {},
bindAttemptCancellation: ({ attemptId }: { attemptId: string }) => {
const record = { attemptId, canceled: 0, released: 0 };
cancellations.push(record);
return {
cancel: () => {
record.canceled += 1;
},
release: () => {
record.released += 1;
},
};
},
};
}

const PASSED: ReplayTestAttemptOutcome = {
status: 'passed',
replayed: 1,
Expand Down Expand Up @@ -57,6 +83,7 @@ test('runReplayTestAttempt keeps cancellation active until a timed-out replay se
runReplay: async () => await replayPromise,
finalizeAttempt,
cleanupSession,
...trackCancellation(),
});

await vi.advanceTimersByTimeAsync(10);
Expand All @@ -80,7 +107,7 @@ test('runReplayTestAttempt keeps cancellation active until a timed-out replay se
}),
);
expect(lifecycleEvents).toEqual(['finalize', 'cleanup']);
expect(isRequestCanceled('req-timeout-open')).toBe(true);
expect(cancellations[0]?.canceled).toBe(1);
// #1478 P3: the second cleanup is strictly deferred until the abandoned replay settles, so
// the attempt returns having cleaned up exactly once. P3 keeps this orchestration inside
// replay-test while the cleanup effect itself stays in the daemon adapter.
Expand All @@ -94,7 +121,7 @@ test('runReplayTestAttempt keeps cancellation active until a timed-out replay se
});
await replaySettled;
await vi.waitFor(() => {
expect(isRequestCanceled('req-timeout-open')).toBe(false);
expect(cancellations[0]?.released).toBe(1);
});
await vi.waitFor(() => {
expect(cleanupSession).toHaveBeenCalledTimes(2);
Expand All @@ -118,6 +145,7 @@ test('runReplayTestAttempt keeps a passing replay passed when finalization fails
infrastructure: false,
}),
cleanupSession,
...trackCancellation(),
});

expect(result.status).toBe('passed');
Expand Down Expand Up @@ -149,6 +177,7 @@ test('runReplayTestAttempt finalizes before cleanup and records that order in th
cleanupSession: async () => {
lifecycleEvents.push('cleanup');
},
...trackCancellation(),
});

expect(result.status).toBe('passed');
Expand Down Expand Up @@ -188,6 +217,7 @@ test('runReplayTestAttempt cleans up once when a timed-out replay settles inside
return undefined;
},
cleanupSession,
...trackCancellation(),
});

await vi.advanceTimersByTimeAsync(10);
Expand Down Expand Up @@ -225,6 +255,7 @@ test('runReplayTestAttempt cleans up without a finalizer and adds no finalizatio
requestId: 'req-no-finalizer',
runReplay: async () => PASSED,
cleanupSession,
...trackCancellation(),
});

expect(result.status).toBe('passed');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { isMaestroYamlPath } from '../../replay/format.ts';
import type { ReplayTestAttemptOutcome } from './session-test-types.ts';
import { SessionStore } from '../session-store.ts';
import type { ReplayTestAttemptOutcome } from '@agent-device/replay-test';

const DEFAULT_TEST_ARTIFACTS_ROOT = '.agent-device/test-artifacts';

Expand All @@ -12,7 +10,9 @@ export function resolveReplayTestArtifactsDir(params: {
suiteInvocationId: string;
}): string {
const { artifactsDir, cwd, suiteInvocationId } = params;
const resolvedRoot = SessionStore.expandHome(artifactsDir ?? DEFAULT_TEST_ARTIFACTS_ROOT, cwd);
// `artifactsDir` arrives already home-expanded: resolving `~` and the caller's cwd is host
// work, done once when the daemon builds the suite request.
const resolvedRoot = path.resolve(cwd ?? '.', artifactsDir ?? DEFAULT_TEST_ARTIFACTS_ROOT);
return path.join(resolvedRoot, suiteInvocationId);
}

Expand Down Expand Up @@ -107,7 +107,10 @@ function copyReplaySourceFile(filePath: string, attemptArtifactsDir: string): vo
const genericReplayPath = path.join(attemptArtifactsDir, 'replay.ad');
fs.copyFileSync(filePath, genericReplayPath);

if (!isMaestroYamlPath(filePath)) return;
// Sources that are not native `.ad` keep their original filename alongside `replay.ad`, so
// CI artifacts point back at the flow the author wrote. This is artifact naming, not format
// routing — it asks what the file is called, never which engine will run it.
if (path.extname(filePath).toLowerCase() === '.ad') return;
// Keep replay.ad for existing artifact consumers, and preserve the original
// Maestro filename so CI artifacts point back to the source flow.
const originalReplayPath = path.join(attemptArtifactsDir, path.basename(filePath));
Expand Down
Loading
Loading