Skip to content

Commit 66712cb

Browse files
committed
fix: improve Maestro test suite replay
1 parent 136d313 commit 66712cb

11 files changed

Lines changed: 436 additions & 18 deletions

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 {

src/daemon/handlers/__tests__/session-replay-vars.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,96 @@ test('runReplayScriptFile lets Maestro tapOn use fuzzy visible text matching', a
730730
assert.equal(calls[0]?.flags?.noRecord, true);
731731
});
732732

733+
test('runReplayScriptFile promotes Maestro text tapOn to an actionable ancestor', async () => {
734+
const calls: CapturedInvocation[] = [];
735+
const { response } = await runReplayFixture({
736+
label: 'maestro-tap-visible-text-actionable-ancestor',
737+
script: ['appId: demo.app', '---', '- tapOn: Article', ''].join('\n'),
738+
flags: { replayBackend: 'maestro', platform: 'ios' },
739+
invoke: async (req) => {
740+
calls.push({ command: req.command, positionals: req.positionals, flags: req.flags });
741+
if (req.command === 'snapshot') {
742+
return {
743+
ok: true,
744+
data: {
745+
nodes: [
746+
{
747+
index: 1,
748+
type: 'XCUIElementTypeButton',
749+
rect: { x: 40, y: 100, width: 120, height: 48 },
750+
hittable: true,
751+
},
752+
{
753+
index: 2,
754+
parentIndex: 1,
755+
type: 'XCUIElementTypeStaticText',
756+
label: 'Article',
757+
rect: { x: 76, y: 114, width: 48, height: 20 },
758+
hittable: false,
759+
},
760+
],
761+
},
762+
};
763+
}
764+
return { ok: true, data: {} };
765+
},
766+
});
767+
768+
assert.equal(response.ok, true);
769+
assert.deepEqual(
770+
calls.map((call) => [call.command, call.positionals]),
771+
[
772+
['snapshot', []],
773+
['click', ['100', '124']],
774+
],
775+
);
776+
});
777+
778+
test('runReplayScriptFile promotes Maestro id tapOn to an actionable ancestor', async () => {
779+
const calls: CapturedInvocation[] = [];
780+
const { response } = await runReplayFixture({
781+
label: 'maestro-tap-id-actionable-ancestor',
782+
script: ['appId: demo.app', '---', '- tapOn:', ' id: album-0', ''].join('\n'),
783+
flags: { replayBackend: 'maestro', platform: 'android' },
784+
invoke: async (req) => {
785+
calls.push({ command: req.command, positionals: req.positionals, flags: req.flags });
786+
if (req.command === 'snapshot') {
787+
return {
788+
ok: true,
789+
data: {
790+
nodes: [
791+
{
792+
index: 1,
793+
type: 'android.widget.Button',
794+
rect: { x: 24, y: 320, width: 312, height: 64 },
795+
hittable: true,
796+
},
797+
{
798+
index: 2,
799+
parentIndex: 1,
800+
type: 'android.widget.TextView',
801+
identifier: 'album-0',
802+
rect: { x: 44, y: 334, width: 80, height: 24 },
803+
hittable: false,
804+
},
805+
],
806+
},
807+
};
808+
}
809+
return { ok: true, data: {} };
810+
},
811+
});
812+
813+
assert.equal(response.ok, true);
814+
assert.deepEqual(
815+
calls.map((call) => [call.command, call.positionals]),
816+
[
817+
['snapshot', []],
818+
['click', ['180', '352']],
819+
],
820+
);
821+
});
822+
733823
test('runReplayScriptFile reuses successful Maestro visibility snapshot for following tapOn', async () => {
734824
let snapshots = 0;
735825
const { response, calls } = await runReplayFixture({
@@ -1281,6 +1371,54 @@ test('runReplayScriptFile resolves Maestro screen swipes from the snapshot frame
12811371
);
12821372
});
12831373

1374+
test('runReplayScriptFile uses Android content lane for Maestro horizontal screen swipes', async () => {
1375+
const calls: CapturedInvocation[] = [];
1376+
const { response } = await runReplayFixture({
1377+
label: 'maestro-screen-swipe-android-content-lane',
1378+
script: [
1379+
'appId: demo.app',
1380+
'---',
1381+
'- swipe:',
1382+
' direction: LEFT',
1383+
' duration: 300',
1384+
'- swipe:',
1385+
' start: 90%,50%',
1386+
' end: 10%,50%',
1387+
' duration: 300',
1388+
'',
1389+
].join('\n'),
1390+
flags: { replayBackend: 'maestro', platform: 'android' },
1391+
invoke: async (req) => {
1392+
calls.push({ command: req.command, positionals: req.positionals, flags: req.flags });
1393+
if (req.command === 'snapshot') {
1394+
return {
1395+
ok: true,
1396+
data: {
1397+
nodes: [
1398+
{
1399+
index: 0,
1400+
type: 'application',
1401+
rect: { x: 0, y: 0, width: 400, height: 800 },
1402+
},
1403+
],
1404+
},
1405+
};
1406+
}
1407+
return { ok: true, data: {} };
1408+
},
1409+
});
1410+
1411+
assert.equal(response.ok, true);
1412+
assert.deepEqual(
1413+
calls.map((call) => [call.command, call.positionals]),
1414+
[
1415+
['snapshot', []],
1416+
['swipe', ['320', '520', '80', '520', '300']],
1417+
['swipe', ['360', '520', '40', '520', '300']],
1418+
],
1419+
);
1420+
});
1421+
12841422
test('runReplayScriptFile maps Maestro enter to keyboard enter', async () => {
12851423
const calls: CapturedInvocation[] = [];
12861424
const { response } = await runReplayFixture({

0 commit comments

Comments
 (0)