Skip to content

Commit 27548f0

Browse files
committed
fix: stabilize Maestro visibility resolution
1 parent 3671340 commit 27548f0

5 files changed

Lines changed: 206 additions & 49 deletions

File tree

src/compat/maestro/__tests__/runtime-assertions.test.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,21 +70,58 @@ test('invokeMaestroAssertNotVisible passes after a slow hidden sample exhausts t
7070
invoke: async (req): Promise<DaemonResponse> => {
7171
calls.push(req);
7272
return {
73-
ok: false,
74-
error: {
75-
code: 'COMMAND_FAILED',
76-
message: 'Selector did not match: id="tab-4"',
77-
details: { command: 'is', reason: 'selector_not_found' },
73+
ok: true,
74+
data: {
75+
createdAt: 1,
76+
nodes: [],
7877
},
7978
};
8079
},
8180
});
8281

8382
assert.equal(response.ok, true);
84-
assert.deepEqual(calls.map((call) => call.positionals), [['visible', 'id="tab-4"']]);
83+
assert.deepEqual(calls.map((call) => [call.command, call.positionals]), [
84+
['snapshot', []],
85+
]);
8586
if (response.ok) {
8687
assert.ok(response.data);
8788
assert.equal(response.data.stableSamples, 1);
8889
assert.equal(response.data.waitedMs, 3500);
8990
}
9091
});
92+
93+
test('invokeMaestroAssertNotVisible ignores matched nodes without visible rects', async () => {
94+
vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValueOnce(3500);
95+
96+
const response = await invokeMaestroAssertNotVisible({
97+
baseReq: {
98+
token: 't',
99+
session: 's',
100+
flags: { platform: 'android' },
101+
},
102+
positionals: ['label="📌" || text="📌" || id="📌"'],
103+
invoke: async (): Promise<DaemonResponse> => ({
104+
ok: true,
105+
data: {
106+
createdAt: 1,
107+
nodes: [
108+
{
109+
index: 1,
110+
ref: 'e1',
111+
type: 'android.widget.TextView',
112+
label: '📌',
113+
value: '📌',
114+
enabled: true,
115+
depth: 21,
116+
},
117+
],
118+
},
119+
}),
120+
});
121+
122+
assert.equal(response.ok, true);
123+
if (response.ok) {
124+
assert.ok(response.data);
125+
assert.equal(response.data.stableSamples, 1);
126+
}
127+
});

src/compat/maestro/__tests__/runtime-targets.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,60 @@ test('resolveMaestroNodeFromSnapshot prefers concrete Android tab rect over hidd
315315
});
316316
});
317317

318+
test('resolveMaestroNodeFromSnapshot prefers exact Android tab label over normalized header icon text', () => {
319+
const snapshot: SnapshotState = {
320+
createdAt: Date.now(),
321+
nodes: [
322+
{
323+
index: 1,
324+
ref: 'e1',
325+
type: 'android.widget.FrameLayout',
326+
label: 'Search',
327+
rect: { x: 810, y: 2054, width: 270, height: 132 },
328+
enabled: true,
329+
hittable: true,
330+
depth: 16,
331+
},
332+
{
333+
index: 2,
334+
ref: 'e2',
335+
type: 'android.widget.Button',
336+
label: 'search',
337+
rect: { x: 673, y: 165, width: 132, height: 132 },
338+
enabled: true,
339+
hittable: true,
340+
depth: 22,
341+
},
342+
{
343+
index: 3,
344+
ref: 'e3',
345+
type: 'android.widget.TextView',
346+
label: 'search',
347+
value: 'search',
348+
rect: { x: 706, y: 198, width: 66, height: 66 },
349+
enabled: true,
350+
depth: 23,
351+
parentIndex: 2,
352+
},
353+
],
354+
};
355+
356+
const target = resolveMaestroNodeFromSnapshot(
357+
snapshot,
358+
'label="Search" || text="Search" || id="Search"',
359+
{},
360+
'android',
361+
{ referenceWidth: 1080, referenceHeight: 2340 },
362+
{ promoteTapTarget: true },
363+
);
364+
365+
expect(target).toMatchObject({
366+
ok: true,
367+
node: expect.objectContaining({ index: 1 }),
368+
rect: { x: 810, y: 2054, width: 270, height: 132 },
369+
});
370+
});
371+
318372
test('resolveMaestroNodeFromSnapshot infers missing selected tab slot from tab-strip children', () => {
319373
const snapshot: SnapshotState = {
320374
createdAt: Date.now(),

src/compat/maestro/runtime-assertions.ts

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -149,16 +149,11 @@ export async function invokeMaestroAssertNotVisible(params: {
149149
let hiddenSamples = 0;
150150
let lastVisibleResponse: DaemonResponse | undefined;
151151
while (Date.now() - startedAt <= MAESTRO_ASSERTION_POLICY.assertNotVisibleTimeoutMs) {
152-
const response = await params.invoke({
153-
...params.baseReq,
154-
command: 'is',
155-
positionals: ['visible', selector],
156-
flags: { ...params.baseReq.flags, noRecord: true },
157-
});
158-
if (response.ok) {
152+
const attempt = await readAssertNotVisibleAttempt(params, selector);
153+
if (attempt.visible) {
159154
hiddenSamples = 0;
160-
lastVisibleResponse = response;
161-
} else if (isMaestroVisibilityMiss(response)) {
155+
lastVisibleResponse = attempt.response;
156+
} else if (attempt.hidden) {
162157
hiddenSamples += 1;
163158
const waitedMs = Date.now() - startedAt;
164159
if (
@@ -177,7 +172,7 @@ export async function invokeMaestroAssertNotVisible(params: {
177172
};
178173
}
179174
} else {
180-
return response;
175+
return attempt.response;
181176
}
182177
await sleep(MAESTRO_ASSERTION_POLICY.assertNotVisiblePollMs);
183178
}
@@ -188,6 +183,59 @@ export async function invokeMaestroAssertNotVisible(params: {
188183
});
189184
}
190185

186+
async function readAssertNotVisibleAttempt(
187+
params: {
188+
baseReq: ReplayBaseRequest;
189+
positionals: string[];
190+
invoke: MaestroRuntimeInvoke;
191+
},
192+
selector: string,
193+
): Promise<
194+
| { visible: true; hidden: false; response: DaemonResponse }
195+
| { visible: false; hidden: true; response: DaemonResponse }
196+
| { visible: false; hidden: false; response: DaemonResponse }
197+
> {
198+
const response = await captureMaestroRawSnapshot(params);
199+
if (!response.ok) return { visible: false, hidden: false, response };
200+
const snapshot = readSnapshotState(response.data);
201+
if (!snapshot) {
202+
return {
203+
visible: false,
204+
hidden: false,
205+
response: errorResponse('COMMAND_FAILED', 'Unable to read snapshot data for assertNotVisible.'),
206+
};
207+
}
208+
const target = resolveVisibleMaestroNodeFromSnapshot(
209+
snapshot,
210+
selector,
211+
readMaestroSelectorPlatform(params.baseReq.flags),
212+
getSnapshotReferenceFrame(snapshot),
213+
);
214+
if (!target.ok) {
215+
return {
216+
visible: false,
217+
hidden: true,
218+
response: errorResponse('COMMAND_FAILED', target.message, { selector }),
219+
};
220+
}
221+
return {
222+
visible: true,
223+
hidden: false,
224+
response: {
225+
ok: true,
226+
data: {
227+
selector,
228+
matches: target.matches,
229+
nodeIndex: target.node.index,
230+
nodeType: target.node.type,
231+
nodeLabel: target.node.label,
232+
nodeIdentifier: target.node.identifier,
233+
rect: target.rect,
234+
},
235+
},
236+
};
237+
}
238+
191239
export async function invokeMaestroWaitForAnimationToEnd(params: {
192240
baseReq: ReplayBaseRequest;
193241
positionals: string[];
@@ -215,14 +263,6 @@ export async function invokeMaestroWaitForAnimationToEnd(params: {
215263
: { ok: true, data: { stable: false, timeoutMs } };
216264
}
217265

218-
function isMaestroVisibilityMiss(response: Extract<DaemonResponse, { ok: false }>): boolean {
219-
const details = response.error.details;
220-
return (
221-
details?.command === 'is' &&
222-
(details.reason === 'selector_not_found' || details.reason === 'predicate_failed')
223-
);
224-
}
225-
226266
function readAnimationPollResult(
227267
response: DaemonResponse,
228268
previousSignature: string | undefined,

src/compat/maestro/runtime-targets.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -428,15 +428,35 @@ function selectLocalizedMaestroVisibleTextMatch(
428428
query: string,
429429
): MaestroResolvedSnapshotMatch | null {
430430
const exactMatches = candidates.filter(
431-
(candidate) => maestroVisibleTextMatchRank(candidate.node, query) <= 1,
431+
(candidate) => maestroVisibleTextMatchRank(candidate.node, query) === 0,
432432
);
433-
if (exactMatches.length < 2) return null;
433+
if (exactMatches.length >= 2) {
434+
const localizedExact = selectLocalizedMaestroVisibleTextMatchFromCandidates(
435+
nodes,
436+
exactMatches,
437+
query,
438+
);
439+
if (localizedExact) return localizedExact;
440+
}
441+
442+
const normalizedMatches = candidates.filter(
443+
(candidate) => maestroVisibleTextMatchRank(candidate.node, query) === 1,
444+
);
445+
if (exactMatches.length > 0 || normalizedMatches.length < 2) return null;
446+
447+
return selectLocalizedMaestroVisibleTextMatchFromCandidates(nodes, normalizedMatches, query);
448+
}
434449

450+
function selectLocalizedMaestroVisibleTextMatchFromCandidates(
451+
nodes: SnapshotState['nodes'],
452+
candidates: MaestroResolvedSnapshotMatch[],
453+
query: string,
454+
): MaestroResolvedSnapshotMatch | null {
435455
const nodeByIndex = buildSnapshotNodeByIndex(nodes);
436-
const localized = exactMatches.filter(
456+
const localized = candidates.filter(
437457
(candidate) =>
438458
isLocalizedMaestroVisibleTextCandidate(candidate) &&
439-
exactMatches.some((container) =>
459+
candidates.some((container) =>
440460
isMaestroVisibleTextContainerForCandidate(nodes, container, candidate, nodeByIndex),
441461
),
442462
);

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

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -888,11 +888,10 @@ test('runReplayScriptFile treats absent Maestro assertNotVisible targets as pass
888888
invoke: async (req) => {
889889
calls.push({ command: req.command, positionals: req.positionals, flags: req.flags });
890890
return {
891-
ok: false,
892-
error: {
893-
code: 'COMMAND_FAILED',
894-
message: 'Selector did not match',
895-
details: { command: 'is', reason: 'selector_not_found' },
891+
ok: true,
892+
data: {
893+
createdAt: 1,
894+
nodes: [],
896895
},
897896
};
898897
},
@@ -902,14 +901,8 @@ test('runReplayScriptFile treats absent Maestro assertNotVisible targets as pass
902901
assert.deepEqual(
903902
calls.map((call) => [call.command, call.positionals]),
904903
[
905-
[
906-
'is',
907-
['visible', 'label="Archived banner" || text="Archived banner" || id="Archived banner"'],
908-
],
909-
[
910-
'is',
911-
['visible', 'label="Archived banner" || text="Archived banner" || id="Archived banner"'],
912-
],
904+
['snapshot', []],
905+
['snapshot', []],
913906
],
914907
);
915908
assert.equal(calls[0]?.flags?.noRecord, true);
@@ -940,21 +933,34 @@ test('runReplayScriptFile propagates Maestro assertNotVisible infrastructure fai
940933

941934
test('runReplayScriptFile waits briefly for Maestro assertNotVisible to stabilize', async () => {
942935
const calls: CapturedInvocation[] = [];
943-
let visibleChecks = 0;
936+
let snapshots = 0;
944937
const { response } = await runReplayFixture({
945938
label: 'maestro-assert-not-visible-stable',
946939
script: ['appId: demo.app', '---', '- assertNotVisible: Archived banner', ''].join('\n'),
947940
flags: { replayBackend: 'maestro' },
948941
invoke: async (req) => {
949942
calls.push({ command: req.command, positionals: req.positionals, flags: req.flags });
950-
visibleChecks += 1;
951-
if (visibleChecks === 1) return { ok: true, data: { pass: true } };
943+
snapshots += 1;
944+
if (snapshots === 1) {
945+
return {
946+
ok: true,
947+
data: {
948+
createdAt: 1,
949+
nodes: [
950+
{
951+
index: 1,
952+
label: 'Archived banner',
953+
rect: { x: 10, y: 20, width: 180, height: 44 },
954+
},
955+
],
956+
},
957+
};
958+
}
952959
return {
953-
ok: false,
954-
error: {
955-
code: 'COMMAND_FAILED',
956-
message: 'is visible failed',
957-
details: { command: 'is', reason: 'predicate_failed' },
960+
ok: true,
961+
data: {
962+
createdAt: snapshots,
963+
nodes: [],
958964
},
959965
};
960966
},

0 commit comments

Comments
 (0)