Skip to content

Commit 7705032

Browse files
committed
refactor: narrow Maestro flow runtime bridge
1 parent f74f4e0 commit 7705032

11 files changed

Lines changed: 195 additions & 135 deletions

File tree

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

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -522,18 +522,26 @@ test('parseMaestroReplayFlow keeps visible-gated runFlow commands for runtime ev
522522
{ platform: 'ios' },
523523
);
524524

525-
assert.equal(parsed.actions[0]?.command, '__maestroRunFlowWhen');
525+
assert.equal(parsed.actions[0]?.command, 'runFlow.when');
526526
assert.deepEqual(parsed.actions[0]?.positionals, [
527527
'visible',
528528
'label="Continue" || text="Continue" || id="Continue"',
529529
]);
530-
assert.deepEqual(parsed.actions[0]?.flags.batchSteps, [
531-
{
532-
command: '__maestroTapOn',
533-
positionals: ['label="Continue" || text="Continue" || id="Continue"'],
534-
flags: { maestro: { allowNonHittableCoordinateFallback: true } },
535-
},
536-
]);
530+
const control = parsed.actions[0]?.replayControl;
531+
assert.equal(control?.kind, 'maestroRunFlowWhen');
532+
if (control?.kind !== 'maestroRunFlowWhen') throw new Error('expected runFlow.when control');
533+
assert.equal(control.mode, 'visible');
534+
assert.equal(control.selector, 'label="Continue" || text="Continue" || id="Continue"');
535+
assert.deepEqual(
536+
control.actions.map((entry) => [entry.command, entry.positionals, entry.flags]),
537+
[
538+
[
539+
'__maestroTapOn',
540+
['label="Continue" || text="Continue" || id="Continue"'],
541+
{ maestro: { allowNonHittableCoordinateFallback: true } },
542+
],
543+
],
544+
);
537545
});
538546

539547
test('parseMaestroReplayFlow keeps retry commands for runtime evaluation', () => {
@@ -550,20 +558,19 @@ test('parseMaestroReplayFlow keeps retry commands for runtime evaluation', () =>
550558
{ env: { APP_SCHEME: 'example://' } },
551559
);
552560

553-
assert.equal(parsed.actions[0]?.command, '__maestroRetry');
561+
assert.equal(parsed.actions[0]?.command, 'retry');
554562
assert.deepEqual(parsed.actions[0]?.positionals, ['3']);
555-
assert.deepEqual(parsed.actions[0]?.flags.batchSteps, [
556-
{
557-
command: 'open',
558-
positionals: ['example://details'],
559-
flags: {},
560-
},
561-
{
562-
command: '__maestroAssertVisible',
563-
positionals: ['label="Article" || text="Article" || id="Article"', '5000'],
564-
flags: {},
565-
},
566-
]);
563+
const control = parsed.actions[0]?.replayControl;
564+
assert.equal(control?.kind, 'retry');
565+
if (control?.kind !== 'retry') throw new Error('expected retry control');
566+
assert.equal(control.maxRetries, 3);
567+
assert.deepEqual(
568+
control.actions.map((entry) => [entry.command, entry.positionals, entry.flags]),
569+
[
570+
['open', ['example://details'], {}],
571+
['__maestroAssertVisible', ['label="Article" || text="Article" || id="Article"', '5000'], {}],
572+
],
573+
);
567574
});
568575

569576
test('parseMaestroReplayFlow accepts launchApp reset options', () => {

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

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,27 @@
11
import assert from 'node:assert/strict';
22
import { test } from 'vitest';
3-
import type { CommandFlags } from '../../../core/dispatch.ts';
43
import type { DaemonRequest, DaemonResponse, SessionAction } from '../../../daemon/types.ts';
5-
import { invokeMaestroRunFlowWhen } from '../runtime-flow.ts';
4+
import { invokeMaestroRunFlowWhenControl } from '../runtime-flow.ts';
65

7-
test('invokeMaestroRunFlowWhen waits briefly for visible conditions', async () => {
6+
test('invokeMaestroRunFlowWhenControl waits briefly for visible conditions', async () => {
87
let snapshots = 0;
98
const invokedActions: SessionAction[] = [];
10-
const batchSteps: CommandFlags['batchSteps'] = [
11-
{ command: 'click', positionals: ['label="Dismiss"'] },
9+
const actions: SessionAction[] = [
10+
{ ts: Date.now(), command: 'click', positionals: ['label="Dismiss"'], flags: {} },
1211
];
1312

14-
const response = await invokeMaestroRunFlowWhen({
13+
const response = await invokeMaestroRunFlowWhenControl({
1514
baseReq: {
1615
token: 't',
1716
session: 's',
1817
flags: { platform: 'android' },
1918
},
20-
positionals: ['visible', 'label="Dismiss" || text="Dismiss" || id="Dismiss"'],
21-
batchSteps,
19+
control: {
20+
kind: 'maestroRunFlowWhen',
21+
mode: 'visible',
22+
selector: 'label="Dismiss" || text="Dismiss" || id="Dismiss"',
23+
actions,
24+
},
2225
line: 12,
2326
step: 4,
2427
invoke: async (req: DaemonRequest): Promise<DaemonResponse> => {
@@ -61,16 +64,20 @@ test('invokeMaestroRunFlowWhen waits briefly for visible conditions', async () =
6164
}
6265
});
6366

64-
test('invokeMaestroRunFlowWhen keeps notVisible conditions immediate', async () => {
67+
test('invokeMaestroRunFlowWhenControl keeps notVisible conditions immediate', async () => {
6568
let snapshots = 0;
66-
const response = await invokeMaestroRunFlowWhen({
69+
const response = await invokeMaestroRunFlowWhenControl({
6770
baseReq: {
6871
token: 't',
6972
session: 's',
7073
flags: { platform: 'android' },
7174
},
72-
positionals: ['notVisible', 'label="Loading" || text="Loading" || id="Loading"'],
73-
batchSteps: [{ command: 'click', positionals: ['label="Continue"'] }],
75+
control: {
76+
kind: 'maestroRunFlowWhen',
77+
mode: 'notVisible',
78+
selector: 'label="Loading" || text="Loading" || id="Loading"',
79+
actions: [{ ts: Date.now(), command: 'click', positionals: ['label="Continue"'], flags: {} }],
80+
},
7481
line: 14,
7582
step: 7,
7683
invoke: async (): Promise<DaemonResponse> => {

src/compat/maestro/flow-control.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
import type { CommandFlags } from '../../core/dispatch.ts';
21
import type { SessionAction } from '../../daemon/types.ts';
32
import { AppError } from '../../utils/errors.ts';
43
import { maestroSelector } from './interactions.ts';
5-
import { MAESTRO_RUNTIME_COMMAND } from './runtime-commands.ts';
64
import {
75
action,
86
assertOnlyKeys,
@@ -107,8 +105,10 @@ export function convertRetry(
107105
const commands = normalizeCommandList(value.commands);
108106
const actions = convertCommandList(commands, config, context, deps);
109107
return [
110-
action(MAESTRO_RUNTIME_COMMAND.retry, [String(maxRetries)], {
111-
batchSteps: actions.map(sessionActionToBatchStep),
108+
replayControlAction('retry', [String(maxRetries)], {
109+
kind: 'retry',
110+
maxRetries,
111+
actions,
112112
}),
113113
];
114114
}
@@ -419,24 +419,29 @@ function wrapRunFlowCondition(
419419
);
420420
}
421421
return [
422-
action(
423-
MAESTRO_RUNTIME_COMMAND.runFlowWhen,
422+
replayControlAction(
423+
'runFlow.when',
424424
condition.visibleSelector
425425
? ['visible', condition.visibleSelector]
426426
: ['notVisible', condition.notVisibleSelector ?? ''],
427-
{ batchSteps: actions.map(sessionActionToBatchStep) },
427+
{
428+
kind: 'maestroRunFlowWhen',
429+
mode: condition.visibleSelector ? 'visible' : 'notVisible',
430+
selector: condition.visibleSelector ?? condition.notVisibleSelector ?? '',
431+
actions,
432+
},
428433
),
429434
];
430435
}
431436

432-
function sessionActionToBatchStep(
433-
entry: SessionAction,
434-
): NonNullable<CommandFlags['batchSteps']>[number] {
437+
function replayControlAction(
438+
command: string,
439+
positionals: string[],
440+
replayControl: NonNullable<SessionAction['replayControl']>,
441+
): SessionAction {
435442
return {
436-
command: entry.command,
437-
positionals: entry.positionals,
438-
flags: entry.flags,
439-
...(entry.runtime !== undefined ? { runtime: entry.runtime } : {}),
443+
...action(command, positionals),
444+
replayControl,
440445
};
441446
}
442447

src/compat/maestro/runtime-commands.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
export const MAESTRO_RUNTIME_COMMAND = {
2-
runFlowWhen: '__maestroRunFlowWhen',
3-
retry: '__maestroRetry',
42
runScript: '__maestroRunScript',
53
assertVisible: '__maestroAssertVisible',
64
assertNotVisible: '__maestroAssertNotVisible',

src/compat/maestro/runtime-flow.ts

Lines changed: 18 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
1-
import { type CommandFlags } from '../../core/dispatch.ts';
2-
import type { DaemonRequest, DaemonResponse } from '../../daemon/types.ts';
1+
import type { DaemonRequest, DaemonResponse, SessionReplayControl } from '../../daemon/types.ts';
32
import { getSnapshotReferenceFrame } from '../../daemon/touch-reference-frame.ts';
4-
import {
5-
batchStepsToSessionActions,
6-
invokeReplayActionBlock,
7-
invokeReplayRetryBlock,
8-
} from '../../replay/control-flow-runtime.ts';
3+
import { invokeReplayActionBlock } from '../../replay/control-flow-runtime.ts';
94
import {
105
captureMaestroRawSnapshot,
116
errorResponse,
@@ -24,21 +19,19 @@ const MAESTRO_RUN_FLOW_WHEN_POLICY = {
2419
visiblePollMs: 250,
2520
} as const;
2621

27-
type MaestroRunFlowWhenCondition =
28-
| { ok: true; mode: string; selector: string }
29-
| { ok: false; response: DaemonResponse };
22+
type MaestroRunFlowWhenCondition = { mode: 'visible' | 'notVisible'; selector: string };
23+
24+
type MaestroRunFlowWhenControl = Extract<SessionReplayControl, { kind: 'maestroRunFlowWhen' }>;
3025

31-
export async function invokeMaestroRunFlowWhen(params: {
26+
export async function invokeMaestroRunFlowWhenControl(params: {
3227
baseReq: ReplayBaseRequest;
33-
positionals: string[];
34-
batchSteps: CommandFlags['batchSteps'] | undefined;
28+
control: MaestroRunFlowWhenControl;
3529
line: number;
3630
step: number;
3731
invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
3832
invokeReplayAction: MaestroReplayInvoker;
3933
}): Promise<DaemonResponse> {
40-
const condition = readMaestroRunFlowWhenCondition(params.positionals);
41-
if (!condition.ok) return condition.response;
34+
const condition = readMaestroRunFlowWhenCondition(params.control);
4235
const conditionResult = await evaluateMaestroRunFlowWhenCondition(params, condition);
4336
if (!conditionResult.ok) return conditionResult.response;
4437
if (!conditionResult.matched) {
@@ -50,43 +43,12 @@ export async function invokeMaestroRunFlowWhen(params: {
5043
return await invokeMaestroRunFlowWhenSteps(params, condition);
5144
}
5245

53-
export async function invokeMaestroRetry(params: {
54-
positionals: string[];
55-
batchSteps: CommandFlags['batchSteps'] | undefined;
56-
line: number;
57-
step: number;
58-
invokeReplayAction: MaestroReplayInvoker;
59-
}): Promise<DaemonResponse> {
60-
const [maxRetriesValue = '1'] = params.positionals;
61-
const maxRetries = Number(maxRetriesValue);
62-
if (!Number.isInteger(maxRetries) || maxRetries < 0) {
63-
return errorResponse('INVALID_ARGS', 'retry.maxRetries must be a non-negative integer.');
64-
}
65-
66-
return await invokeReplayRetryBlock({
67-
actions: batchStepsToSessionActions(params.batchSteps),
68-
maxRetries,
69-
line: params.line,
70-
step: params.step,
71-
invokeReplayAction: params.invokeReplayAction,
72-
});
73-
}
74-
75-
function readMaestroRunFlowWhenCondition(positionals: string[]): MaestroRunFlowWhenCondition {
76-
const [mode, selector] = positionals;
77-
if ((mode !== 'visible' && mode !== 'notVisible') || !selector) {
78-
return {
79-
ok: false,
80-
response: errorResponse(
81-
'INVALID_ARGS',
82-
'runFlow.when requires visible/notVisible and a selector.',
83-
),
84-
};
85-
}
46+
function readMaestroRunFlowWhenCondition(
47+
control: MaestroRunFlowWhenControl,
48+
): MaestroRunFlowWhenCondition {
8649
return {
87-
ok: true,
88-
mode,
89-
selector,
50+
mode: control.mode,
51+
selector: control.selector,
9052
};
9153
}
9254

@@ -95,7 +57,7 @@ async function evaluateMaestroRunFlowWhenCondition(
9557
baseReq: ReplayBaseRequest;
9658
invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
9759
},
98-
condition: Extract<MaestroRunFlowWhenCondition, { ok: true }>,
60+
condition: MaestroRunFlowWhenCondition,
9961
): Promise<{ ok: true; matched: boolean } | { ok: false; response: DaemonResponse }> {
10062
if (condition.mode === 'visible') {
10163
return await waitForMaestroRunFlowVisibleCondition(params, condition);
@@ -118,7 +80,7 @@ async function waitForMaestroRunFlowVisibleCondition(
11880
baseReq: ReplayBaseRequest;
11981
invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
12082
},
121-
condition: Extract<MaestroRunFlowWhenCondition, { ok: true }>,
83+
condition: MaestroRunFlowWhenCondition,
12284
): Promise<{ ok: true; matched: boolean } | { ok: false; response: DaemonResponse }> {
12385
// Maestro conditionals commonly guard UI that appears immediately after the
12486
// previous command. Keep this bounded and only for visible; notVisible stays
@@ -162,15 +124,15 @@ function readMaestroRunFlowVisibleCondition(
162124

163125
async function invokeMaestroRunFlowWhenSteps(
164126
params: {
165-
batchSteps: CommandFlags['batchSteps'] | undefined;
127+
control: MaestroRunFlowWhenControl;
166128
line: number;
167129
step: number;
168130
invokeReplayAction: MaestroReplayInvoker;
169131
},
170-
condition: Extract<MaestroRunFlowWhenCondition, { ok: true }>,
132+
condition: MaestroRunFlowWhenCondition,
171133
): Promise<DaemonResponse> {
172134
const response = await invokeReplayActionBlock({
173-
actions: batchStepsToSessionActions(params.batchSteps),
135+
actions: params.control.actions,
174136
line: params.line,
175137
step: params.step,
176138
invokeReplayAction: params.invokeReplayAction,

src/compat/maestro/runtime.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { type CommandFlags } from '../../core/dispatch.ts';
21
import { asAppError } from '../../utils/errors.ts';
32
import type { ReplayVarScope } from '../../replay/vars.ts';
43
import type { DaemonRequest, DaemonResponse } from '../../daemon/types.ts';
@@ -9,7 +8,6 @@ import {
98
invokeMaestroAssertVisible,
109
invokeMaestroWaitForAnimationToEnd,
1110
} from './runtime-assertions.ts';
12-
import { invokeMaestroRetry, invokeMaestroRunFlowWhen } from './runtime-flow.ts';
1311
import {
1412
errorResponse,
1513
type MaestroReplayInvoker,
@@ -28,7 +26,6 @@ export async function invokeMaestroRuntimeCommand(params: {
2826
command: string;
2927
baseReq: ReplayBaseRequest;
3028
positionals: string[];
31-
batchSteps: CommandFlags['batchSteps'] | undefined;
3229
scope: ReplayVarScope;
3330
line: number;
3431
step: number;
@@ -40,8 +37,6 @@ export async function invokeMaestroRuntimeCommand(params: {
4037
return await invokeMaestroAssertVisible(params);
4138
case MAESTRO_RUNTIME_COMMAND.assertNotVisible:
4239
return await invokeMaestroAssertNotVisible(params);
43-
case MAESTRO_RUNTIME_COMMAND.retry:
44-
return await invokeMaestroRetry(params);
4540
case MAESTRO_RUNTIME_COMMAND.pressEnter:
4641
return await invokeMaestroPressEnter(params);
4742
case MAESTRO_RUNTIME_COMMAND.waitForAnimationToEnd:
@@ -56,8 +51,6 @@ export async function invokeMaestroRuntimeCommand(params: {
5651
return await invokeMaestroTapOn(params);
5752
case MAESTRO_RUNTIME_COMMAND.tapPointPercent:
5853
return await invokeMaestroTapPointPercent(params);
59-
case MAESTRO_RUNTIME_COMMAND.runFlowWhen:
60-
return await invokeMaestroRunFlowWhen(params);
6154
case MAESTRO_RUNTIME_COMMAND.runScript:
6255
return invokeMaestroRunScript(params);
6356
default:

0 commit comments

Comments
 (0)