Skip to content

Commit 78be55c

Browse files
committed
fix: address Maestro replay review feedback
1 parent d1875b3 commit 78be55c

10 files changed

Lines changed: 262 additions & 26 deletions

File tree

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,10 @@ extension RunnerTests {
236236
return false
237237
}
238238
let appFrame = app.frame
239-
return appFrame.isEmpty || appFrame.intersects(frame)
239+
if appFrame.isEmpty {
240+
return true
241+
}
242+
return appFrame.contains(CGPoint(x: frame.midX, y: frame.midY))
240243
}
241244

242245
func queryElement(app: XCUIApplication, selectorKey: String, selectorValue: String) -> Response {

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

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ env:
6969
['scroll', ['right']],
7070
[
7171
'__maestroScrollUntilVisible',
72-
['label="Discover" || text="Discover" || id="Discover"', '5000', 'down'],
72+
['label="Discover" || text="Discover" || id="Discover"', '5000', 'up'],
7373
],
7474
['screenshot', ['./screens/form.png']],
7575
['keyboard', ['dismiss']],
@@ -128,6 +128,29 @@ output.result = SERVER_PATH + ':' + json(res.body).appviewDid
128128
);
129129
});
130130

131+
test('parseMaestroReplayFlow reports runScript http failures with command context', () => {
132+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-maestro-runscript-fail-'));
133+
const scriptPath = path.join(root, 'setup.js');
134+
const flowPath = path.join(root, 'flow.yml');
135+
fs.writeFileSync(scriptPath, `output.result = http.post('http://127.0.0.1:1').body`);
136+
137+
assert.throws(
138+
() =>
139+
parseMaestroReplayFlow(
140+
`appId: com.callstack.agentdevicelab
141+
---
142+
- runScript: ./setup.js
143+
`,
144+
{ sourcePath: flowPath },
145+
),
146+
(error) =>
147+
error instanceof AppError &&
148+
error.code === 'COMMAND_FAILED' &&
149+
/runScript failed/.test(error.message) &&
150+
/http\.post failed/.test(error.message),
151+
);
152+
});
153+
131154
test('parseMaestroReplayFlow rejects unsupported Maestro commands', () => {
132155
assert.throws(
133156
() => parseMaestroReplayFlow('---\n- travelThroughTime: Save\n'),
@@ -325,7 +348,7 @@ test('parseMaestroReplayFlow keeps visible-gated runFlow commands for runtime ev
325348
]);
326349
});
327350

328-
test('parseMaestroReplayFlow accepts launchApp reset options without state-reset side effects', () => {
351+
test('parseMaestroReplayFlow accepts launchApp reset options', () => {
329352
const parsed = parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab
330353
---
331354
- launchApp:

src/compat/maestro/interactions.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,7 @@ export function convertScrollUntilVisible(
142142
assertOnlyKeys(value, 'scrollUntilVisible', ['element', 'direction', 'timeout']);
143143
const selector = maestroSelector(value.element, 'scrollUntilVisible.element', [], context);
144144
const direction =
145-
typeof value.direction === 'string'
146-
? readScrollPositionalsFromDirectionSwipe(value.direction)[0]
147-
: 'down';
145+
typeof value.direction === 'string' ? readScrollUntilVisibleDirection(value.direction) : 'down';
148146
const timeoutMs = String(readTimeoutMs(value, 5000));
149147
return [action(MAESTRO_RUNTIME_COMMAND.scrollUntilVisible, [selector, timeoutMs, direction])];
150148
}
@@ -198,6 +196,20 @@ function readScrollPositionalsFromDirectionSwipe(direction: string): string[] {
198196
}
199197
}
200198

199+
function readScrollUntilVisibleDirection(direction: string): string {
200+
switch (direction.toLowerCase()) {
201+
case 'up':
202+
case 'down':
203+
case 'left':
204+
case 'right':
205+
return direction.toLowerCase();
206+
default:
207+
throw unsupportedMaestroSyntax(
208+
'Maestro scrollUntilVisible.direction must be UP, DOWN, LEFT, or RIGHT.',
209+
);
210+
}
211+
}
212+
201213
export function convertPressKey(value: unknown): SessionAction {
202214
const key = requireStringValue('pressKey', value).toLowerCase();
203215
if (key === 'back') return action('back');

src/compat/maestro/run-script.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
} from './support.ts';
1313
import type { MaestroParseContext } from './types.ts';
1414

15+
const RUN_SCRIPT_TIMEOUT_MS = 30_000;
16+
1517
type HttpResponse = {
1618
status: number;
1719
body: string;
@@ -51,7 +53,7 @@ export function executeRunScript(value: unknown, context: MaestroParseContext):
5153
try {
5254
vm.runInNewContext(script, buildScriptGlobals(scriptEnv, output), {
5355
filename: scriptPath,
54-
timeout: 30_000,
56+
timeout: RUN_SCRIPT_TIMEOUT_MS,
5557
});
5658
} catch (error) {
5759
throw new AppError(
@@ -124,13 +126,41 @@ function runHttpRequestSync(
124126
headers: options?.headers ?? {},
125127
body: options?.body ?? '',
126128
}),
127-
timeoutMs: 30_000,
129+
timeoutMs: RUN_SCRIPT_TIMEOUT_MS,
130+
allowFailure: true,
128131
});
129-
return JSON.parse(result.stdout) as HttpResponse;
132+
if (result.exitCode !== 0) {
133+
throw new AppError(
134+
'COMMAND_FAILED',
135+
`Maestro runScript http.${method.toLowerCase()} failed for ${url}: ${trimHttpErrorOutput(result.stderr)}`,
136+
{
137+
exitCode: result.exitCode,
138+
stderr: result.stderr,
139+
},
140+
);
141+
}
142+
try {
143+
return JSON.parse(result.stdout) as HttpResponse;
144+
} catch (error) {
145+
throw new AppError(
146+
'COMMAND_FAILED',
147+
`Maestro runScript http.${method.toLowerCase()} returned invalid JSON for ${url}`,
148+
{
149+
stdout: result.stdout.slice(0, 1000),
150+
stderr: result.stderr.slice(0, 1000),
151+
},
152+
error instanceof Error ? error : undefined,
153+
);
154+
}
130155
}
131156

132157
function stringifyOutputValue(value: unknown): string {
133158
if (typeof value === 'string') return value;
134159
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
135160
return JSON.stringify(value);
136161
}
162+
163+
function trimHttpErrorOutput(stderr: string): string {
164+
const trimmed = stderr.trim();
165+
return trimmed.length > 0 ? trimmed.slice(0, 1000) : 'request process exited without stderr';
166+
}

src/core/__tests__/dispatch-open.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,26 @@ test('dispatch open rejects URL as first argument when second URL is provided',
2323
},
2424
);
2525
});
26+
27+
test('dispatch open rejects Android launch arguments instead of dropping them', async () => {
28+
const device: DeviceInfo = {
29+
platform: 'android',
30+
id: 'emulator-5554',
31+
name: 'Pixel',
32+
kind: 'emulator',
33+
booted: true,
34+
};
35+
36+
await assert.rejects(
37+
() =>
38+
dispatchCommand(device, 'open', ['com.example.app'], undefined, {
39+
launchArgs: ['--fixture', 'demo'],
40+
}),
41+
(error: unknown) => {
42+
assert.equal(error instanceof AppError, true);
43+
assert.equal((error as AppError).code, 'UNSUPPORTED_OPERATION');
44+
assert.match((error as AppError).message, /Apple platforms/i);
45+
return true;
46+
},
47+
);
48+
});

src/core/dispatch.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,12 @@ async function handleOpenCommand(
225225
if (launchConsole && isDeepLinkTarget(app)) {
226226
throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE);
227227
}
228+
if (device.platform === 'android' && context?.launchArgs && context.launchArgs.length > 0) {
229+
throw new AppError(
230+
'UNSUPPORTED_OPERATION',
231+
'Launch arguments are currently supported only on Apple platforms.',
232+
);
233+
}
228234
if (context?.maestroClearState) {
229235
if (isDeepLinkTarget(app)) {
230236
throw new AppError(

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

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -480,10 +480,10 @@ test('runReplayScriptFile retries Maestro scrollUntilVisible with scroll probes'
480480
[
481481
['wait', ['label="Discover" || text="Discover" || id="Discover"', '500']],
482482
['find', ['Discover', 'wait', '500']],
483-
['scroll', ['down']],
483+
['scroll', ['up']],
484484
['wait', ['label="Discover" || text="Discover" || id="Discover"', '500']],
485485
['find', ['Discover', 'wait', '500']],
486-
['scroll', ['down']],
486+
['scroll', ['up']],
487487
['wait', ['label="Discover" || text="Discover" || id="Discover"', '200']],
488488
],
489489
);
@@ -728,6 +728,61 @@ test('runReplayScriptFile skips Maestro runFlow.when.visible commands when absen
728728
);
729729
});
730730

731+
test('runReplayScriptFile skips Maestro runFlow.when.visible commands on false predicate', async () => {
732+
const calls: CapturedInvocation[] = [];
733+
const { response } = await runReplayFixture({
734+
label: 'maestro-run-flow-when-visible-false-skip',
735+
script: [
736+
'appId: demo.app',
737+
'---',
738+
'- runFlow:',
739+
' when:',
740+
' visible: Continue',
741+
' commands:',
742+
' - tapOn: Continue',
743+
'',
744+
].join('\n'),
745+
flags: { replayBackend: 'maestro' },
746+
invoke: async (req) => {
747+
calls.push({ command: req.command, positionals: req.positionals, flags: req.flags });
748+
return { ok: true, data: { pass: false } };
749+
},
750+
});
751+
752+
assert.equal(response.ok, true);
753+
assert.deepEqual(
754+
calls.map((call) => [call.command, call.positionals]),
755+
[['is', ['visible', 'label="Continue" || text="Continue" || id="Continue"']]],
756+
);
757+
});
758+
759+
test('runReplayScriptFile propagates Maestro runFlow.when runtime errors', async () => {
760+
const { response } = await runReplayFixture({
761+
label: 'maestro-run-flow-when-visible-runtime-error',
762+
script: [
763+
'appId: demo.app',
764+
'---',
765+
'- runFlow:',
766+
' when:',
767+
' visible: Continue',
768+
' commands:',
769+
' - tapOn: Continue',
770+
'',
771+
].join('\n'),
772+
flags: { replayBackend: 'maestro' },
773+
invoke: async () => ({
774+
ok: false,
775+
error: { code: 'UNKNOWN', message: 'fetch failed' },
776+
}),
777+
});
778+
779+
assert.equal(response.ok, false);
780+
if (!response.ok) {
781+
assert.equal(response.error.code, 'UNKNOWN');
782+
assert.match(response.error.message, /fetch failed/);
783+
}
784+
});
785+
731786
test('runReplayScriptFile runs Maestro runFlow.when.visible commands when present', async () => {
732787
const calls: CapturedInvocation[] = [];
733788
const { response } = await runReplayFixture({
@@ -766,6 +821,42 @@ test('runReplayScriptFile runs Maestro runFlow.when.visible commands when presen
766821
);
767822
});
768823

824+
test('runReplayScriptFile runs nested Maestro runtime commands inside runFlow.when', async () => {
825+
const calls: CapturedInvocation[] = [];
826+
const { response } = await runReplayFixture({
827+
label: 'maestro-run-flow-when-nested-runtime',
828+
script: [
829+
'appId: demo.app',
830+
'---',
831+
'- runFlow:',
832+
' when:',
833+
' visible: Feed',
834+
' commands:',
835+
' - scrollUntilVisible:',
836+
' element: Done',
837+
' direction: DOWN',
838+
' timeout: 500',
839+
'',
840+
].join('\n'),
841+
flags: { replayBackend: 'maestro' },
842+
invoke: async (req) => {
843+
calls.push({ command: req.command, positionals: req.positionals, flags: req.flags });
844+
if (req.command === 'is') return { ok: true, data: { pass: true } };
845+
if (req.command === 'wait') return { ok: true, data: { found: true } };
846+
return { ok: true, data: {} };
847+
},
848+
});
849+
850+
assert.equal(response.ok, true);
851+
assert.deepEqual(
852+
calls.map((call) => [call.command, call.positionals]),
853+
[
854+
['is', ['visible', 'label="Feed" || text="Feed" || id="Feed"']],
855+
['wait', ['label="Done" || text="Done" || id="Done"', '500']],
856+
],
857+
);
858+
});
859+
769860
test('runReplayScriptFile reads shell env from request (client-collected), not daemon process.env', async () => {
770861
// Ensure the daemon's own process.env does NOT contain AD_VAR_APP.
771862
assert.equal(process.env.AD_VAR_APP, undefined);

0 commit comments

Comments
 (0)