Skip to content

Commit d7dcb1d

Browse files
committed
fix: address maestro replay control review
1 parent 7705032 commit d7dcb1d

5 files changed

Lines changed: 135 additions & 72 deletions

File tree

src/compat/maestro/flow-control.ts

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -412,26 +412,25 @@ function wrapRunFlowCondition(
412412
actions: SessionAction[],
413413
condition: RunFlowCondition,
414414
): SessionAction[] {
415-
if (!condition.visibleSelector && !condition.notVisibleSelector) return actions;
416-
if (condition.visibleSelector && condition.notVisibleSelector) {
415+
const control = buildRunFlowConditionReplayControl(condition, actions);
416+
if (!control) return actions;
417+
return [replayControlAction('runFlow.when', [control.mode, control.selector], control)];
418+
}
419+
420+
function buildRunFlowConditionReplayControl(
421+
condition: RunFlowCondition,
422+
actions: SessionAction[],
423+
): Extract<NonNullable<SessionAction['replayControl']>, { kind: 'maestroRunFlowWhen' }> | null {
424+
const { visibleSelector, notVisibleSelector } = condition;
425+
if (!visibleSelector && !notVisibleSelector) return null;
426+
if (visibleSelector && notVisibleSelector) {
417427
throw unsupportedMaestroSyntax(
418428
'Maestro runFlow.when cannot combine visible and notVisible yet.',
419429
);
420430
}
421-
return [
422-
replayControlAction(
423-
'runFlow.when',
424-
condition.visibleSelector
425-
? ['visible', condition.visibleSelector]
426-
: ['notVisible', condition.notVisibleSelector ?? ''],
427-
{
428-
kind: 'maestroRunFlowWhen',
429-
mode: condition.visibleSelector ? 'visible' : 'notVisible',
430-
selector: condition.visibleSelector ?? condition.notVisibleSelector ?? '',
431-
actions,
432-
},
433-
),
434-
];
431+
const mode = visibleSelector ? 'visible' : 'notVisible';
432+
const selector = visibleSelector ?? notVisibleSelector ?? '';
433+
return { kind: 'maestroRunFlowWhen', mode, selector, actions };
435434
}
436435

437436
function replayControlAction(

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,38 @@ test('resolveReplayAction walks runtime hints', () => {
224224
assert.equal(resolved.runtime?.metroHost, '10.0.0.1');
225225
});
226226

227+
test('resolveReplayAction resolves replay control conditions without pre-resolving nested actions', () => {
228+
const action: SessionAction = {
229+
ts: 0,
230+
command: 'runFlow.when',
231+
positionals: ['visible', '${VISIBLE}'],
232+
flags: {},
233+
replayControl: {
234+
kind: 'maestroRunFlowWhen',
235+
mode: 'visible',
236+
selector: '${VISIBLE}',
237+
actions: [
238+
{
239+
ts: 0,
240+
command: 'tap',
241+
positionals: ['${TARGET}'],
242+
flags: {},
243+
},
244+
],
245+
},
246+
};
247+
const scope = buildReplayVarScope({
248+
fileEnv: { VISIBLE: 'Feed', TARGET: '${NEXT}', NEXT: 'Done' },
249+
});
250+
const resolved = resolveReplayAction(action, scope, LOC);
251+
assert.equal(resolved.replayControl?.kind, 'maestroRunFlowWhen');
252+
if (resolved.replayControl?.kind !== 'maestroRunFlowWhen') {
253+
throw new Error('expected runFlow.when control');
254+
}
255+
assert.equal(resolved.replayControl.selector, 'Feed');
256+
assert.deepEqual(resolved.replayControl.actions[0]?.positionals, ['${TARGET}']);
257+
});
258+
227259
test('parseReplayScriptDetailed tracks line numbers', () => {
228260
const script = [
229261
'# comment',
@@ -1824,6 +1856,72 @@ test('runReplayScriptFile runs nested Maestro runtime commands inside runFlow.wh
18241856
);
18251857
});
18261858

1859+
test('runReplayScriptFile resolves nested Maestro runFlow.when command variables once at execution', async () => {
1860+
const calls: CapturedInvocation[] = [];
1861+
const { response } = await runReplayFixture({
1862+
label: 'maestro-run-flow-when-nested-vars',
1863+
script: [
1864+
'appId: demo.app',
1865+
'env:',
1866+
' TARGET_LABEL: ${NEXT_LABEL}',
1867+
' NEXT_LABEL: ${FINAL_LABEL}',
1868+
' FINAL_LABEL: Done',
1869+
'---',
1870+
'- runFlow:',
1871+
' when:',
1872+
' visible: Feed',
1873+
' commands:',
1874+
' - tapOn: ${TARGET_LABEL}',
1875+
'',
1876+
].join('\n'),
1877+
flags: { replayBackend: 'maestro' },
1878+
invoke: async (req) => {
1879+
calls.push({ command: req.command, positionals: req.positionals, flags: req.flags });
1880+
if (req.command === 'snapshot') {
1881+
return {
1882+
ok: true,
1883+
data: {
1884+
nodes: [
1885+
{
1886+
index: 0,
1887+
type: 'application',
1888+
rect: { x: 0, y: 0, width: 390, height: 844 },
1889+
},
1890+
{
1891+
index: 1,
1892+
depth: 1,
1893+
parentIndex: 0,
1894+
type: 'statictext',
1895+
label: 'Feed',
1896+
rect: { x: 16, y: 100, width: 120, height: 24 },
1897+
},
1898+
{
1899+
index: 2,
1900+
depth: 1,
1901+
parentIndex: 0,
1902+
type: 'button',
1903+
label: '${FINAL_LABEL}',
1904+
rect: { x: 100, y: 300, width: 80, height: 40 },
1905+
},
1906+
],
1907+
},
1908+
};
1909+
}
1910+
return { ok: true, data: {} };
1911+
},
1912+
});
1913+
1914+
assert.equal(response.ok, true);
1915+
assert.deepEqual(
1916+
calls.map((call) => [call.command, call.positionals]),
1917+
[
1918+
['snapshot', []],
1919+
['snapshot', []],
1920+
['click', ['140', '320']],
1921+
],
1922+
);
1923+
});
1924+
18271925
test('runReplayScriptFile reads shell env from request (client-collected), not daemon process.env', async () => {
18281926
// Ensure the daemon's own process.env does NOT contain AD_VAR_APP.
18291927
assert.equal(process.env.AD_VAR_APP, undefined);

src/daemon/handlers/session-replay-action-runtime.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@ async function invokeReplayControl(params: {
160160
invokeReplayAction,
161161
});
162162
}
163-
return undefined;
163+
const _exhaustive: never = control;
164+
return _exhaustive;
164165
}
165166

166167
function readReplayOutputEnv(data: unknown): Record<string, string> | null {

src/replay/vars.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,29 @@ export function resolveReplayAction(
154154
positionals: (action.positionals ?? []).map((token) => resolveReplayString(token, scope, loc)),
155155
flags: resolveStringProps(action.flags, scope, loc) ?? {},
156156
runtime: resolveStringProps(action.runtime, scope, loc),
157-
replayControl: resolveStringProps(action.replayControl, scope, loc),
157+
replayControl: resolveReplayControl(action.replayControl, scope, loc),
158158
};
159159
}
160160

161+
function resolveReplayControl(
162+
control: SessionAction['replayControl'] | undefined,
163+
scope: ReplayVarScope,
164+
loc: { file: string; line: number },
165+
): SessionAction['replayControl'] | undefined {
166+
if (!control) return control;
167+
switch (control.kind) {
168+
case 'maestroRunFlowWhen':
169+
return {
170+
...control,
171+
selector: resolveReplayString(control.selector, scope, loc),
172+
};
173+
case 'retry':
174+
return control;
175+
}
176+
const _exhaustive: never = control;
177+
return _exhaustive;
178+
}
179+
161180
function resolveStringProps<T extends object>(
162181
obj: T | undefined,
163182
scope: ReplayVarScope,

test/integration/provider-scenarios/android-test-suite.test.ts

Lines changed: 0 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -126,60 +126,6 @@ test('Provider-backed integration Android Maestro replay uses fresh selector sna
126126
);
127127
});
128128

129-
test('Provider-backed integration Android Maestro executes runFlow conditions and retry batches at runtime', async () => {
130-
let snapshots = 0;
131-
await withProviderScenarioResource(
132-
async () =>
133-
await createAndroidSettingsWorld({
134-
snapshotXml: () => {
135-
snapshots += 1;
136-
return androidMaestroReplayXml('[100,300][260,360]');
137-
},
138-
}),
139-
async (world) => {
140-
const client = world.daemon.client();
141-
const suiteRoot = path.join(world.tempRoot, 'suite-maestro-runtime-flow');
142-
fs.mkdirSync(suiteRoot, { recursive: true });
143-
const flowPath = path.join(suiteRoot, 'runtime-flow.yaml');
144-
fs.writeFileSync(
145-
flowPath,
146-
[
147-
'appId: com.android.settings',
148-
'---',
149-
'- launchApp',
150-
'- runFlow:',
151-
' when:',
152-
' visible: Apps',
153-
' commands:',
154-
' - tapOn: Search',
155-
'- retry:',
156-
' maxRetries: 1',
157-
' commands:',
158-
' - assertVisible: Apps',
159-
'',
160-
].join('\n'),
161-
);
162-
163-
const suite = await client.replay.test({
164-
paths: [flowPath],
165-
backend: 'maestro',
166-
artifactsDir: path.join(suiteRoot, 'artifacts'),
167-
timeoutMs: 30000,
168-
...world.selection,
169-
});
170-
171-
assert.equal(suite.total, 1, JSON.stringify(suite));
172-
assert.equal(suite.passed, 1, JSON.stringify(suite));
173-
assert.equal(suite.failed, 0, JSON.stringify(suite));
174-
assert.deepEqual(
175-
world.adbCalls.find((call) => call.slice(0, 3).join(' ') === 'shell input tap'),
176-
['shell', 'input', 'tap', '180', '330'],
177-
);
178-
assert.equal(snapshots >= 3, true);
179-
},
180-
);
181-
});
182-
183129
function androidMaestroReplayXml(searchBounds: string): string {
184130
return [
185131
'<?xml version="1.0" encoding="UTF-8"?>',

0 commit comments

Comments
 (0)