Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/maestro-compat-debt-map.md

Large diffs are not rendered by default.

49 changes: 28 additions & 21 deletions src/compat/maestro/__tests__/replay-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,18 +580,26 @@ test('parseMaestroReplayFlow keeps visible-gated runFlow commands for runtime ev
{ platform: 'ios' },
);

assert.equal(parsed.actions[0]?.command, '__maestroRunFlowWhen');
assert.equal(parsed.actions[0]?.command, 'runFlow.when');
assert.deepEqual(parsed.actions[0]?.positionals, [
'visible',
'label="Continue" || text="Continue" || id="Continue"',
]);
assert.deepEqual(parsed.actions[0]?.flags.batchSteps, [
{
command: '__maestroTapOn',
positionals: ['label="Continue" || text="Continue" || id="Continue"'],
flags: { maestro: { allowNonHittableCoordinateFallback: true } },
},
]);
const control = parsed.actions[0]?.replayControl;
assert.equal(control?.kind, 'maestroRunFlowWhen');
if (control?.kind !== 'maestroRunFlowWhen') throw new Error('expected runFlow.when control');
assert.equal(control.mode, 'visible');
assert.equal(control.selector, 'label="Continue" || text="Continue" || id="Continue"');
assert.deepEqual(
control.actions.map((entry) => [entry.command, entry.positionals, entry.flags]),
[
[
'__maestroTapOn',
['label="Continue" || text="Continue" || id="Continue"'],
{ maestro: { allowNonHittableCoordinateFallback: true } },
],
],
);
});

test('parseMaestroReplayFlow keeps retry commands for runtime evaluation', () => {
Expand All @@ -608,20 +616,19 @@ test('parseMaestroReplayFlow keeps retry commands for runtime evaluation', () =>
{ env: { APP_SCHEME: 'example://' } },
);

assert.equal(parsed.actions[0]?.command, '__maestroRetry');
assert.equal(parsed.actions[0]?.command, 'retry');
assert.deepEqual(parsed.actions[0]?.positionals, ['3']);
assert.deepEqual(parsed.actions[0]?.flags.batchSteps, [
{
command: 'open',
positionals: ['example://details'],
flags: {},
},
{
command: '__maestroAssertVisible',
positionals: ['label="Article" || text="Article" || id="Article"', '5000'],
flags: {},
},
]);
const control = parsed.actions[0]?.replayControl;
assert.equal(control?.kind, 'retry');
if (control?.kind !== 'retry') throw new Error('expected retry control');
assert.equal(control.maxRetries, 3);
assert.deepEqual(
control.actions.map((entry) => [entry.command, entry.positionals, entry.flags]),
[
['open', ['example://details'], {}],
['__maestroAssertVisible', ['label="Article" || text="Article" || id="Article"', '5000'], {}],
],
);
});

test('parseMaestroReplayFlow accepts launchApp reset options', () => {
Expand Down
31 changes: 19 additions & 12 deletions src/compat/maestro/__tests__/runtime-flow.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import type { CommandFlags } from '../../../core/dispatch.ts';
import type { DaemonRequest, DaemonResponse, SessionAction } from '../../../daemon/types.ts';
import { invokeMaestroRunFlowWhen } from '../runtime-flow.ts';
import { invokeMaestroRunFlowWhenControl } from '../runtime-flow.ts';

test('invokeMaestroRunFlowWhen waits briefly for visible conditions', async () => {
test('invokeMaestroRunFlowWhenControl waits briefly for visible conditions', async () => {
let snapshots = 0;
const invokedActions: SessionAction[] = [];
const batchSteps: CommandFlags['batchSteps'] = [
{ command: 'click', positionals: ['label="Dismiss"'] },
const actions: SessionAction[] = [
{ ts: Date.now(), command: 'click', positionals: ['label="Dismiss"'], flags: {} },
];

const response = await invokeMaestroRunFlowWhen({
const response = await invokeMaestroRunFlowWhenControl({
baseReq: {
token: 't',
session: 's',
flags: { platform: 'android' },
},
positionals: ['visible', 'label="Dismiss" || text="Dismiss" || id="Dismiss"'],
batchSteps,
control: {
kind: 'maestroRunFlowWhen',
mode: 'visible',
selector: 'label="Dismiss" || text="Dismiss" || id="Dismiss"',
actions,
},
line: 12,
step: 4,
invoke: async (req: DaemonRequest): Promise<DaemonResponse> => {
Expand Down Expand Up @@ -61,16 +64,20 @@ test('invokeMaestroRunFlowWhen waits briefly for visible conditions', async () =
}
});

test('invokeMaestroRunFlowWhen keeps notVisible conditions immediate', async () => {
test('invokeMaestroRunFlowWhenControl keeps notVisible conditions immediate', async () => {
let snapshots = 0;
const response = await invokeMaestroRunFlowWhen({
const response = await invokeMaestroRunFlowWhenControl({
baseReq: {
token: 't',
session: 's',
flags: { platform: 'android' },
},
positionals: ['notVisible', 'label="Loading" || text="Loading" || id="Loading"'],
batchSteps: [{ command: 'click', positionals: ['label="Continue"'] }],
control: {
kind: 'maestroRunFlowWhen',
mode: 'notVisible',
selector: 'label="Loading" || text="Loading" || id="Loading"',
actions: [{ ts: Date.now(), command: 'click', positionals: ['label="Continue"'], flags: {} }],
},
line: 14,
step: 7,
invoke: async (): Promise<DaemonResponse> => {
Expand Down
42 changes: 22 additions & 20 deletions src/compat/maestro/flow-control.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import type { CommandFlags } from '../../core/dispatch.ts';
import type { SessionAction } from '../../daemon/types.ts';
import { AppError } from '../../utils/errors.ts';
import { maestroSelector } from './interactions.ts';
import { MAESTRO_RUNTIME_COMMAND } from './runtime-commands.ts';
import {
action,
assertOnlyKeys,
Expand Down Expand Up @@ -107,8 +105,10 @@ export function convertRetry(
const commands = normalizeCommandList(value.commands);
const actions = convertCommandList(commands, config, context, deps);
return [
action(MAESTRO_RUNTIME_COMMAND.retry, [String(maxRetries)], {
batchSteps: actions.map(sessionActionToBatchStep),
replayControlAction('retry', [String(maxRetries)], {
kind: 'retry',
maxRetries,
actions,
}),
];
}
Expand Down Expand Up @@ -412,31 +412,33 @@ function wrapRunFlowCondition(
actions: SessionAction[],
condition: RunFlowCondition,
): SessionAction[] {
if (!condition.visibleSelector && !condition.notVisibleSelector) return actions;
if (condition.visibleSelector && condition.notVisibleSelector) {
const { visibleSelector, notVisibleSelector } = condition;
if (!visibleSelector && !notVisibleSelector) return actions;
if (visibleSelector && notVisibleSelector) {
throw unsupportedMaestroSyntax(
'Maestro runFlow.when cannot combine visible and notVisible yet.',
);
}
const mode = visibleSelector ? 'visible' : 'notVisible';
const selector = visibleSelector ?? notVisibleSelector ?? '';
return [
action(
MAESTRO_RUNTIME_COMMAND.runFlowWhen,
condition.visibleSelector
? ['visible', condition.visibleSelector]
: ['notVisible', condition.notVisibleSelector ?? ''],
{ batchSteps: actions.map(sessionActionToBatchStep) },
),
replayControlAction('runFlow.when', [mode, selector], {
kind: 'maestroRunFlowWhen',
mode,
selector,
actions,
}),
];
}

function sessionActionToBatchStep(
entry: SessionAction,
): NonNullable<CommandFlags['batchSteps']>[number] {
function replayControlAction(
command: string,
positionals: string[],
replayControl: NonNullable<SessionAction['replayControl']>,
): SessionAction {
return {
command: entry.command,
positionals: entry.positionals,
flags: entry.flags,
...(entry.runtime !== undefined ? { runtime: entry.runtime } : {}),
...action(command, positionals),
replayControl,
};
}

Expand Down
2 changes: 0 additions & 2 deletions src/compat/maestro/runtime-commands.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
export const MAESTRO_RUNTIME_COMMAND = {
runFlowWhen: '__maestroRunFlowWhen',
retry: '__maestroRetry',
runScript: '__maestroRunScript',
assertVisible: '__maestroAssertVisible',
assertNotVisible: '__maestroAssertNotVisible',
Expand Down
93 changes: 22 additions & 71 deletions src/compat/maestro/runtime-flow.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import { type CommandFlags } from '../../core/dispatch.ts';
import type { DaemonRequest, DaemonResponse } from '../../daemon/types.ts';
import type { DaemonRequest, DaemonResponse, SessionReplayControl } from '../../daemon/types.ts';
import { getSnapshotReferenceFrame } from '../../daemon/touch-reference-frame.ts';
import {
batchStepsToSessionActions,
invokeReplayActionBlock,
invokeReplayRetryBlock,
} from '../../replay/control-flow-runtime.ts';
import { invokeReplayActionBlock } from '../../replay/control-flow-runtime.ts';
import {
captureMaestroRawSnapshot,
errorResponse,
Expand All @@ -24,78 +19,33 @@ const MAESTRO_RUN_FLOW_WHEN_POLICY = {
visiblePollMs: 250,
} as const;

type MaestroRunFlowWhenCondition =
| { ok: true; mode: string; selector: string }
| { ok: false; response: DaemonResponse };
type MaestroRunFlowWhenControl = Extract<SessionReplayControl, { kind: 'maestroRunFlowWhen' }>;

export async function invokeMaestroRunFlowWhen(params: {
export async function invokeMaestroRunFlowWhenControl(params: {
baseReq: ReplayBaseRequest;
positionals: string[];
batchSteps: CommandFlags['batchSteps'] | undefined;
control: MaestroRunFlowWhenControl;
line: number;
step: number;
invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
invokeReplayAction: MaestroReplayInvoker;
}): Promise<DaemonResponse> {
const condition = readMaestroRunFlowWhenCondition(params.positionals);
if (!condition.ok) return condition.response;
const conditionResult = await evaluateMaestroRunFlowWhenCondition(params, condition);
const conditionResult = await evaluateMaestroRunFlowWhenCondition(params, params.control);
if (!conditionResult.ok) return conditionResult.response;
if (!conditionResult.matched) {
return {
ok: true,
data: { skipped: true, condition: condition.mode, selector: condition.selector },
data: { skipped: true, condition: params.control.mode, selector: params.control.selector },
};
}
return await invokeMaestroRunFlowWhenSteps(params, condition);
}

export async function invokeMaestroRetry(params: {
positionals: string[];
batchSteps: CommandFlags['batchSteps'] | undefined;
line: number;
step: number;
invokeReplayAction: MaestroReplayInvoker;
}): Promise<DaemonResponse> {
const [maxRetriesValue = '1'] = params.positionals;
const maxRetries = Number(maxRetriesValue);
if (!Number.isInteger(maxRetries) || maxRetries < 0) {
return errorResponse('INVALID_ARGS', 'retry.maxRetries must be a non-negative integer.');
}

return await invokeReplayRetryBlock({
actions: batchStepsToSessionActions(params.batchSteps),
maxRetries,
line: params.line,
step: params.step,
invokeReplayAction: params.invokeReplayAction,
});
}

function readMaestroRunFlowWhenCondition(positionals: string[]): MaestroRunFlowWhenCondition {
const [mode, selector] = positionals;
if ((mode !== 'visible' && mode !== 'notVisible') || !selector) {
return {
ok: false,
response: errorResponse(
'INVALID_ARGS',
'runFlow.when requires visible/notVisible and a selector.',
),
};
}
return {
ok: true,
mode,
selector,
};
return await invokeMaestroRunFlowWhenSteps(params);
}

async function evaluateMaestroRunFlowWhenCondition(
params: {
baseReq: ReplayBaseRequest;
invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
},
condition: Extract<MaestroRunFlowWhenCondition, { ok: true }>,
condition: MaestroRunFlowWhenControl,
): Promise<{ ok: true; matched: boolean } | { ok: false; response: DaemonResponse }> {
if (condition.mode === 'visible') {
return await waitForMaestroRunFlowVisibleCondition(params, condition);
Expand All @@ -118,7 +68,7 @@ async function waitForMaestroRunFlowVisibleCondition(
baseReq: ReplayBaseRequest;
invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
},
condition: Extract<MaestroRunFlowWhenCondition, { ok: true }>,
condition: MaestroRunFlowWhenControl,
): Promise<{ ok: true; matched: boolean } | { ok: false; response: DaemonResponse }> {
// Maestro conditionals commonly guard UI that appears immediately after the
// previous command. Keep this bounded and only for visible; notVisible stays
Expand Down Expand Up @@ -160,17 +110,14 @@ function readMaestroRunFlowVisibleCondition(
return { ok: true, matched };
}

async function invokeMaestroRunFlowWhenSteps(
params: {
batchSteps: CommandFlags['batchSteps'] | undefined;
line: number;
step: number;
invokeReplayAction: MaestroReplayInvoker;
},
condition: Extract<MaestroRunFlowWhenCondition, { ok: true }>,
): Promise<DaemonResponse> {
async function invokeMaestroRunFlowWhenSteps(params: {
control: MaestroRunFlowWhenControl;
line: number;
step: number;
invokeReplayAction: MaestroReplayInvoker;
}): Promise<DaemonResponse> {
const response = await invokeReplayActionBlock({
actions: batchStepsToSessionActions(params.batchSteps),
actions: params.control.actions,
line: params.line,
step: params.step,
invokeReplayAction: params.invokeReplayAction,
Expand All @@ -179,6 +126,10 @@ async function invokeMaestroRunFlowWhenSteps(

return {
ok: true,
data: { ran: response.data?.ran, condition: condition.mode, selector: condition.selector },
data: {
ran: response.data?.ran,
condition: params.control.mode,
selector: params.control.selector,
},
};
}
7 changes: 0 additions & 7 deletions src/compat/maestro/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { type CommandFlags } from '../../core/dispatch.ts';
import { asAppError } from '../../utils/errors.ts';
import type { ReplayVarScope } from '../../replay/vars.ts';
import type { DaemonRequest, DaemonResponse } from '../../daemon/types.ts';
Expand All @@ -9,7 +8,6 @@ import {
invokeMaestroAssertVisible,
invokeMaestroWaitForAnimationToEnd,
} from './runtime-assertions.ts';
import { invokeMaestroRetry, invokeMaestroRunFlowWhen } from './runtime-flow.ts';
import {
errorResponse,
type MaestroReplayInvoker,
Expand All @@ -28,7 +26,6 @@ export async function invokeMaestroRuntimeCommand(params: {
command: string;
baseReq: ReplayBaseRequest;
positionals: string[];
batchSteps: CommandFlags['batchSteps'] | undefined;
scope: ReplayVarScope;
line: number;
step: number;
Expand All @@ -40,8 +37,6 @@ export async function invokeMaestroRuntimeCommand(params: {
return await invokeMaestroAssertVisible(params);
case MAESTRO_RUNTIME_COMMAND.assertNotVisible:
return await invokeMaestroAssertNotVisible(params);
case MAESTRO_RUNTIME_COMMAND.retry:
return await invokeMaestroRetry(params);
case MAESTRO_RUNTIME_COMMAND.pressEnter:
return await invokeMaestroPressEnter(params);
case MAESTRO_RUNTIME_COMMAND.waitForAnimationToEnd:
Expand All @@ -56,8 +51,6 @@ export async function invokeMaestroRuntimeCommand(params: {
return await invokeMaestroTapOn(params);
case MAESTRO_RUNTIME_COMMAND.tapPointPercent:
return await invokeMaestroTapPointPercent(params);
case MAESTRO_RUNTIME_COMMAND.runFlowWhen:
return await invokeMaestroRunFlowWhen(params);
case MAESTRO_RUNTIME_COMMAND.runScript:
return invokeMaestroRunScript(params);
default:
Expand Down
Loading
Loading