Skip to content

Commit aca9637

Browse files
committed
fix: stabilize maestro ci contracts
1 parent 2f5ee56 commit aca9637

8 files changed

Lines changed: 174 additions & 49 deletions

File tree

src/compat/maestro/runtime-port-observation.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,26 +95,42 @@ function isRect(value: unknown): value is { x: number; y: number; width: number;
9595
}
9696

9797
function validateTargetMatch(match: MaestroTargetMatch, generation: number): MaestroTargetMatch {
98+
assertMatchingGeneration(match, generation);
99+
assertValidCandidateCount(match);
100+
assertValidGeometry(match);
101+
assertValidSurfaceSignature(match);
102+
return match;
103+
}
104+
105+
function assertMatchingGeneration(match: MaestroTargetMatch, generation: number): void {
98106
if (match.generation !== generation) {
99107
throw new AppError(
100108
'COMMAND_FAILED',
101109
`Maestro target evidence generation ${match.generation} does not match ${generation}.`,
102110
);
103111
}
112+
}
113+
114+
function assertValidCandidateCount(match: MaestroTargetMatch): void {
104115
if (!Number.isInteger(match.candidateCount) || match.candidateCount < 0) {
105116
throw new AppError('COMMAND_FAILED', 'Maestro target evidence has an invalid candidate count.');
106117
}
118+
}
119+
120+
function assertValidGeometry(match: MaestroTargetMatch): void {
107121
if (
108122
(match.rect !== undefined && !isRect(match.rect)) ||
109123
(match.viewport !== undefined && !isRect(match.viewport))
110124
) {
111125
throw new AppError('COMMAND_FAILED', 'Maestro target evidence has invalid geometry.');
112126
}
127+
}
128+
129+
function assertValidSurfaceSignature(match: MaestroTargetMatch): void {
113130
if (match.surfaceSignature !== undefined && typeof match.surfaceSignature !== 'string') {
114131
throw new AppError(
115132
'COMMAND_FAILED',
116133
'Maestro target evidence has an invalid surface signature.',
117134
);
118135
}
119-
return match;
120136
}

src/compat/maestro/runtime-target-matching.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import type { SnapshotNode, SnapshotState } from '../../kernel/snapshot.ts';
2+
import {
3+
buildSnapshotNodeByIndex,
4+
isDescendantOfSnapshotNode,
5+
} from '../../snapshot/snapshot-processing.ts';
26
import type { MaestroSelector } from './program-ir.ts';
37
import { matchesMaestroTypedSelector } from './runtime-target-policy.ts';
48

@@ -9,3 +13,22 @@ export function findMaestroTypedSelectorMatches(
913
): SnapshotNode[] {
1014
return snapshot.nodes.filter((node) => matchesMaestroTypedSelector(node, selector));
1115
}
16+
17+
export function scopeMaestroMatchesByAncestor(
18+
snapshot: SnapshotState,
19+
matches: SnapshotNode[],
20+
childOf: MaestroSelector | undefined,
21+
): { matches: SnapshotNode[]; parentMatched: boolean } {
22+
if (!childOf) return { matches, parentMatched: true };
23+
const parents = findMaestroTypedSelectorMatches(snapshot, childOf);
24+
if (parents.length === 0) return { matches: [], parentMatched: false };
25+
const nodeByIndex = buildSnapshotNodeByIndex(snapshot.nodes);
26+
return {
27+
matches: matches.filter((node) =>
28+
parents.some((parent) =>
29+
isDescendantOfSnapshotNode(snapshot.nodes, node, parent, nodeByIndex),
30+
),
31+
),
32+
parentMatched: true,
33+
};
34+
}

src/compat/maestro/runtime-targets.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,11 @@ import {
55
type SnapshotState,
66
} from '../../kernel/snapshot.ts';
77
import { presentIosInteractiveSnapshot } from '../../daemon/snapshot-presentation/ios/index.ts';
8-
import {
9-
buildSnapshotNodeByIndex,
10-
isDescendantOfSnapshotNode,
11-
} from '../../snapshot/snapshot-processing.ts';
128
import type { MaestroSelector } from './program-ir.ts';
13-
import { findMaestroTypedSelectorMatches } from './runtime-target-matching.ts';
9+
import {
10+
findMaestroTypedSelectorMatches,
11+
scopeMaestroMatchesByAncestor,
12+
} from './runtime-target-matching.ts';
1413
import { filterVisibleMaestroMatches, type MaestroPlatform } from './runtime-target-policy.ts';
1514
import {
1615
normalizeMaestroSnapshotMatches,
@@ -51,20 +50,15 @@ export function resolveMaestroTargetFromSnapshot(
5150
): MaestroTargetResolution {
5251
let matches = findMaestroTypedSelectorMatches(snapshot, query.selector);
5352
if (query.childOf) {
54-
const parents = findMaestroTypedSelectorMatches(snapshot, query.childOf);
55-
if (parents.length === 0) {
53+
const scoped = scopeMaestroMatchesByAncestor(snapshot, matches, query.childOf);
54+
if (!scoped.parentMatched) {
5655
return {
5756
ok: false,
5857
message: 'Maestro childOf parent did not match.',
5958
evidence: buildMaestroTargetEvidence(query, matches, [], undefined),
6059
};
6160
}
62-
const nodeByIndex = buildSnapshotNodeByIndex(snapshot.nodes);
63-
matches = matches.filter((node) =>
64-
parents.some((parent) =>
65-
isDescendantOfSnapshotNode(snapshot.nodes, node, parent, nodeByIndex),
66-
),
67-
);
61+
matches = scoped.matches;
6862
}
6963

7064
const visible = filterVisibleMaestroMatches({ nodes: snapshot.nodes, matches, platform });

src/core/dispatch-context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,6 @@ export type DispatchContext = ScreenshotDispatchFlags & {
8181
value: string;
8282
raw: string;
8383
allowNonHittableCoordinateFallback?: boolean;
84+
expectedPoint?: Point;
8485
};
8586
};

src/daemon/handlers/session-replay-maestro-failure.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,17 @@ import { formatMaestroCommandProgress } from '../../compat/maestro/progress.ts';
33
import type { MaestroCommand, MaestroSelector } from '../../compat/maestro/program-ir.ts';
44
import { evaluateMaestroReplayResume } from '../../compat/maestro/replay-plan.ts';
55
import type { MaestroReplayPlan } from '../../compat/maestro/replay-plan-types.ts';
6-
import { findMaestroTypedSelectorMatches } from '../../compat/maestro/runtime-target-matching.ts';
6+
import {
7+
findMaestroTypedSelectorMatches,
8+
scopeMaestroMatchesByAncestor,
9+
} from '../../compat/maestro/runtime-target-matching.ts';
710
import {
811
filterVisibleMaestroMatches,
912
matchesMaestroTypedSelector,
1013
} from '../../compat/maestro/runtime-target-policy.ts';
1114
import { normalizeMaestroSnapshotMatches } from '../../compat/maestro/runtime-target-ranking.ts';
1215
import type { DaemonError } from '../../kernel/contracts.ts';
1316
import type { SnapshotNode } from '../../kernel/snapshot.ts';
14-
import {
15-
buildSnapshotNodeByIndex,
16-
isDescendantOfSnapshotNode,
17-
} from '../../snapshot/snapshot-processing.ts';
1817
import {
1918
REPLAY_DIVERGENCE_SUGGESTION_LIMIT,
2019
createReplayDivergenceSanitizer,
@@ -236,14 +235,7 @@ function collectTypedMaestroCandidates(
236235
): SnapshotNode[] {
237236
let matches = findMaestroTypedSelectorMatches(snapshot, query.selector);
238237
if (query.childOf) {
239-
const parents = findMaestroTypedSelectorMatches(snapshot, query.childOf);
240-
if (parents.length === 0) return [];
241-
const nodeByIndex = buildSnapshotNodeByIndex(snapshot.nodes);
242-
matches = matches.filter((node) =>
243-
parents.some((parent) =>
244-
isDescendantOfSnapshotNode(snapshot.nodes, node, parent, nodeByIndex),
245-
),
246-
);
238+
matches = scopeMaestroMatchesByAncestor(snapshot, matches, query.childOf).matches;
247239
}
248240

249241
const visible = filterVisibleMaestroMatches({

src/daemon/handlers/session-replay-maestro-runtime.ts

Lines changed: 116 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ type TypedMaestroReplayState = {
6464
type TypedMaestroReplayContext = {
6565
filePath: string;
6666
program: MaestroProgram;
67-
device: DeviceInfo;
67+
device?: DeviceInfo;
6868
platform: Extract<MaestroPlatform, 'android' | 'ios'>;
6969
target: string;
7070
runtimeHints: ReturnType<typeof resolveEffectiveOpenRuntimeHints>;
@@ -74,6 +74,11 @@ type TypedMaestroReplayContext = {
7474
loadProgram: ReturnType<typeof createMaestroProgramLoader>;
7575
};
7676

77+
type MaestroReplayBinding = Pick<
78+
TypedMaestroReplayContext,
79+
'device' | 'platform' | 'target' | 'runtimeHints'
80+
>;
81+
7782
export async function runTypedMaestroReplayFile(
7883
params: TypedMaestroReplayParams,
7984
): Promise<DaemonResponse> {
@@ -177,36 +182,89 @@ async function prepareTypedMaestroReplay(
177182
});
178183
const session = sessionStore.get(sessionName);
179184
if (session) assertSessionSelectorMatches(session, req.flags);
180-
const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
185+
const binding = await resolveMaestroReplayBinding({ req, sessionStore, sessionName, session });
186+
return {
187+
filePath,
188+
program,
189+
...binding,
190+
defaults: buildTypedMaestroDefaults({
191+
req,
192+
sessionName,
193+
filePath,
194+
platform: binding.platform,
195+
target: binding.target,
196+
}),
197+
env: buildTypedMaestroEnv(req),
198+
signal: getRequestSignal(req.meta?.requestId),
199+
loadProgram: createMaestroProgramLoader(path.dirname(filePath)),
200+
};
201+
}
202+
203+
async function resolveMaestroReplayBinding(params: {
204+
req: DaemonRequest;
205+
sessionStore: SessionStore;
206+
sessionName: string;
207+
session: ReturnType<SessionStore['get']>;
208+
}): Promise<MaestroReplayBinding> {
209+
const { req, sessionStore, sessionName, session } = params;
210+
const requestedPlatform = req.flags?.platform;
211+
const device =
212+
session?.device ??
213+
(requestedPlatform === 'android' || requestedPlatform === 'ios'
214+
? undefined
215+
: await resolveTargetDevice(req.flags ?? {}));
181216
const platform = resolveMaestroPlatform(req, device);
182-
const target = resolveMaestroTarget(req, device);
183217
const runtimeHints = resolveEffectiveOpenRuntimeHints({
184218
req,
185219
sessionStore,
186220
sessionName,
187221
device,
188222
platform,
189223
});
190-
return {
191-
filePath,
192-
program,
224+
return await completeMaestroRuntimeBinding({
225+
req,
226+
sessionStore,
227+
sessionName,
193228
device,
194229
platform,
195-
target,
230+
target: resolveMaestroTarget(req, device),
196231
runtimeHints,
197-
defaults: buildTypedMaestroDefaults({
198-
req,
199-
sessionName,
200-
filePath,
201-
platform,
202-
target,
232+
});
233+
}
234+
235+
async function completeMaestroRuntimeBinding(
236+
params: {
237+
req: DaemonRequest;
238+
sessionStore: SessionStore;
239+
sessionName: string;
240+
} & MaestroReplayBinding,
241+
): Promise<MaestroReplayBinding> {
242+
if (params.device || !requiresDeviceRuntimeDefaults(params.runtimeHints)) return params;
243+
const device = await resolveTargetDevice(params.req.flags ?? {});
244+
return {
245+
device,
246+
platform: params.platform,
247+
target: resolveMaestroTarget(params.req, device),
248+
runtimeHints: resolveEffectiveOpenRuntimeHints({
249+
req: params.req,
250+
sessionStore: params.sessionStore,
251+
sessionName: params.sessionName,
252+
device,
253+
platform: params.platform,
203254
}),
204-
env: buildTypedMaestroEnv(req),
205-
signal: getRequestSignal(req.meta?.requestId),
206-
loadProgram: createMaestroProgramLoader(path.dirname(filePath)),
207255
};
208256
}
209257

258+
function requiresDeviceRuntimeDefaults(
259+
runtimeHints: ReturnType<typeof resolveEffectiveOpenRuntimeHints>,
260+
): boolean {
261+
return (
262+
runtimeHints?.metroPort !== undefined &&
263+
runtimeHints.metroHost === undefined &&
264+
runtimeHints.bundleUrl === undefined
265+
);
266+
}
267+
210268
function buildTypedMaestroDefaults(params: {
211269
req: DaemonRequest;
212270
sessionName: string;
@@ -239,7 +297,7 @@ function createMaestroReplayPort(params: {
239297
logPath: string;
240298
sessionName: string;
241299
sessionStore: SessionStore;
242-
device: DeviceInfo;
300+
device: DeviceInfo | undefined;
243301
platform: Extract<MaestroPlatform, 'android' | 'ios'>;
244302
runtimeHints: ReturnType<typeof resolveEffectiveOpenRuntimeHints>;
245303
sourcePath: string;
@@ -264,7 +322,7 @@ function createMaestroReplayPort(params: {
264322
} = req;
265323
const baseReq = {
266324
...requestBase,
267-
flags: maestroRuntimeDeviceFlags(device, platform),
325+
flags: maestroRuntimeDeviceFlags(device, platform, req.flags),
268326
...(runtimeHints === undefined ? {} : { runtime: runtimeHints }),
269327
};
270328
return createDaemonMaestroRuntimePort({
@@ -292,9 +350,11 @@ function createMaestroReplayPort(params: {
292350
}
293351

294352
function maestroRuntimeDeviceFlags(
295-
device: DeviceInfo,
353+
device: DeviceInfo | undefined,
296354
platform: Extract<MaestroPlatform, 'android' | 'ios'>,
355+
requestedFlags: CommandFlags | undefined,
297356
): CommandFlags {
357+
if (!device) return unresolvedMaestroRuntimeDeviceFlags(platform, requestedFlags);
298358
const flags: CommandFlags = {
299359
platform,
300360
target: device.target,
@@ -308,6 +368,43 @@ function maestroRuntimeDeviceFlags(
308368
};
309369
}
310370

371+
function unresolvedMaestroRuntimeDeviceFlags(
372+
platform: Extract<MaestroPlatform, 'android' | 'ios'>,
373+
requestedFlags: CommandFlags | undefined,
374+
): CommandFlags {
375+
const flags: CommandFlags = {
376+
platform,
377+
target: requestedFlags?.target ?? 'mobile',
378+
noRecord: true,
379+
};
380+
if (requestedFlags?.device) flags.device = requestedFlags.device;
381+
return platform === 'android'
382+
? unresolvedAndroidMaestroFlags(flags, requestedFlags)
383+
: unresolvedIosMaestroFlags(flags, requestedFlags);
384+
}
385+
386+
function unresolvedAndroidMaestroFlags(
387+
flags: CommandFlags,
388+
requestedFlags: CommandFlags | undefined,
389+
): CommandFlags {
390+
if (requestedFlags?.serial) flags.serial = requestedFlags.serial;
391+
if (requestedFlags?.androidDeviceAllowlist) {
392+
flags.androidDeviceAllowlist = requestedFlags.androidDeviceAllowlist;
393+
}
394+
return flags;
395+
}
396+
397+
function unresolvedIosMaestroFlags(
398+
flags: CommandFlags,
399+
requestedFlags: CommandFlags | undefined,
400+
): CommandFlags {
401+
if (requestedFlags?.udid) flags.udid = requestedFlags.udid;
402+
if (requestedFlags?.iosSimulatorDeviceSet) {
403+
flags.iosSimulatorDeviceSet = requestedFlags.iosSimulatorDeviceSet;
404+
}
405+
return flags;
406+
}
407+
311408
function createMaestroReplayObserver(params: {
312409
filePath: string;
313410
tracePath: string | undefined;

src/platforms/apple/interactions.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,9 @@ export function iosRunnerOverrides(
8383
selectorKey: selector.key,
8484
selectorValue: selector.value,
8585
allowNonHittableCoordinateFallback: selector.allowNonHittableCoordinateFallback,
86-
x: selector.expectedPoint?.x,
87-
y: selector.expectedPoint?.y,
86+
...(selector.expectedPoint
87+
? { x: selector.expectedPoint.x, y: selector.expectedPoint.y }
88+
: {}),
8889
appBundleId: ctx.appBundleId,
8990
},
9091
runnerOpts,

test/integration/provider-scenarios/android-test-suite.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ test('Provider-backed integration Android Maestro refreshes action geometry and
125125
assert.deepEqual(swipePlan.pointers[0]?.samples[0]?.point, { x: 351, y: 300 });
126126
assert.deepEqual(swipePlan.pointers[0]?.samples.at(-1)?.point, { x: 39, y: 300 });
127127
assert.equal(world.gestureViewportCalls, 1);
128-
assert.equal(snapshots, 2);
128+
// Assertion, fresh tap geometry, then the two-snapshot stability proof.
129+
assert.equal(snapshots, 4);
129130
},
130131
);
131132
});

0 commit comments

Comments
 (0)