Skip to content

Commit 4235852

Browse files
committed
refactor: converge Maestro assertion waits
1 parent f74f4e0 commit 4235852

5 files changed

Lines changed: 177 additions & 99 deletions

File tree

src/compat/maestro/__tests__/replay-flow.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,21 @@ test('parseMaestroReplayFlow preserves selector state and absolute swipe command
330330
assert.deepEqual(parsed.actionLines, [3, 6]);
331331
});
332332

333+
test('parseMaestroReplayFlow maps extendedWaitUntil.notVisible through Maestro visibility assertions', () => {
334+
const parsed = parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab
335+
---
336+
- extendedWaitUntil:
337+
notVisible:
338+
text: Loading
339+
timeout: 1200
340+
`);
341+
342+
assert.deepEqual(
343+
parsed.actions.map((entry) => [entry.command, entry.positionals]),
344+
[['__maestroAssertNotVisible', ['label="Loading" || text="Loading" || id="Loading"', '1200']]],
345+
);
346+
});
347+
333348
test('parseMaestroReplayFlow rejects deferred Maestro utility commands loudly', () => {
334349
assert.throws(
335350
() => parseMaestroReplayFlow('---\n- assertTrue: "${READY}"\n'),

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

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import assert from 'node:assert/strict';
22
import { afterEach, test, vi } from 'vitest';
3-
import { invokeMaestroAssertNotVisible, invokeMaestroAssertVisible } from '../runtime-assertions.ts';
3+
import {
4+
invokeMaestroAssertNotVisible,
5+
invokeMaestroAssertVisible,
6+
} from '../runtime-assertions.ts';
47
import type { DaemonRequest, DaemonResponse } from '../../../daemon/types.ts';
58

69
afterEach(() => {
@@ -80,9 +83,10 @@ test('invokeMaestroAssertNotVisible passes after a slow hidden sample exhausts t
8083
});
8184

8285
assert.equal(response.ok, true);
83-
assert.deepEqual(calls.map((call) => [call.command, call.positionals]), [
84-
['snapshot', []],
85-
]);
86+
assert.deepEqual(
87+
calls.map((call) => [call.command, call.positionals]),
88+
[['snapshot', []]],
89+
);
8690
if (response.ok) {
8791
assert.ok(response.data);
8892
assert.equal(response.data.stableSamples, 1);
@@ -125,3 +129,30 @@ test('invokeMaestroAssertNotVisible ignores matched nodes without visible rects'
125129
assert.equal(response.data.stableSamples, 1);
126130
}
127131
});
132+
133+
test('invokeMaestroAssertNotVisible accepts timeout overrides for short extended waits', async () => {
134+
vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValueOnce(300);
135+
136+
const response = await invokeMaestroAssertNotVisible({
137+
baseReq: {
138+
token: 't',
139+
session: 's',
140+
flags: {},
141+
},
142+
positionals: ['id="toast"', '1'],
143+
invoke: async (): Promise<DaemonResponse> => ({
144+
ok: true,
145+
data: {
146+
createdAt: 1,
147+
nodes: [],
148+
},
149+
}),
150+
});
151+
152+
assert.equal(response.ok, true);
153+
if (response.ok) {
154+
assert.ok(response.data);
155+
assert.equal(response.data.stableSamples, 1);
156+
assert.equal(response.data.timeoutMs, 1);
157+
}
158+
});

src/compat/maestro/interactions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export function convertExtendedWaitUntil(
142142
const selector = maestroSelector(target, 'extendedWaitUntil', [], context);
143143
const timeoutMs = String(readTimeoutMs(value, 30000));
144144
if (value.notVisible !== undefined) {
145-
return [action('wait', [timeoutMs]), action('is', ['hidden', selector])];
145+
return [action(MAESTRO_RUNTIME_COMMAND.assertNotVisible, [selector, timeoutMs])];
146146
}
147147
return [action(MAESTRO_RUNTIME_COMMAND.assertVisible, [selector, timeoutMs])];
148148
}

src/compat/maestro/runtime-assertions.ts

Lines changed: 97 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -20,31 +20,48 @@ const MAESTRO_ASSERTION_POLICY = {
2020
assertVisibleGraceMs: 1000,
2121
assertVisiblePollMs: 250,
2222
assertNotVisiblePollMs: 250,
23-
assertNotVisibleTimeoutMs: 3000,
23+
defaultAssertNotVisibleTimeoutMs: 3000,
2424
} as const;
2525

26+
type MaestroVisibilitySample =
27+
| { visible: true; response: DaemonResponse }
28+
| { visible: false; response: DaemonResponse; infrastructureFailure: boolean };
29+
2630
export async function invokeMaestroAssertVisible(params: {
2731
baseReq: ReplayBaseRequest;
2832
positionals: string[];
2933
invoke: MaestroRuntimeInvoke;
3034
scope?: ReplayVarScope;
3135
}): Promise<DaemonResponse> {
32-
const args = readAssertVisibleArgs(params.positionals);
36+
const args = readVisibilityAssertionArgs(params.positionals, {
37+
command: 'assertVisible',
38+
defaultTimeoutMs: 5000,
39+
});
3340
if (!args.ok) return args.response;
3441

42+
// Native wait/is cannot replace this loop: wait only proves existence, while
43+
// is requires unique resolution and does not apply Maestro overlay filtering.
3544
const startedAt = Date.now();
3645
const deadlineMs = args.timeoutMs + MAESTRO_ASSERTION_POLICY.assertVisibleGraceMs;
3746
let lastResponse: DaemonResponse | undefined;
3847
let capturedAfterDeadline = false;
3948
while (true) {
4049
const captureStartedAt = Date.now();
41-
const attempt = await readAssertVisibleAttempt(params, args.selector, startedAt);
42-
if (attempt.done) return attempt.response;
43-
lastResponse = attempt.response;
50+
const sample = await readMaestroVisibilitySample(params, args.selector, 'assertVisible');
51+
if (sample.visible) return visibleAssertionResponse(sample.response, args.selector, startedAt);
52+
if (!sample.visible && sample.infrastructureFailure) return sample.response;
53+
lastResponse = sample.response;
4454

4555
const elapsedMs = Date.now() - startedAt;
4656
if (elapsedMs >= deadlineMs) {
47-
if (shouldCaptureOnceAfterDeadline(capturedAfterDeadline, captureStartedAt, startedAt, deadlineMs)) {
57+
if (
58+
shouldCaptureOnceAfterDeadline(
59+
capturedAfterDeadline,
60+
captureStartedAt,
61+
startedAt,
62+
deadlineMs,
63+
)
64+
) {
4865
capturedAfterDeadline = true;
4966
continue;
5067
}
@@ -62,42 +79,47 @@ export async function invokeMaestroAssertVisible(params: {
6279
);
6380
}
6481

65-
function readAssertVisibleArgs(
82+
function readVisibilityAssertionArgs(
6683
positionals: string[],
67-
):
68-
| { ok: true; selector: string; timeoutMs: number }
69-
| { ok: false; response: DaemonResponse } {
70-
const [selector, timeoutValue = '5000'] = positionals;
84+
options: { command: string; defaultTimeoutMs: number },
85+
): { ok: true; selector: string; timeoutMs: number } | { ok: false; response: DaemonResponse } {
86+
const [selector, timeoutValue = String(options.defaultTimeoutMs)] = positionals;
7187
if (!selector) {
72-
return { ok: false, response: errorResponse('INVALID_ARGS', 'assertVisible requires a selector.') };
88+
return {
89+
ok: false,
90+
response: errorResponse('INVALID_ARGS', `${options.command} requires a selector.`),
91+
};
7392
}
7493
const timeoutMs = Number(timeoutValue);
7594
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
7695
return {
7796
ok: false,
78-
response: errorResponse('INVALID_ARGS', 'assertVisible timeout must be a non-negative number.'),
97+
response: errorResponse(
98+
'INVALID_ARGS',
99+
`${options.command} timeout must be a non-negative number.`,
100+
),
79101
};
80102
}
81103
return { ok: true, selector, timeoutMs };
82104
}
83105

84-
async function readAssertVisibleAttempt(
106+
async function readMaestroVisibilitySample(
85107
params: {
86108
baseReq: ReplayBaseRequest;
87-
positionals: string[];
88109
invoke: MaestroRuntimeInvoke;
89110
scope?: ReplayVarScope;
90111
},
91112
selector: string,
92-
startedAt: number,
93-
): Promise<{ done: true; response: DaemonResponse } | { done: false; response: DaemonResponse }> {
113+
command: string,
114+
): Promise<MaestroVisibilitySample> {
94115
const response = await captureMaestroRawSnapshot(params);
95-
if (!response.ok) return { done: false, response };
116+
if (!response.ok) return { visible: false, response, infrastructureFailure: true };
96117
const snapshot = readSnapshotState(response.data);
97118
if (!snapshot) {
98119
return {
99-
done: true,
100-
response: errorResponse('COMMAND_FAILED', 'Unable to read snapshot data for assertVisible.'),
120+
visible: false,
121+
response: errorResponse('COMMAND_FAILED', `Unable to read snapshot data for ${command}.`),
122+
infrastructureFailure: true,
101123
};
102124
}
103125
const target = resolveVisibleMaestroNodeFromSnapshot(
@@ -107,10 +129,14 @@ async function readAssertVisibleAttempt(
107129
getSnapshotReferenceFrame(snapshot),
108130
);
109131
if (!target.ok) {
110-
return { done: false, response: errorResponse('COMMAND_FAILED', target.message, { selector }) };
132+
return {
133+
visible: false,
134+
response: errorResponse('COMMAND_FAILED', target.message, { selector }),
135+
infrastructureFailure: false,
136+
};
111137
}
112138
return {
113-
done: true,
139+
visible: true,
114140
response: {
115141
ok: true,
116142
data: {
@@ -121,12 +147,27 @@ async function readAssertVisibleAttempt(
121147
nodeLabel: target.node.label,
122148
nodeIdentifier: target.node.identifier,
123149
rect: target.rect,
124-
waitedMs: Date.now() - startedAt,
125150
},
126151
},
127152
};
128153
}
129154

155+
function visibleAssertionResponse(
156+
response: DaemonResponse,
157+
selector: string,
158+
startedAt: number,
159+
): DaemonResponse {
160+
if (!response.ok) return response;
161+
return {
162+
ok: true,
163+
data: {
164+
selector,
165+
...response.data,
166+
waitedMs: Date.now() - startedAt,
167+
},
168+
};
169+
}
170+
130171
function shouldCaptureOnceAfterDeadline(
131172
capturedAfterDeadline: boolean,
132173
captureStartedAt: number,
@@ -141,99 +182,59 @@ export async function invokeMaestroAssertNotVisible(params: {
141182
positionals: string[];
142183
invoke: MaestroRuntimeInvoke;
143184
}): Promise<DaemonResponse> {
144-
const [selector] = params.positionals;
145-
if (!selector) {
146-
return errorResponse('INVALID_ARGS', 'assertNotVisible requires a selector.');
147-
}
185+
const args = readVisibilityAssertionArgs(params.positionals, {
186+
command: 'assertNotVisible',
187+
defaultTimeoutMs: MAESTRO_ASSERTION_POLICY.defaultAssertNotVisibleTimeoutMs,
188+
});
189+
if (!args.ok) return args.response;
190+
191+
// Native is hidden intentionally fails for absent selectors. Maestro
192+
// assertNotVisible treats absent and overlay-blocked targets as passing, so
193+
// this loop shares the visible resolver instead of delegating to native is.
148194
const startedAt = Date.now();
149195
let hiddenSamples = 0;
150196
let lastVisibleResponse: DaemonResponse | undefined;
151-
while (Date.now() - startedAt <= MAESTRO_ASSERTION_POLICY.assertNotVisibleTimeoutMs) {
152-
const attempt = await readAssertNotVisibleAttempt(params, selector);
153-
if (attempt.visible) {
197+
while (Date.now() - startedAt <= args.timeoutMs) {
198+
const sample = await readMaestroVisibilitySample(params, args.selector, 'assertNotVisible');
199+
if (!sample.visible && sample.infrastructureFailure) return sample.response;
200+
if (sample.visible) {
154201
hiddenSamples = 0;
155-
lastVisibleResponse = attempt.response;
156-
} else if (attempt.hidden) {
202+
lastVisibleResponse = sample.response;
203+
} else {
157204
hiddenSamples += 1;
158205
const waitedMs = Date.now() - startedAt;
159-
if (
160-
hiddenSamples >= 2 ||
161-
waitedMs >= MAESTRO_ASSERTION_POLICY.assertNotVisibleTimeoutMs
162-
) {
206+
if (hiddenSamples >= 2 || waitedMs >= args.timeoutMs) {
163207
return {
164208
ok: true,
165209
data: {
166210
pass: true,
167-
selector,
211+
selector: args.selector,
168212
stableSamples: hiddenSamples,
169213
waitedMs,
170-
timeoutMs: MAESTRO_ASSERTION_POLICY.assertNotVisibleTimeoutMs,
214+
timeoutMs: args.timeoutMs,
171215
},
172216
};
173217
}
174-
} else {
175-
return attempt.response;
176218
}
177219
await sleep(MAESTRO_ASSERTION_POLICY.assertNotVisiblePollMs);
178220
}
179-
return errorResponse('COMMAND_FAILED', `Expected not visible but matched: ${selector}`, {
180-
selector,
181-
timeoutMs: MAESTRO_ASSERTION_POLICY.assertNotVisibleTimeoutMs,
182-
lastResponse: lastVisibleResponse,
183-
});
184-
}
185-
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) {
221+
if (hiddenSamples > 0) {
202222
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: {
225223
ok: true,
226224
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,
225+
pass: true,
226+
selector: args.selector,
227+
stableSamples: hiddenSamples,
228+
waitedMs: Date.now() - startedAt,
229+
timeoutMs: args.timeoutMs,
234230
},
235-
},
236-
};
231+
};
232+
}
233+
return errorResponse('COMMAND_FAILED', `Expected not visible but matched: ${args.selector}`, {
234+
selector: args.selector,
235+
timeoutMs: args.timeoutMs,
236+
lastResponse: lastVisibleResponse,
237+
});
237238
}
238239

239240
export async function invokeMaestroWaitForAnimationToEnd(params: {
@@ -245,6 +246,8 @@ export async function invokeMaestroWaitForAnimationToEnd(params: {
245246
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
246247
return errorResponse('INVALID_ARGS', 'waitForAnimationToEnd timeout must be a number.');
247248
}
249+
// There is no native wait/is equivalent for "animation has ended"; this is
250+
// snapshot stability polling by design.
248251
const startedAt = Date.now();
249252
let previousSignature: string | undefined;
250253
let lastResponse: DaemonResponse | undefined;

0 commit comments

Comments
 (0)