Skip to content

Commit 4e8829b

Browse files
committed
fix: improve Maestro test suite replay
1 parent 136d313 commit 4e8829b

12 files changed

Lines changed: 474 additions & 18 deletions

src/__tests__/cli-network.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,44 @@ test('test command reports flaky passed-on-retry cases in the default summary',
153153
assert.match(result.stdout, /Test summary: 1 passed, 0 failed, 1 flaky in 25ms/);
154154
});
155155

156+
test('test --maestro forwards Maestro backend and platform for directory suites', async () => {
157+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-cli-maestro-suite-'));
158+
await fs.writeFile(
159+
path.join(tmpDir, 'auth-flow.yml'),
160+
['appId: demo.app', '---', '- launchApp', ''].join('\n'),
161+
);
162+
163+
try {
164+
const result = await runCliCapture(
165+
['test', '--maestro', '--platform', 'android', tmpDir],
166+
async () => ({
167+
ok: true,
168+
data: {
169+
total: 1,
170+
executed: 1,
171+
passed: 1,
172+
failed: 0,
173+
skipped: 0,
174+
notRun: 0,
175+
durationMs: 5,
176+
failures: [],
177+
tests: [],
178+
},
179+
}),
180+
);
181+
182+
assert.equal(result.code, null);
183+
assert.equal(result.calls.length, 1);
184+
assert.equal(result.calls[0]?.command, 'test');
185+
assert.deepEqual(result.calls[0]?.positionals, [tmpDir]);
186+
assert.equal(result.calls[0]?.flags?.replayBackend, 'maestro');
187+
assert.equal(result.calls[0]?.flags?.platform, 'android');
188+
assert.match(result.stderr, /Running replay suite\.\.\./);
189+
} finally {
190+
await fs.rm(tmpDir, { recursive: true, force: true });
191+
}
192+
});
193+
156194
test('test command writes JUnit report with failure metadata', async () => {
157195
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-junit-test-'));
158196
const reportPath = path.join(tmpDir, 'replays.junit.xml');

src/__tests__/client.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,23 @@ test('replay.test keeps backend alias for suite discovery', async () => {
418418
assert.equal(setup.calls[0]?.flags?.replayBackend, 'maestro');
419419
});
420420

421+
test('structured replay.test command forwards Maestro backend for suite discovery', async () => {
422+
const setup = createTransport(async () => ({ ok: true, data: {} }));
423+
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
424+
425+
await runCommand(client, 'test', {
426+
paths: ['./e2e/maestro'],
427+
backend: 'maestro',
428+
platform: 'android',
429+
});
430+
431+
assert.equal(setup.calls.length, 1);
432+
assert.equal(setup.calls[0]?.command, 'test');
433+
assert.deepEqual(setup.calls[0]?.positionals, ['./e2e/maestro']);
434+
assert.equal(setup.calls[0]?.flags?.replayBackend, 'maestro');
435+
assert.equal(setup.calls[0]?.flags?.platform, 'android');
436+
});
437+
421438
test('client.command.wait prepares selector options and rejects invalid selectors', async () => {
422439
const setup = createTransport(async () => ({
423440
ok: true,

src/cli-test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,13 @@ function buildSystemOut(test: ReplaySuiteTestResult): string {
182182
if ('replayed' in test) lines.push(`replayed: ${test.replayed}`);
183183
if ('healed' in test) lines.push(`healed: ${test.healed}`);
184184
if ('artifactsDir' in test && test.artifactsDir) lines.push(`artifactsDir: ${test.artifactsDir}`);
185+
if (test.status === 'failed') {
186+
lines.push(`errorCode: ${test.error.code}`, `errorMessage: ${test.error.message}`);
187+
if (test.error.hint) lines.push(`hint: ${test.error.hint}`);
188+
if (test.error.diagnosticId) lines.push(`diagnosticId: ${test.error.diagnosticId}`);
189+
if (test.error.logPath) lines.push(`logPath: ${test.error.logPath}`);
190+
if (test.error.details) lines.push(`details: ${JSON.stringify(test.error.details)}`);
191+
}
185192
if (test.status === 'passed' && test.attempts > 1) lines.push('flaky: true');
186193
return lines.join('\n');
187194
}

src/commands/client-command-contracts.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,10 +261,12 @@ export const clientCommandDefinitions = [
261261
),
262262
defineFieldCommand(
263263
'test',
264-
'Run one or more .ad scripts.',
264+
'Run one or more replay scripts.',
265265
{
266266
paths: requiredField(stringArrayField()),
267267
update: booleanField(),
268+
backend: stringField(),
269+
maestro: booleanField(),
268270
env: stringArrayField(),
269271
failFast: booleanField(),
270272
timeoutMs: integerField(),

src/compat/maestro/runtime-interactions.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,13 @@ async function resolveMaestroScreenSwipe(params: {
218218
}
219219

220220
const [mode, ...args] = params.positionals;
221-
if (mode === 'direction') return resolveDirectionalScreenSwipe(args, frame);
221+
if (mode === 'direction') {
222+
return resolveDirectionalScreenSwipe(
223+
args,
224+
frame,
225+
readMaestroSelectorPlatform(params.baseReq.flags),
226+
);
227+
}
222228
if (mode === 'percent') {
223229
return resolvePercentScreenSwipe(
224230
args,
@@ -246,6 +252,7 @@ async function captureFrameForMaestroScreenSwipe(params: {
246252
function resolveDirectionalScreenSwipe(
247253
args: string[],
248254
frame: { referenceWidth: number; referenceHeight: number },
255+
platform: string,
249256
):
250257
| {
251258
ok: true;
@@ -267,10 +274,14 @@ function resolveDirectionalScreenSwipe(
267274
return { ok: true, start: point(50, 80), end: point(50, 20), durationMs };
268275
case 'down':
269276
return { ok: true, start: point(50, 20), end: point(50, 80), durationMs };
270-
case 'left':
271-
return { ok: true, start: point(80, 50), end: point(20, 50), durationMs };
272-
case 'right':
273-
return { ok: true, start: point(20, 50), end: point(80, 50), durationMs };
277+
case 'left': {
278+
const yPercent = androidHorizontalContentSwipeY(platform, 80, 50, 20, 50);
279+
return { ok: true, start: point(80, yPercent), end: point(20, yPercent), durationMs };
280+
}
281+
case 'right': {
282+
const yPercent = androidHorizontalContentSwipeY(platform, 20, 50, 80, 50);
283+
return { ok: true, start: point(20, yPercent), end: point(80, yPercent), durationMs };
284+
}
274285
default:
275286
return {
276287
ok: false,

src/compat/maestro/runtime-targets.ts

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { parseSelectorChain } from '../../daemon/selectors.ts';
44
import { matchesSelector } from '../../daemon/selectors-match.ts';
55
import { evaluateIsPredicate } from '../../utils/selector-is-predicates.ts';
66
import { normalizeText } from '../../utils/finders.ts';
7-
import { extractNodeText } from '../../utils/snapshot-processing.ts';
7+
import { extractNodeText, normalizeType } from '../../utils/snapshot-processing.ts';
88
import type { TouchReferenceFrame } from '../../daemon/touch-reference-frame.ts';
99
import type { DaemonRequest } from '../../daemon/types.ts';
1010
import type { Selector, SelectorTerm } from '../../daemon/selectors-parse.ts';
@@ -74,6 +74,8 @@ export function resolveMaestroNodeFromSnapshot(
7474
options.index,
7575
extractMaestroVisibleTextQuery(selector),
7676
frame,
77+
false,
78+
true,
7779
);
7880
if (!target) {
7981
const index = options.index ?? 0;
@@ -93,7 +95,15 @@ export function resolveMaestroFuzzyTextNodeFromSnapshot(
9395
frame: TouchReferenceFrame | undefined,
9496
): { ok: true; node: SnapshotNode; rect: Rect } | { ok: false; message: string } {
9597
const matches = findMaestroFuzzyTextMatches(snapshot, query);
96-
const target = selectMaestroSnapshotMatch(snapshot.nodes, matches, undefined, query, frame);
98+
const target = selectMaestroSnapshotMatch(
99+
snapshot.nodes,
100+
matches,
101+
undefined,
102+
query,
103+
frame,
104+
false,
105+
true,
106+
);
97107
if (!target) {
98108
return { ok: false, message: `Maestro fuzzy text did not match: ${query}` };
99109
}
@@ -308,6 +318,7 @@ function selectMaestroSnapshotMatch(
308318
visibleTextQuery: string | null,
309319
frame: TouchReferenceFrame | undefined,
310320
requireOnScreen = false,
321+
promoteTapTarget = false,
311322
): { node: SnapshotNode; rect: Rect } | null {
312323
const nodeByIndex = buildSnapshotNodeByIndex(nodes);
313324
const resolved = matches
@@ -321,12 +332,24 @@ function selectMaestroSnapshotMatch(
321332
? preferOnScreenMatches(resolved, frame, requireOnScreen)
322333
: resolved;
323334
if (index !== undefined) {
324-
return candidates[index] ?? null;
335+
return promoteMaestroSnapshotMatch(
336+
nodes,
337+
candidates[index] ?? null,
338+
nodeByIndex,
339+
promoteTapTarget,
340+
frame,
341+
);
325342
}
326343
const sorted = candidates.sort((left, right) =>
327344
compareMaestroSnapshotMatches(left, right, visibleTextQuery),
328345
);
329-
return sorted[0] ?? null;
346+
return promoteMaestroSnapshotMatch(
347+
nodes,
348+
sorted[0] ?? null,
349+
nodeByIndex,
350+
promoteTapTarget,
351+
frame,
352+
);
330353
}
331354

332355
function preferOnScreenMatches(
@@ -407,7 +430,78 @@ function rectArea(rect: Rect): number {
407430
}
408431

409432
function maestroTapTargetTypeRank(node: SnapshotNode): number {
410-
return MAESTRO_TAP_TARGET_TYPE_RANK.get(node.type?.toLowerCase() ?? '') ?? 3;
433+
return MAESTRO_TAP_TARGET_TYPE_RANK.get(normalizeType(node.type ?? '')) ?? 3;
434+
}
435+
436+
function promoteMaestroSnapshotMatch(
437+
nodes: SnapshotState['nodes'],
438+
match: MaestroResolvedSnapshotMatch | null,
439+
nodeByIndex: SnapshotNodeByIndex,
440+
promoteTapTarget: boolean,
441+
frame: TouchReferenceFrame | undefined,
442+
): { node: SnapshotNode; rect: Rect } | null {
443+
if (!match) return null;
444+
if (!promoteTapTarget) {
445+
return { node: match.node, rect: match.rect };
446+
}
447+
const ancestor = findMaestroTapAncestor(nodes, match, nodeByIndex, frame);
448+
return ancestor ?? { node: match.node, rect: match.rect };
449+
}
450+
451+
function findMaestroTapAncestor(
452+
nodes: SnapshotState['nodes'],
453+
match: MaestroResolvedSnapshotMatch,
454+
nodeByIndex: SnapshotNodeByIndex,
455+
frame: TouchReferenceFrame | undefined,
456+
): { node: SnapshotNode; rect: Rect } | null {
457+
if (isActionableMaestroTapTarget(match.node)) return null;
458+
return findSnapshotAncestor(nodes, match.node, nodeByIndex, (ancestor) => {
459+
if (!isActionableMaestroTapTarget(ancestor)) return null;
460+
const ancestorRect = resolveNodeRect(nodes, ancestor, nodeByIndex);
461+
if (!ancestorRect || !isUsefulMaestroTapAncestorRect(match.rect, ancestorRect.rect, frame)) {
462+
return null;
463+
}
464+
return { node: ancestor, rect: ancestorRect.rect };
465+
});
466+
}
467+
468+
function isActionableMaestroTapTarget(node: SnapshotNode): boolean {
469+
const type = normalizeType(node.type ?? '');
470+
return (
471+
node.hittable === true ||
472+
type === 'button' ||
473+
type === 'link' ||
474+
type === 'cell' ||
475+
type === 'textfield' ||
476+
type === 'searchfield' ||
477+
type === 'switch' ||
478+
type === 'slider'
479+
);
480+
}
481+
482+
function isUsefulMaestroTapAncestorRect(
483+
matchRect: Rect,
484+
ancestorRect: Rect,
485+
frame: TouchReferenceFrame | undefined,
486+
): boolean {
487+
if (!rectContains(ancestorRect, matchRect)) return false;
488+
const ancestorArea = rectArea(ancestorRect);
489+
const matchArea = rectArea(matchRect);
490+
if (matchArea > 0 && ancestorArea > matchArea * 30) return false;
491+
if (frame) {
492+
const frameArea = frame.referenceWidth * frame.referenceHeight;
493+
if (frameArea > 0 && ancestorArea > frameArea * 0.5) return false;
494+
}
495+
return true;
496+
}
497+
498+
function rectContains(container: Rect, child: Rect): boolean {
499+
return (
500+
child.x >= container.x &&
501+
child.y >= container.y &&
502+
child.x + child.width <= container.x + container.width &&
503+
child.y + child.height <= container.y + container.height
504+
);
411505
}
412506

413507
function maestroVisibleTextMatchRank(node: SnapshotNode, query: string): number {

0 commit comments

Comments
 (0)