Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 5 additions & 4 deletions scripts/__tests__/eager-closure-budgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,14 @@ export function discoverFacadeEntryFiles(repoRoot: string): string[] {
}

/**
* Designated hubs: entry points whose closure the whole suite or every CLI run pays for.
* `src/platform-runtime.ts` is the ADR-0019 composition root, the one production module allowed
* to value-import a concrete platform package; its no-growth rule is also the assertion that
* composing the registry stays metadata-eager.
* Designated hubs: entry points whose closure the whole suite, every CLI run, or every daemon start
* pays for. `src/platform-runtime.ts` is the ADR-0019 composition root, the one production module
* allowed to value-import a concrete platform package; its no-growth rule is also the assertion
* that composing the registry stays metadata-eager.
*/
export const HUB_ENTRY_FILES: readonly string[] = [
'src/cli.ts',
'src/daemon.ts',
'src/platform-runtime.ts',
'src/core/interactors/register-builtins.ts',
'src/daemon/session-teardown.ts',
Expand Down
71 changes: 65 additions & 6 deletions src/daemon/__tests__/replay-device-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,25 @@ import { test, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { parseReplayInput } from '@agent-device/ad-script';
import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay';
import { buildReplayTargetDeviceResolution } from '../replay-device-selection.ts';
import { buildReplayScriptPlatformFlags } from '@agent-device/replay-port/replay-script-selection';
import {
appTargetResolutionOptions,
buildReplayScriptPlatformFlags,
} from '@agent-device/replay-port/replay-script-selection';
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';

test('replay leaves deep-link opens to normal device resolution', () => {
test('replay leaves deep-link opens to normal device resolution', async () => {
const root = mkdtempForTestSync('agent-device-replay-device-selection-');
const replayPath = path.join(root, 'deep-link.ad');
fs.writeFileSync(replayPath, 'open demo://checkout\n');

expect(
buildReplayTargetDeviceResolution({
await buildReplayTargetDeviceResolution({
token: 'test-token',
session: 'default',
command: 'replay',
Expand All @@ -36,15 +43,15 @@ test('native replay applies its authored platform before a first deep link', ()
).toEqual({ platform: 'ios' });
});

test('native replay uses its authored Android runtime setting without an iOS app probe', () => {
test('native replay uses its authored Android runtime setting without an iOS app probe', async () => {
const root = mkdtempForTestSync('agent-device-replay-device-selection-');
const replayPath = path.join(root, 'android.ad');
fs.writeFileSync(
replayPath,
'runtime set --platform android --metro-port 8081\nopen com.example.demo\n',
);

const resolution = buildReplayTargetDeviceResolution({
const resolution = await buildReplayTargetDeviceResolution({
token: 'test-token',
session: 'default',
command: 'replay',
Expand All @@ -58,3 +65,55 @@ test('native replay uses its authored Android runtime setting without an iOS app
expect(resolution?.flags.platform).toBe('android');
expect(resolution?.options).toBeUndefined();
});

test('a replay whose wire bundle has a malformed entry stays advisory', async () => {
// The HTTP boundary validates `flags` only as an object, so a remote caller
// can deliver a bundle without a string `entry`. #1802 keeps lock binding
// advisory: the probe must fall back to normal device resolution, and the
// replay handler stays the one to reject the request.
const malformedBundles = [
// A missing entry fails the bundle read itself.
{ entry: undefined, files: {} },
// A non-string entry survives the read — `files` is keyed by the coerced
// string — and reaches format resolution, where `path.extname` rejects it.
{ entry: 42, files: { '42': 'open demo://checkout\n' } },
] as unknown as ReplayScriptSourceBundle[];

for (const bundle of malformedBundles) {
await expect(
buildReplayTargetDeviceResolution({
token: 'test-token',
session: 'default',
command: 'replay',
positionals: [],
flags: {
replayBackend: 'maestro',
replayScriptSource: bundle,
},
meta: { cwd: mkdtempForTestSync('agent-device-replay-device-selection-') },
}),
).resolves.toBeUndefined();
}
});

test('a Maestro flow pre-binds an iOS replay to its static appId', async () => {
const root = mkdtempForTestSync('agent-device-replay-device-selection-');
const flowPath = path.join(root, 'checkout.yaml');
fs.writeFileSync(flowPath, 'appId: com.example.demo\n---\n- launchApp\n');

const resolution = await buildReplayTargetDeviceResolution({
token: 'test-token',
session: 'default',
command: 'replay',
positionals: [flowPath],
flags: {
platform: 'ios',
replayBackend: 'maestro',
replayScriptSource: await maestroScriptSourceBundleFor(flowPath),
},
meta: { cwd: root },
});

expect(resolution?.options).toEqual(appTargetResolutionOptions('com.example.demo'));
expect(resolution?.options).toBeDefined();
});
71 changes: 48 additions & 23 deletions src/daemon/replay-device-selection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { inspectMaestroFlow } from '@agent-device/maestro';
import { parseReplayInput, resolveReplayFormat } from '@agent-device/ad-script';
import type { ResolveTargetDeviceOptions } from '@agent-device/device-selection/dispatch-resolve';
import type { CommandFlags } from '@agent-device/contracts/command';
Expand All @@ -24,40 +23,66 @@ export type ReplayTargetDeviceResolution = {
* 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.
*
* The Maestro engine loads only once the entry resolves to a flow: request
* binding is in the daemon's startup closure, and most replays are `.ad`.
*/
export function buildReplayTargetDeviceResolution(
export async function buildReplayTargetDeviceResolution(
req: DaemonRequest,
): ReplayTargetDeviceResolution | undefined {
): Promise<ReplayTargetDeviceResolution | undefined> {
if (req.command !== 'replay' || req.flags?.replayFrom !== undefined) return undefined;
const bundle = req.flags?.replayScriptSource;
if (!bundle) return undefined;

try {
return readAdvisoryResolution(async () => {
const resolved = bundle.entry;
const source = readReplayScriptSourceFile(bundle, resolved);
if (resolveReplayFormat(resolved, req.flags?.replayBackend) === 'maestro') {
const flow = inspectMaestroFlow(source, resolved);
return {
flags: req.flags ?? {},
options: buildMaestroReplayTargetDeviceResolutionOptions(
flow.appTarget,
req.flags?.platform,
),
};
return await readMaestroReplayResolution(source, resolved, req.flags);
}
const parsed = parseReplayInput(source, req.flags);
const selection = readScriptReplaySelection(parsed.actions);
if (!selection.appTarget) return undefined;
const scriptFlags = buildReplayScriptPlatformFlags(req.flags, parsed.actions);
const platform = scriptFlags.platform ?? parsed.metadata.platform;
return {
flags:
platform && scriptFlags.platform === undefined ? { ...scriptFlags, platform } : scriptFlags,
options: platform === 'ios' ? appTargetResolutionOptions(selection.appTarget) : undefined,
};
return readAdScriptResolution(source, req.flags);
});
}

async function readMaestroReplayResolution(
source: string,
resolvedPath: string,
flags: DaemonRequest['flags'],
): Promise<ReplayTargetDeviceResolution> {
const { inspectMaestroFlow } = await import('@agent-device/maestro');
const flow = inspectMaestroFlow(source, resolvedPath);
return {
flags: flags ?? {},
options: buildMaestroReplayTargetDeviceResolutionOptions(flow.appTarget, flags?.platform),
};
}

function readAdScriptResolution(
source: string,
flags: DaemonRequest['flags'],
): ReplayTargetDeviceResolution | undefined {
const parsed = parseReplayInput(source, flags);
const selection = readScriptReplaySelection(parsed.actions);
if (!selection.appTarget) return undefined;
const scriptFlags = buildReplayScriptPlatformFlags(flags, parsed.actions);
const platform = scriptFlags.platform ?? parsed.metadata.platform;
return {
flags:
platform && scriptFlags.platform === undefined ? { ...scriptFlags, platform } : scriptFlags,
options: platform === 'ios' ? appTargetResolutionOptions(selection.appTarget) : undefined,
};
}

async function readAdvisoryResolution(
read: () => Promise<ReplayTargetDeviceResolution | undefined>,
): Promise<ReplayTargetDeviceResolution | undefined> {
try {
return await read();
} catch {
// Parsing and validation stay in the replay handler. Lock binding is only
// advisory, so an unreadable/invalid plan must not mask its real error.
// advisory, so an unreadable/invalid plan must not mask its real error. The
// whole probe sits behind this guard, including the wire bundle's entry and
// format, which the request boundary only checks as an object.
return undefined;
}
}
13 changes: 7 additions & 6 deletions src/daemon/request-binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,15 @@ function resolveFreshSessionBindingRequest(req: DaemonRequest): DaemonRequest {
}
}

function resolveFreshSessionDeviceLock(req: DaemonRequest):
async function resolveFreshSessionDeviceLock(req: DaemonRequest): Promise<
| {
flags: NonNullable<DaemonRequest['flags']>;
options: ReturnType<typeof buildOpenTargetDeviceResolutionOptions> | undefined;
}
| undefined {
| undefined
> {
if (req.command === 'open') return resolveOpenDeviceLock(req);
if (req.command === 'replay') return resolveReplayDeviceLock(req);
if (req.command === 'replay') return await resolveReplayDeviceLock(req);
return resolveExplicitDeviceLock(req);
}

Expand All @@ -101,7 +102,7 @@ function resolveFreshSessionDeviceLock(req: DaemonRequest):
* happens after: the locked request resolves and binds its device on its own.
*/
async function resolveFreshSessionDevice(req: DaemonRequest): Promise<DeviceInfo | undefined> {
const resolution = resolveFreshSessionDeviceLock(resolveFreshSessionBindingRequest(req));
const resolution = await resolveFreshSessionDeviceLock(resolveFreshSessionBindingRequest(req));
if (!resolution) return undefined;
try {
return await resolveTargetDevice(resolution.flags, resolution.options);
Expand All @@ -116,8 +117,8 @@ function resolveOpenDeviceLock(req: DaemonRequest) {
return options ? { flags: req.flags ?? {}, options } : undefined;
}

function resolveReplayDeviceLock(req: DaemonRequest) {
return buildReplayTargetDeviceResolution(req) ?? resolveExplicitDeviceLock(req);
async function resolveReplayDeviceLock(req: DaemonRequest) {
return (await buildReplayTargetDeviceResolution(req)) ?? resolveExplicitDeviceLock(req);
}

function resolveExplicitDeviceLock(req: DaemonRequest) {
Expand Down
Loading