Skip to content

Commit 6290a66

Browse files
fix(maestro): address review feedback on numeric resolution
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 6254c57 commit 6290a66

11 files changed

Lines changed: 215 additions & 177 deletions

scripts/maestro-conformance/normalize.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -322,20 +322,20 @@ function canonicalizeAgentCommand(
322322
stopApp: command.stopApp,
323323
});
324324
case 'tapOn': {
325-
const repeat = command.repeat ?? 1;
325+
const repeat = num(command.repeat) ?? 1;
326326
return canonicalTap({
327327
longPress: false,
328328
repeat,
329-
delay: repeat > 1 ? (command.delay ?? AGENT_REPEAT_DELAY_MS) : undefined,
330-
target: agentTarget(command.target, command.index, command.childOf),
329+
delay: repeat > 1 ? (num(command.delay) ?? AGENT_REPEAT_DELAY_MS) : undefined,
330+
target: agentTarget(command.target, num(command.index), command.childOf),
331331
});
332332
}
333333
case 'doubleTapOn':
334334
// Upstream compiles doubleTapOn to a repeat-2 tap with the same default delay.
335335
return canonicalTap({
336336
longPress: false,
337337
repeat: 2,
338-
delay: command.delay ?? AGENT_REPEAT_DELAY_MS,
338+
delay: num(command.delay) ?? AGENT_REPEAT_DELAY_MS,
339339
target: agentTarget(command.target),
340340
});
341341
case 'longPressOn':
@@ -366,7 +366,7 @@ function canonicalizeAgentCommand(
366366
case 'inputText':
367367
return dropUndefined({ kind: 'inputText', text: command.text });
368368
case 'eraseText':
369-
return dropUndefined({ kind: 'eraseText', count: command.charactersToErase });
369+
return dropUndefined({ kind: 'eraseText', count: num(command.charactersToErase) });
370370
case 'openLink':
371371
return dropUndefined({ kind: 'openLink', link: command.link });
372372
case 'scroll':
@@ -386,13 +386,13 @@ function canonicalizeAgentCommand(
386386
case 'takeScreenshot':
387387
return { kind: 'takeScreenshot' };
388388
case 'waitForAnimationToEnd':
389-
return dropUndefined({ kind: 'waitForAnimationToEnd', timeout: command.timeout });
389+
return dropUndefined({ kind: 'waitForAnimationToEnd', timeout: num(command.timeout) });
390390
case 'stopApp':
391391
return { kind: 'stopApp' };
392392
case 'repeat':
393-
return { kind: 'repeat', times: command.times };
393+
return { kind: 'repeat', times: num(command.times) ?? str(command.times) };
394394
case 'retry':
395-
return dropUndefined({ kind: 'retry', maxRetries: command.maxRetries });
395+
return dropUndefined({ kind: 'retry', maxRetries: num(command.maxRetries) ?? str(command.maxRetries) });
396396
case 'runFlow':
397397
return { kind: 'runFlow', source: command.include.kind === 'file' ? 'file' : 'commands' };
398398
case 'runScript':
@@ -436,7 +436,7 @@ function agentSelector(
436436
}
437437

438438
function agentGesture(gesture: MaestroSwipeGesture): CanonicalGesture {
439-
const duration = gesture.duration ?? AGENT_SWIPE_DEFAULT_DURATION;
439+
const duration = num(gesture.duration) ?? AGENT_SWIPE_DEFAULT_DURATION;
440440
switch (gesture.kind) {
441441
case 'screen':
442442
return { mode: 'direction', direction: gesture.direction, duration };

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

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,18 @@ test('resolveNumeric coerces valid resolved strings and numbers', () => {
1616
expect(resolveNumeric(undefined, 'x', {})).toBeUndefined();
1717
});
1818

19-
test('resolveNumeric rejects blank, whitespace, and malformed resolved strings', () => {
19+
test('resolveNumeric trims whitespace before coercion', () => {
20+
expect(resolveNumeric(' 42 ', 'x', {})).toBe(42);
21+
expect(resolveNumeric(' 0 ', 'x', { integer: true, nonNegative: true })).toBe(0);
22+
});
23+
24+
test('resolveNumeric accepts JS numeric forms for backward compatibility', () => {
25+
expect(resolveNumeric('1e3', 'x', {})).toBe(1000);
26+
expect(resolveNumeric('+5', 'x', {})).toBe(5);
27+
expect(resolveNumeric('.5', 'x', {})).toBe(0.5);
28+
});
29+
30+
test('resolveNumeric rejects blank, whitespace, malformed, and unsafe resolved strings', () => {
2031
expect(() => resolveNumeric('', 'x', {})).toThrow(/must be a finite number/);
2132
expect(() => resolveNumeric(' ', 'x', {})).toThrow(/must be a finite number/);
2233
expect(() => resolveNumeric('', 'x', { integer: true, nonNegative: true })).toThrow(
@@ -29,3 +40,23 @@ test('resolveNumeric rejects blank, whitespace, and malformed resolved strings',
2940
expect(() => resolveNumeric('1.2.3', 'x', {})).toThrow(/must be a finite number/);
3041
expect(() => resolveNumeric('1.5', 'x', { integer: true })).toThrow(/must be an integer/);
3142
});
43+
44+
test('resolveNumeric rejects out-of-range and negative values per constraints', () => {
45+
const huge = String(Number.MAX_SAFE_INTEGER + 1);
46+
expect(() => resolveNumeric(huge, 'x', {})).toThrow(/must be a finite number/);
47+
expect(() => resolveNumeric('-1', 'x', { nonNegative: true })).toThrow(
48+
/must be a non-negative finite number/,
49+
);
50+
expect(() => resolveNumeric('0', 'x', { integer: true, positive: true })).toThrow(
51+
/must be a positive integer/,
52+
);
53+
});
54+
55+
test('resolveNumeric resolves constraints from the shared field map when omitted', () => {
56+
expect(resolveNumeric('1000', 'extendedWaitUntil.timeout')).toBe(1000);
57+
expect(() => resolveNumeric('-1', 'extendedWaitUntil.timeout')).toThrow(
58+
/must be a non-negative finite number/,
59+
);
60+
expect(resolveNumeric('5', 'tapOn.repeat')).toBe(5);
61+
expect(() => resolveNumeric('0', 'tapOn.repeat')).toThrow(/must be a positive integer/);
62+
});

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

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -432,12 +432,83 @@ describe('MaestroRuntimePort', () => {
432432
executeMaestroProgram(program, createMaestroRuntimePort(operations), {
433433
env: { TIMEOUT: '' },
434434
}),
435-
).rejects.toThrow(/extendedWaitUntil\.timeout must be a finite number/);
435+
).rejects.toThrow(/extendedWaitUntil\.timeout must be a non-negative finite number/);
436436

437437
await expect(
438438
executeMaestroProgram(program, createMaestroRuntimePort(operations), {
439439
env: { TIMEOUT: ' ' },
440440
}),
441-
).rejects.toThrow(/extendedWaitUntil\.timeout must be a finite number/);
441+
).rejects.toThrow(/extendedWaitUntil\.timeout must be a non-negative finite number/);
442+
});
443+
444+
test('applies the default delay for doubleTapOn when none is specified', async () => {
445+
const calls: RecordedCall[] = [];
446+
const operations = makeOperations({
447+
doubleTapOn: vi.fn(async (input, context) => record(calls, 'doubleTapOn', input, context)),
448+
});
449+
const program = parseMaestroProgram('---\n- doubleTapOn:\n id: button\n');
450+
451+
await executeMaestroProgram(program, createMaestroRuntimePort(operations), {});
452+
453+
expect(calls).toHaveLength(1);
454+
expect(calls[0]).toMatchObject({ kind: 'doubleTapOn', input: { delay: 100 } });
455+
});
456+
457+
test('resolves tapOn.index into the target query', async () => {
458+
const resolveTarget = vi.fn(
459+
async (_input: unknown, context: { generation: number }): Promise<MaestroTargetMatch> => ({
460+
generation: context.generation,
461+
matched: true,
462+
visible: true,
463+
candidateCount: 1,
464+
rect: { x: 0, y: 0, width: 100, height: 50 },
465+
viewport: { x: 0, y: 0, width: 400, height: 800 },
466+
ref: 'button',
467+
}),
468+
);
469+
const tapOn = vi.fn();
470+
const operations = makeOperations({ resolveTarget, tapOn });
471+
const program = parseMaestroProgram('---\n- tapOn:\n id: button\n index: ${INDEX}\n');
472+
473+
await executeMaestroProgram(program, createMaestroRuntimePort(operations), {
474+
env: { INDEX: '2' },
475+
});
476+
477+
expect(resolveTarget).toHaveBeenCalledWith(
478+
expect.objectContaining({ index: 2 }),
479+
expect.anything(),
480+
);
481+
expect(tapOn).toHaveBeenCalled();
482+
});
483+
484+
test('rejects negative, huge, and accepts whitespace-padded resolved numeric values', async () => {
485+
const operations = makeOperations();
486+
const negative = parseMaestroProgram(
487+
'---\n- extendedWaitUntil:\n visible: Ready\n timeout: ${TIMEOUT}\n',
488+
);
489+
490+
await expect(
491+
executeMaestroProgram(negative, createMaestroRuntimePort(operations), {
492+
env: { TIMEOUT: '-1' },
493+
}),
494+
).rejects.toThrow(/extendedWaitUntil\.timeout must be a non-negative finite number/);
495+
496+
const huge = parseMaestroProgram(
497+
'---\n- extendedWaitUntil:\n visible: Ready\n timeout: ${TIMEOUT}\n',
498+
);
499+
500+
await expect(
501+
executeMaestroProgram(huge, createMaestroRuntimePort(operations), {
502+
env: { TIMEOUT: '99999999999999999' },
503+
}),
504+
).rejects.toThrow(/extendedWaitUntil\.timeout must be a non-negative finite number/);
505+
506+
const padded = parseMaestroProgram('---\n- waitForAnimationToEnd: ${TIMEOUT}\n');
507+
508+
await expect(
509+
executeMaestroProgram(padded, createMaestroRuntimePort(operations), {
510+
env: { TIMEOUT: ' 1000 ' },
511+
}),
512+
).resolves.toBeDefined();
442513
});
443514
});

src/compat/maestro/engine-flow.ts

Lines changed: 23 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import path from 'node:path';
22
import { AppError } from '../../kernel/errors.ts';
33
import { createRequestCanceledError } from '../../request/cancel.ts';
4-
import { INTEGER_STRING_PATTERN, NUMERIC_STRING_PATTERN } from './program-ir-values.ts';
4+
import {
5+
MAESTRO_NUMERIC_FIELD_CONSTRAINTS,
6+
numericDescription,
7+
type NumericScalarConstraints,
8+
} from './program-ir-values.ts';
59
import type {
610
MaestroCommand,
711
MaestroProgram,
@@ -22,56 +26,37 @@ export function resolveCommand<T extends { readonly source: MaestroCommand['sour
2226
};
2327
}
2428

25-
export type ResolveNumericConstraints = {
26-
integer?: boolean;
27-
nonNegative?: boolean;
28-
positive?: boolean;
29-
};
30-
31-
function resolveNumericDescription(constraints: ResolveNumericConstraints): string {
32-
if (constraints.positive) return 'a positive integer';
33-
if (constraints.nonNegative) return 'a non-negative integer';
34-
if (constraints.integer) return 'an integer';
35-
return 'a finite number';
36-
}
37-
3829
export function resolveNumeric(
3930
value: number | string | undefined,
4031
name: string,
41-
constraints: ResolveNumericConstraints = {},
32+
constraints?: NumericScalarConstraints,
4233
): number | undefined {
4334
if (value === undefined) return undefined;
44-
const num =
45-
typeof value === 'number'
46-
? value
47-
: Number(parseResolvedNumericString(value, name, constraints));
48-
const description = resolveNumericDescription(constraints);
49-
if (!Number.isFinite(num)) {
50-
throw new AppError('INVALID_ARGS', `Maestro ${name} must be ${description}.`);
35+
const effectiveConstraints = constraints ?? MAESTRO_NUMERIC_FIELD_CONSTRAINTS[name] ?? {};
36+
const description = numericDescription(effectiveConstraints);
37+
let num: number;
38+
if (typeof value === 'number') {
39+
num = value;
40+
} else {
41+
const trimmed = value.trim();
42+
if (trimmed === '') {
43+
throw new AppError('INVALID_ARGS', `Maestro ${name} must be ${description}.`);
44+
}
45+
num = Number(trimmed);
5146
}
52-
if (constraints.integer && !Number.isInteger(num)) {
47+
if (!Number.isFinite(num) || Math.abs(num) > Number.MAX_SAFE_INTEGER) {
5348
throw new AppError('INVALID_ARGS', `Maestro ${name} must be ${description}.`);
5449
}
55-
if (constraints.nonNegative && num < 0) {
50+
if (effectiveConstraints.integer && !Number.isSafeInteger(num)) {
5651
throw new AppError('INVALID_ARGS', `Maestro ${name} must be ${description}.`);
5752
}
58-
if (constraints.positive && num <= 0) {
53+
if (effectiveConstraints.nonNegative && num < 0) {
5954
throw new AppError('INVALID_ARGS', `Maestro ${name} must be ${description}.`);
6055
}
61-
return num;
62-
}
63-
64-
function parseResolvedNumericString(
65-
value: string,
66-
name: string,
67-
constraints: ResolveNumericConstraints,
68-
): string {
69-
const description = resolveNumericDescription(constraints);
70-
const pattern = constraints.integer ? INTEGER_STRING_PATTERN : NUMERIC_STRING_PATTERN;
71-
if (!pattern.test(value)) {
56+
if (effectiveConstraints.positive && num <= 0) {
7257
throw new AppError('INVALID_ARGS', `Maestro ${name} must be ${description}.`);
7358
}
74-
return value;
59+
return num;
7560
}
7661

7762
export function readIterationCount(
@@ -81,7 +66,7 @@ export function readIterationCount(
8166
name: string,
8267
): number {
8368
const resolved = value === undefined ? fallback : context.resolve(String(value));
84-
return resolveNumeric(resolved, name, { integer: true, nonNegative: true }) ?? fallback;
69+
return resolveNumeric(resolved, name)!;
8570
}
8671

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

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

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,9 @@ import {
4646
readOptionalBoolean,
4747
readOptionalCommandOption,
4848
readOptionalEntry,
49-
readOptionalNumber,
50-
readOptionalPositiveInteger,
49+
readOptionalNumeric,
5150
readOptionalString,
52-
readRequiredPositiveInteger,
51+
readRequiredNumeric,
5352
readRequiredString,
5453
readScalarMap,
5554
readScalarValue,
@@ -221,12 +220,12 @@ function parseEraseText(
221220
return {
222221
kind: 'eraseText',
223222
source,
224-
charactersToErase: readRequiredPositiveInteger(value, 'eraseText', context),
223+
charactersToErase: readRequiredNumeric(value, 'eraseText', context),
225224
};
226225
const entries = readMapEntries(value, 'eraseText', context);
227226
assertOnlyKeys(entries, 'eraseText', ['charactersToErase'], context);
228227
const charactersToErase = hasEntry(entries, 'charactersToErase')
229-
? readOptionalPositiveInteger(
228+
? readOptionalNumeric(
230229
entryValue(entries, 'charactersToErase'),
231230
'eraseText.charactersToErase',
232231
context,
@@ -354,7 +353,7 @@ function parseExtendedWaitUntil(
354353
const options = readOptionalCommandOption(entries, 'extendedWaitUntil', context);
355354
const condition = parseExtendedWaitUntilCondition(entries, commandNode, context);
356355
const timeout = hasEntry(entries, 'timeout')
357-
? readOptionalNumber(entryValue(entries, 'timeout'), 'extendedWaitUntil.timeout', context)
356+
? readOptionalNumeric(entryValue(entries, 'timeout'), 'extendedWaitUntil.timeout', context)
358357
: undefined;
359358
const optional = options.optional === true || condition.optional === true ? true : undefined;
360359
const command: MaestroExtendedWaitUntilCommand = {
@@ -425,7 +424,7 @@ function parseScrollUntilVisible(
425424
)
426425
: undefined;
427426
const timeout = hasEntry(entries, 'timeout')
428-
? readOptionalNumber(entryValue(entries, 'timeout'), 'scrollUntilVisible.timeout', context)
427+
? readOptionalNumeric(entryValue(entries, 'timeout'), 'scrollUntilVisible.timeout', context)
429428
: undefined;
430429
const optional = options.optional === true || parsedElement!.optional === true ? true : undefined;
431430
return stripUndefined({
@@ -476,13 +475,13 @@ function parseWaitForAnimationToEnd(
476475
const source = sourceAt(commandNode, context);
477476
if (isNullNode(value)) return { kind: 'waitForAnimationToEnd', source };
478477
if (isScalar(value)) {
479-
const timeout = readOptionalNumber(value, 'waitForAnimationToEnd', context);
478+
const timeout = readOptionalNumeric(value, 'waitForAnimationToEnd', context);
480479
return stripUndefined({ kind: 'waitForAnimationToEnd' as const, source, timeout });
481480
}
482481
const entries = readMapEntries(value, 'waitForAnimationToEnd', context);
483482
assertOnlyKeys(entries, 'waitForAnimationToEnd', ['timeout'], context);
484483
const timeout = hasEntry(entries, 'timeout')
485-
? readOptionalNumber(entryValue(entries, 'timeout'), 'waitForAnimationToEnd.timeout', context)
484+
? readOptionalNumeric(entryValue(entries, 'timeout'), 'waitForAnimationToEnd.timeout', context)
486485
: undefined;
487486
return stripUndefined({ kind: 'waitForAnimationToEnd' as const, source, timeout });
488487
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import {
1313
entryValue,
1414
hasEntry,
1515
invalidAt,
16-
readIntegerValue,
1716
readMapEntries,
17+
readRequiredNumeric,
1818
readOptionalString,
1919
readOptionalEntry,
2020
readRequiredString,
@@ -137,7 +137,7 @@ export function parseMaestroRepeatCommand(
137137
return {
138138
kind: 'repeat',
139139
source,
140-
times: readIntegerValue(entryValue(entries, 'times'), 'repeat.times', context),
140+
times: readRequiredNumeric(entryValue(entries, 'times'), 'repeat.times', context),
141141
commands: parseCommands(entryValue(entries, 'commands'), 'repeat.commands', context),
142142
};
143143
}
@@ -154,7 +154,7 @@ export function parseMaestroRetryCommand(
154154
if (!hasEntry(entries, 'commands'))
155155
invalidAt('Maestro retry requires commands.', commandNode, context);
156156
const maxRetries = hasEntry(entries, 'maxRetries')
157-
? readIntegerValue(entryValue(entries, 'maxRetries'), 'retry.maxRetries', context)
157+
? readRequiredNumeric(entryValue(entries, 'maxRetries'), 'retry.maxRetries', context)
158158
: undefined;
159159
return stripUndefined({
160160
kind: 'retry' as const,

0 commit comments

Comments
 (0)