Skip to content

Commit 17a2be7

Browse files
feat(maestro): numeric option fields accept ${VAR} lookup interpolation (#1293)
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent ab80434 commit 17a2be7

13 files changed

Lines changed: 365 additions & 96 deletions

scripts/maestro-conformance/expected-divergence.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,22 +73,17 @@ export const FLOW_DIVERGENCES: Record<string, FlowDivergence> = {
7373
// --- Unsupported options on supported commands (parity gaps) ---
7474
'upstream/032_element_index': {
7575
classification: 'we-reject',
76-
reason: 'A literal tapOn.index is supported; the flow also uses a ${0 + 1} JS-expression index.',
77-
unsupported: ['tapOn.index (expression)'],
78-
tracking: COMPAT_TRACKER,
76+
reason:
77+
'tapOn.index supports ${VAR} lookup; the flow uses a ${0 + 1} JS expression, which is not supported.',
78+
unsupported: ['tapOn.index (JS expression)'],
79+
tracking: 'https://github.com/callstack/agent-device/issues/1292',
7980
},
8081
'upstream/034_press_key': {
8182
classification: 'we-reject',
8283
reason: 'pressKey supports back/enter/home/return; the flow exercises ~30 Android/TV keycodes.',
8384
unsupported: ['pressKey (extended keycodes)'],
8485
tracking: COMPAT_TRACKER,
8586
},
86-
'upstream/042_extended_wait': {
87-
classification: 'we-reject',
88-
reason: 'A literal extendedWaitUntil.timeout is supported; the flow interpolates ${TIMEOUT} from a flow env block (unresolved ${} is fail-loud).',
89-
unsupported: ['extendedWaitUntil.timeout (interpolation)'],
90-
tracking: COMPAT_TRACKER,
91-
},
9287
'upstream/076_optional_assertion': {
9388
classification: 'we-reject',
9489
reason: 'assertTrue is outside the supported subset; optional is now supported on scrollUntilVisible and extendedWaitUntil.',

scripts/maestro-conformance/normalize.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ function canonicalizeAgentCommand(
358358
return dropUndefined({
359359
kind: 'assert',
360360
mode: command.notVisible ? 'notVisible' : 'visible',
361-
timed: true,
361+
timed: command.timeout != null,
362362
selector: agentSelector(command.notVisible ?? command.visible),
363363
});
364364
case 'swipe':

src/compat/maestro/__tests__/program-ir-parser.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,71 @@ describe('parseMaestroProgram', () => {
463463
/Invalid Maestro YAML flow[\s\S]*\/flows\/includes\/child\.yaml:line 4/i,
464464
);
465465
});
466+
467+
test('parses numeric option fields as literals or variable references', () => {
468+
const program = parseMaestroProgram(
469+
[
470+
'---',
471+
'- extendedWaitUntil:',
472+
' visible: Ready',
473+
' timeout: ${TIMEOUT}',
474+
'- scrollUntilVisible:',
475+
' element: Item',
476+
' timeout: 1200',
477+
'- waitForAnimationToEnd: ${ANIM_TIMEOUT}',
478+
'- tapOn:',
479+
' id: button',
480+
' index: ${INDEX}',
481+
' repeat: ${REPEAT}',
482+
' delay: 50',
483+
'- doubleTapOn:',
484+
' id: button',
485+
' delay: ${DELAY}',
486+
'- swipe:',
487+
' start: 90%, 50%',
488+
' end: 10%, 50%',
489+
' duration: ${DURATION}',
490+
'- eraseText: ${CHARS}',
491+
'- eraseText:',
492+
' charactersToErase: ${CHARS}',
493+
].join('\n'),
494+
);
495+
496+
const extendedWaitUntil = commandOfKind(program.commands[0], 'extendedWaitUntil');
497+
assert.equal(extendedWaitUntil.timeout, '${TIMEOUT}');
498+
499+
const scrollUntilVisible = commandOfKind(program.commands[1], 'scrollUntilVisible');
500+
assert.equal(scrollUntilVisible.timeout, 1200);
501+
502+
const waitForAnimationToEnd = commandOfKind(program.commands[2], 'waitForAnimationToEnd');
503+
assert.equal(waitForAnimationToEnd.timeout, '${ANIM_TIMEOUT}');
504+
505+
const tapOn = commandOfKind(program.commands[3], 'tapOn');
506+
assert.equal(tapOn.index, '${INDEX}');
507+
assert.equal(tapOn.repeat, '${REPEAT}');
508+
assert.equal(tapOn.delay, 50);
509+
510+
const doubleTapOn = commandOfKind(program.commands[4], 'doubleTapOn');
511+
assert.equal(doubleTapOn.delay, '${DELAY}');
512+
513+
const swipe = commandOfKind(program.commands[5], 'swipe');
514+
assert.equal(swipe.gesture.kind, 'coordinates');
515+
assert.equal(swipe.gesture.duration, '${DURATION}');
516+
517+
const eraseTextScalar = commandOfKind(program.commands[6], 'eraseText');
518+
assert.equal(eraseTextScalar.charactersToErase, '${CHARS}');
519+
const eraseTextMap = commandOfKind(program.commands[7], 'eraseText');
520+
assert.equal(eraseTextMap.charactersToErase, '${CHARS}');
521+
522+
assert.throws(
523+
() => parseMaestroProgram('---\n- tapOn:\n id: button\n index: ${0 + 1}\n'),
524+
/tapOn\.index.*non-negative integer.*line 4/i,
525+
);
526+
assert.throws(
527+
() => parseMaestroProgram('---\n- eraseText: 0\n'),
528+
/eraseText.*positive integer.*line 2/i,
529+
);
530+
});
466531
});
467532

468533
function commandOfKind<K extends MaestroCommand['kind']>(

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,4 +344,81 @@ describe('MaestroRuntimePort', () => {
344344
}),
345345
).rejects.toBe(failure);
346346
});
347+
348+
test('resolves ${VAR} numeric option fields to numbers at runtime', async () => {
349+
const calls: RecordedCall[] = [];
350+
const operations = makeOperations({
351+
tapOn: vi.fn(async (input, context) => record(calls, 'tapOn', input, context)),
352+
doubleTapOn: vi.fn(async (input, context) => record(calls, 'doubleTapOn', input, context)),
353+
eraseText: vi.fn(async (input, context) => record(calls, 'eraseText', input, context)),
354+
scrollUntilVisible: vi.fn(async (input, context) =>
355+
record(calls, 'scrollUntilVisible', input, context),
356+
),
357+
waitForAnimationToEnd: vi.fn(async (input, context) =>
358+
record(calls, 'waitForAnimationToEnd', input, context),
359+
),
360+
gesture: vi.fn(async (input, context) => record(calls, 'gesture', input, context)),
361+
});
362+
const program = parseMaestroProgram(
363+
[
364+
'---',
365+
'- tapOn:',
366+
' id: button',
367+
' index: ${INDEX}',
368+
' repeat: ${REPEAT}',
369+
' delay: ${DELAY}',
370+
'- doubleTapOn:',
371+
' id: button',
372+
' delay: ${DELAY}',
373+
'- eraseText:',
374+
' charactersToErase: ${CHARS}',
375+
'- scrollUntilVisible:',
376+
' element: Item',
377+
' timeout: ${SCROLL_TIMEOUT}',
378+
'- waitForAnimationToEnd: ${ANIM_TIMEOUT}',
379+
'- swipe:',
380+
' start: 90%, 50%',
381+
' end: 10%, 50%',
382+
' duration: ${DURATION}',
383+
].join('\n'),
384+
);
385+
386+
await executeMaestroProgram(program, createMaestroRuntimePort(operations), {
387+
env: {
388+
INDEX: '2',
389+
REPEAT: '3',
390+
DELAY: '100',
391+
CHARS: '4',
392+
SCROLL_TIMEOUT: '5000',
393+
ANIM_TIMEOUT: '2000',
394+
DURATION: '250',
395+
},
396+
});
397+
398+
expect(calls).toHaveLength(6);
399+
expect(calls[0]).toMatchObject({
400+
kind: 'tapOn',
401+
input: { target: expect.anything(), repeat: 3, delay: 100 },
402+
});
403+
expect(calls[1]).toMatchObject({
404+
kind: 'doubleTapOn',
405+
input: { target: expect.anything(), delay: 100 },
406+
});
407+
expect(calls[2]).toMatchObject({
408+
kind: 'eraseText',
409+
input: { charactersToErase: 4 },
410+
});
411+
expect(calls[3]).toMatchObject({
412+
kind: 'scrollUntilVisible',
413+
input: { timeoutMs: 5000 },
414+
});
415+
expect(calls[4]).toMatchObject({
416+
kind: 'waitForAnimationToEnd',
417+
input: { timeoutMs: 2000 },
418+
});
419+
expect(calls[5]).toMatchObject({
420+
kind: 'gesture',
421+
input: { durationMs: 250 },
422+
});
423+
});
347424
});

src/compat/maestro/engine-flow.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,50 @@ export function resolveCommand<T extends { readonly source: MaestroCommand['sour
2121
};
2222
}
2323

24+
export type ResolveNumericConstraints = {
25+
integer?: boolean;
26+
nonNegative?: boolean;
27+
positive?: boolean;
28+
};
29+
30+
function resolveNumericDescription(constraints: ResolveNumericConstraints): string {
31+
if (constraints.positive) return 'a positive integer';
32+
if (constraints.nonNegative) return 'a non-negative integer';
33+
if (constraints.integer) return 'an integer';
34+
return 'a finite number';
35+
}
36+
37+
export function resolveNumeric(
38+
value: number | string | undefined,
39+
name: string,
40+
constraints: ResolveNumericConstraints = {},
41+
): number | undefined {
42+
if (value === undefined) return undefined;
43+
const num = typeof value === 'number' ? value : Number(value);
44+
const description = resolveNumericDescription(constraints);
45+
if (!Number.isFinite(num)) {
46+
throw new AppError('INVALID_ARGS', `Maestro ${name} must be ${description}.`);
47+
}
48+
if (constraints.integer && !Number.isInteger(num)) {
49+
throw new AppError('INVALID_ARGS', `Maestro ${name} must be ${description}.`);
50+
}
51+
if (constraints.nonNegative && num < 0) {
52+
throw new AppError('INVALID_ARGS', `Maestro ${name} must be ${description}.`);
53+
}
54+
if (constraints.positive && num <= 0) {
55+
throw new AppError('INVALID_ARGS', `Maestro ${name} must be ${description}.`);
56+
}
57+
return num;
58+
}
59+
2460
export function readIterationCount(
2561
value: number | string | undefined,
2662
fallback: number,
2763
context: MaestroExecutionContext,
2864
name: string,
2965
): number {
3066
const resolved = value === undefined ? fallback : Number(context.resolve(String(value)));
31-
if (!Number.isInteger(resolved) || resolved < 0) {
32-
throw new AppError('INVALID_ARGS', `Maestro ${name} must resolve to a non-negative integer.`);
33-
}
34-
return resolved;
67+
return resolveNumeric(resolved, name, { integer: true, nonNegative: true }) ?? fallback;
3568
}
3669

3770
export function checkpointMaestroCancellation(signal: AbortSignal | undefined): void {

src/compat/maestro/program-ir-command-parser.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ import {
4646
readOptionalBoolean,
4747
readOptionalCommandOption,
4848
readOptionalEntry,
49-
readOptionalNonNegativeInteger,
5049
readOptionalNumber,
50+
readOptionalPositiveInteger,
5151
readOptionalString,
5252
readRequiredPositiveInteger,
5353
readRequiredString,
@@ -515,14 +515,3 @@ function parseLaunchArguments(
515515
if (value === null) invalidAt(`${name} expects a scalar, list, or map.`, node, context);
516516
return { kind: 'scalar', value };
517517
}
518-
519-
function readOptionalPositiveInteger(
520-
node: Node | null | undefined,
521-
name: string,
522-
context: MaestroProgramParseContext,
523-
): number | undefined {
524-
const value = readOptionalNonNegativeInteger(node, name, context);
525-
if (value !== undefined && value === 0)
526-
invalidAt(`Maestro ${name} expects a positive integer.`, node, context);
527-
return value;
528-
}

src/compat/maestro/program-ir-gesture-parser.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ export function parseMaestroSwipeCommand(
296296
function parseCoordinateSwipe(
297297
entries: readonly MaestroMapEntry[],
298298
source: MaestroSourceLocation,
299-
duration: number | undefined,
299+
duration: number | string | undefined,
300300
commandNode: Node,
301301
context: MaestroProgramParseContext,
302302
): MaestroSwipeCommand {
@@ -331,7 +331,7 @@ function parseTargetSwipe(
331331
entries: readonly MaestroMapEntry[],
332332
source: MaestroSourceLocation,
333333
direction: MaestroDirection | undefined,
334-
duration: number | undefined,
334+
duration: number | string | undefined,
335335
commandNode: Node,
336336
context: MaestroProgramParseContext,
337337
): MaestroSwipeCommand {
@@ -360,7 +360,7 @@ function parseTargetSwipe(
360360
function parseScreenSwipe(
361361
source: MaestroSourceLocation,
362362
direction: MaestroDirection | undefined,
363-
duration: number | undefined,
363+
duration: number | string | undefined,
364364
commandNode: Node,
365365
context: MaestroProgramParseContext,
366366
): MaestroSwipeCommand {

0 commit comments

Comments
 (0)