Skip to content

Commit dd06012

Browse files
committed
feat: support Maestro setPermissions
1 parent caa3dc2 commit dd06012

20 files changed

Lines changed: 646 additions & 35 deletions

packages/maestro/src/internal/__tests__/program-ir-parser.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,63 @@ describe('parseMaestroProgram', () => {
409409
});
410410
});
411411

412+
test('parses setPermissions maps, variables, and optional/label', () => {
413+
const program = parseMaestroProgram(`appId: example.app
414+
---
415+
- setPermissions:
416+
permissions:
417+
all: deny
418+
notifications: unset
419+
- setPermissions:
420+
appId: child.app
421+
permissions:
422+
camera: \${CAMERA_STATE}
423+
location: always
424+
optional: true
425+
label: Prepare scan
426+
`);
427+
428+
assert.deepEqual(program.commands[0], {
429+
kind: 'setPermissions',
430+
source: { line: 3 },
431+
permissions: { all: 'deny', notifications: 'unset' },
432+
});
433+
assert.deepEqual(program.commands[1], {
434+
kind: 'setPermissions',
435+
source: { line: 7 },
436+
appId: 'child.app',
437+
permissions: { camera: '${CAMERA_STATE}', location: 'always' },
438+
optional: true,
439+
label: 'Prepare scan',
440+
});
441+
assert.throws(
442+
() =>
443+
parseMaestroProgram(`---
444+
- setPermissions:
445+
appId: example.app
446+
`),
447+
/requires permissions.*line 2/i,
448+
);
449+
assert.throws(
450+
() =>
451+
parseMaestroProgram(`---
452+
- setPermissions:
453+
permissions:
454+
camera: sometimes
455+
`),
456+
/allow\|deny\|unset.*line 4/i,
457+
);
458+
assert.throws(
459+
() =>
460+
parseMaestroProgram(`---
461+
- setPermissions:
462+
permissions:
463+
camera: \${ALLOW + 1}
464+
`),
465+
/not supported.*line 4/i,
466+
);
467+
});
468+
412469
test('reports source lines for unsupported and invalid command shapes', () => {
413470
assert.throws(
414471
() =>

packages/maestro/src/internal/__tests__/runtime-port-fixtures.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export function makeOperations(
5959
resolveGestureViewport: async () => ({ x: 0, y: 0, width: 402, height: 874 }),
6060
launchApp: noOp,
6161
stopApp: noOp,
62+
setPermissions: noOp,
6263
openLink: noOp,
6364
tapOn: noOp,
6465
doubleTapOn: noOp,

packages/maestro/src/internal/__tests__/runtime-port.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,40 @@ import {
1010
} from './runtime-port-fixtures.ts';
1111

1212
describe('MaestroRuntimePort', () => {
13+
test('dispatches setPermissions with the flow appId and resolved values', async () => {
14+
const calls: RecordedCall[] = [];
15+
const operations = makeOperations({
16+
setPermissions: vi.fn(async (input, context) =>
17+
record(calls, 'setPermissions', input, context),
18+
),
19+
});
20+
const program = parseMaestroProgram(
21+
[
22+
'appId: com.example.checkout',
23+
'env:',
24+
' CAMERA_STATE: allow',
25+
'---',
26+
'- setPermissions:',
27+
' permissions:',
28+
' all: deny',
29+
' camera: ${CAMERA_STATE}',
30+
].join('\n'),
31+
);
32+
33+
const result = await executeMaestroProgram(program, createMaestroRuntimePort(operations));
34+
35+
expect(result).toMatchObject({ executed: 1, skipped: 0 });
36+
expect(calls).toHaveLength(1);
37+
expect(calls[0]).toMatchObject({
38+
kind: 'setPermissions',
39+
input: {
40+
appId: 'com.example.checkout',
41+
permissions: { all: 'deny', camera: 'allow' },
42+
},
43+
appId: 'com.example.checkout',
44+
});
45+
});
46+
1347
test('delegates typed lifecycle, input, keyboard, screenshot, and script operations', async () => {
1448
const calls: RecordedCall[] = [];
1549
const operations = makeOperations({

packages/maestro/src/internal/conformance-normalize.ts

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export type CanonicalCommand =
7777
| { kind: 'takeScreenshot' }
7878
| { kind: 'waitForAnimationToEnd'; timeout?: number | string }
7979
| { kind: 'stopApp' }
80+
| { kind: 'setPermissions'; appId?: string; permissions?: Record<string, string> }
8081
| { kind: 'repeat'; times: string | number }
8182
| { kind: 'retry'; maxRetries?: string | number }
8283
| { kind: 'runFlow'; label?: string; source: 'file' | 'commands' }
@@ -98,7 +99,19 @@ export function canonicalizeUpstreamFlow(commands: UpstreamCommand[]): Canonical
9899
.map(canonicalizeUpstreamCommand);
99100
}
100101

102+
/** Upstream commands that canonicalize to a bare kind with no fields. */
103+
const BARE_UPSTREAM_CANONICAL: Record<string, CanonicalCommand> = {
104+
ScrollCommand: { kind: 'scroll' },
105+
BackPressCommand: { kind: 'back' },
106+
HideKeyboardCommand: { kind: 'hideKeyboard' },
107+
TakeScreenshotCommand: { kind: 'takeScreenshot' },
108+
StopAppCommand: { kind: 'stopApp' },
109+
RunScriptCommand: { kind: 'runScript' },
110+
};
111+
101112
function canonicalizeUpstreamCommand(command: UpstreamCommand): CanonicalCommand {
113+
const bare = BARE_UPSTREAM_CANONICAL[command.type];
114+
if (bare) return bare;
102115
const f = command.fields;
103116
switch (command.type) {
104117
case 'LaunchAppCommand':
@@ -164,8 +177,6 @@ function canonicalizeUpstreamCommand(command: UpstreamCommand): CanonicalCommand
164177
}
165178
case 'SwipeCommand':
166179
return dropUndefined({ kind: 'swipe', label: str(f.label), gesture: upstreamGesture(f) });
167-
case 'ScrollCommand':
168-
return { kind: 'scroll' };
169180
case 'ScrollUntilVisibleCommand':
170181
return dropUndefined({
171182
kind: 'scrollUntilVisible',
@@ -185,19 +196,17 @@ function canonicalizeUpstreamCommand(command: UpstreamCommand): CanonicalCommand
185196
return dropUndefined({ kind: 'openLink', link: str(f.link) });
186197
case 'PressKeyCommand':
187198
return { kind: 'pressKey', key: lower(str(f.code)) ?? '' };
188-
case 'BackPressCommand':
189-
return { kind: 'back' };
190-
case 'HideKeyboardCommand':
191-
return { kind: 'hideKeyboard' };
192-
case 'TakeScreenshotCommand':
193-
return { kind: 'takeScreenshot' };
194199
case 'WaitForAnimationToEndCommand':
195200
return dropUndefined({
196201
kind: 'waitForAnimationToEnd',
197202
timeout: numLike(f.timeout) ?? str(f.timeout),
198203
});
199-
case 'StopAppCommand':
200-
return { kind: 'stopApp' };
204+
case 'SetPermissionsCommand':
205+
return dropUndefined({
206+
kind: 'setPermissions',
207+
appId: str(f.appId),
208+
permissions: permissionsRecord(f.permissions),
209+
});
201210
case 'RepeatCommand':
202211
return { kind: 'repeat', times: numLike(f.times) ?? str(f.times) ?? '' };
203212
case 'RetryCommand':
@@ -211,8 +220,6 @@ function canonicalizeUpstreamCommand(command: UpstreamCommand): CanonicalCommand
211220
label: str(f.label),
212221
source: f.sourceDescription != null ? 'file' : 'commands',
213222
});
214-
case 'RunScriptCommand':
215-
return { kind: 'runScript' };
216223
default:
217224
return { kind: 'unsupported', command: unsupportedName(command.type) };
218225
}
@@ -286,6 +293,18 @@ function lower(value: string | undefined): string | undefined {
286293
return value?.toLowerCase();
287294
}
288295

296+
function permissionsRecord(value: unknown): Record<string, string> | undefined {
297+
const record = asRecord(value);
298+
if (!record) return undefined;
299+
const permissions: Record<string, string> = {};
300+
for (const [key, entry] of Object.entries(record)) {
301+
const coerced = str(entry)?.toLowerCase();
302+
if (coerced === undefined) return undefined;
303+
permissions[key] = coerced;
304+
}
305+
return permissions;
306+
}
307+
289308
// ---------------------------------------------------------------------------
290309
// agent-device engine IR → canonical
291310
// ---------------------------------------------------------------------------
@@ -302,10 +321,27 @@ export function canonicalizeAgentCommands(
302321
return program.commands.map((command) => canonicalizeAgentCommand(command, program.config));
303322
}
304323

324+
/** Agent commands that canonicalize to a bare kind with no fields. */
325+
const BARE_AGENT_CANONICAL = {
326+
scroll: { kind: 'scroll' },
327+
back: { kind: 'back' },
328+
hideKeyboard: { kind: 'hideKeyboard' },
329+
takeScreenshot: { kind: 'takeScreenshot' },
330+
stopApp: { kind: 'stopApp' },
331+
runScript: { kind: 'runScript' },
332+
} satisfies Record<string, CanonicalCommand>;
333+
334+
type BareAgentCommand = Extract<MaestroCommand, { kind: keyof typeof BARE_AGENT_CANONICAL }>;
335+
336+
function isBareAgentCommand(command: MaestroCommand): command is BareAgentCommand {
337+
return command.kind in BARE_AGENT_CANONICAL;
338+
}
339+
305340
function canonicalizeAgentCommand(
306341
command: MaestroCommand,
307342
config: MaestroProgram['config'],
308343
): CanonicalCommand {
344+
if (isBareAgentCommand(command)) return BARE_AGENT_CANONICAL[command.kind];
309345
switch (command.kind) {
310346
case 'launchApp':
311347
return dropUndefined({
@@ -384,8 +420,6 @@ function canonicalizeAgentCommand(
384420
return dropUndefined({ kind: 'eraseText', count: numLike(command.charactersToErase) });
385421
case 'openLink':
386422
return dropUndefined({ kind: 'openLink', link: command.link });
387-
case 'scroll':
388-
return { kind: 'scroll' };
389423
case 'scrollUntilVisible':
390424
// Upstream materializes the DOWN default onto the command at parse time;
391425
// our engine defers it to execution (runtime-port-commands.ts). Materialize
@@ -400,16 +434,14 @@ function canonicalizeAgentCommand(
400434
});
401435
case 'pressKey':
402436
return { kind: 'pressKey', key: command.key.toLowerCase() };
403-
case 'back':
404-
return { kind: 'back' };
405-
case 'hideKeyboard':
406-
return { kind: 'hideKeyboard' };
407-
case 'takeScreenshot':
408-
return { kind: 'takeScreenshot' };
409437
case 'waitForAnimationToEnd':
410438
return dropUndefined({ kind: 'waitForAnimationToEnd', timeout: numLike(command.timeout) });
411-
case 'stopApp':
412-
return { kind: 'stopApp' };
439+
case 'setPermissions':
440+
return dropUndefined({
441+
kind: 'setPermissions',
442+
appId: command.appId ?? config.appId,
443+
permissions: command.permissions,
444+
});
413445
case 'repeat':
414446
return { kind: 'repeat', times: numLike(command.times) ?? str(command.times) ?? '' };
415447
case 'retry':
@@ -423,8 +455,6 @@ function canonicalizeAgentCommand(
423455
label: command.label,
424456
source: command.include.kind === 'file' ? 'file' : 'commands',
425457
});
426-
case 'runScript':
427-
return { kind: 'runScript' };
428458
default: {
429459
const exhaustive: never = command;
430460
throw new Error(`Unhandled agent command: ${JSON.stringify(exhaustive)}`);

packages/maestro/src/internal/program-ir-command-parser.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
MaestroPressKeyCommand,
1515
MaestroScrollCommand,
1616
MaestroScrollUntilVisibleCommand,
17+
MaestroSetPermissionsCommand,
1718
MaestroStopAppCommand,
1819
MaestroTakeScreenshotCommand,
1920
MaestroWaitForAnimationToEndCommand,
@@ -58,6 +59,7 @@ import {
5859
readSequenceItems,
5960
sourceAt,
6061
type MaestroProgramParseContext,
62+
VARIABLE_PATTERN,
6163
} from './program-ir-values.ts';
6264

6365
export function parseMaestroCommandList(
@@ -122,6 +124,7 @@ const COMMAND_VALUE_PARSERS: Readonly<Record<string, CommandValueParser>> = {
122124
back: parseBack,
123125
waitForAnimationToEnd: parseWaitForAnimationToEnd,
124126
stopApp: parseStopApp,
127+
setPermissions: parseSetPermissions,
125128
runScript: parseMaestroRunScriptCommand,
126129
runFlow: (value, node, context) =>
127130
parseMaestroRunFlowCommand(value, node, context, parseMaestroCommandList),
@@ -447,6 +450,86 @@ function parseStopApp(
447450
return { kind: 'stopApp', source, appId: readRequiredString(value, 'stopApp', context) };
448451
}
449452

453+
const MAESTRO_PERMISSION_VALUES = new Set([
454+
'allow',
455+
'deny',
456+
'unset',
457+
'always',
458+
'inuse',
459+
'never',
460+
'limited',
461+
]);
462+
463+
function parseSetPermissions(
464+
value: Node | null,
465+
commandNode: Node,
466+
context: MaestroProgramParseContext,
467+
): MaestroSetPermissionsCommand {
468+
const source = sourceAt(commandNode, context);
469+
const entries = readMapEntries(value, 'setPermissions', context);
470+
assertOnlyKeys(entries, 'setPermissions', ['appId', 'permissions', 'optional', 'label'], context);
471+
if (!hasEntry(entries, 'permissions'))
472+
invalidAt('Maestro setPermissions requires permissions.', commandNode, context);
473+
const appId = readOptionalEntry(entries, 'appId', (entry) =>
474+
readOptionalString(entry, 'setPermissions.appId', context),
475+
);
476+
const permissions = readSetPermissionsMap(entryValue(entries, 'permissions'), context);
477+
if (Object.keys(permissions).length === 0)
478+
invalidAt('Maestro setPermissions requires at least one permission.', commandNode, context);
479+
const options = readOptionalCommandOption(entries, 'setPermissions', context);
480+
const label = readMaestroCommandLabel(entries, 'setPermissions', context);
481+
return stripUndefined({
482+
kind: 'setPermissions' as const,
483+
source,
484+
appId,
485+
permissions,
486+
...options,
487+
label,
488+
});
489+
}
490+
491+
function readSetPermissionsMap(
492+
node: Node | null | undefined,
493+
context: MaestroProgramParseContext,
494+
): Record<string, string> {
495+
const entries = readMapEntries(node, 'setPermissions.permissions', context);
496+
const permissions: Record<string, string> = {};
497+
for (const entry of entries) {
498+
if (entry.key in permissions)
499+
invalidAt(
500+
`Maestro setPermissions.permissions contains duplicate permission "${entry.key}".`,
501+
entry.keyNode,
502+
context,
503+
);
504+
permissions[entry.key] = readPermissionValue(entry, context);
505+
}
506+
return permissions;
507+
}
508+
509+
function readPermissionValue(
510+
entry: { key: string; value: Node | null },
511+
context: MaestroProgramParseContext,
512+
): string {
513+
const name = `setPermissions.permissions.${entry.key}`;
514+
const value = readScalarValue(entry.value, name, context);
515+
if (typeof value !== 'string')
516+
invalidAt(`Maestro ${name} expects a string.`, entry.value, context);
517+
const normalized = value.toLowerCase();
518+
if (MAESTRO_PERMISSION_VALUES.has(normalized)) return normalized;
519+
if (VARIABLE_PATTERN.test(value)) return value;
520+
if (value.includes('${'))
521+
invalidAt(
522+
`Maestro ${name} only supports allow|deny|unset (plus always|inuse|never|limited for location/photos) or a bare \${VAR} lookup; JavaScript expressions are not supported.`,
523+
entry.value,
524+
context,
525+
);
526+
invalidAt(
527+
`Maestro ${name} expects allow|deny|unset (plus always|inuse|never|limited for location/photos) or a bare \${VAR} lookup.`,
528+
entry.value,
529+
context,
530+
);
531+
}
532+
450533
function parseLaunchArguments(
451534
node: Node | null | undefined,
452535
name: string,

packages/maestro/src/internal/program-ir-values.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ export function readOptionalBoolean(
199199
return value;
200200
}
201201

202-
const VARIABLE_PATTERN = /^\$\{[A-Za-z_][A-Za-z0-9_.]*\}$/;
202+
export const VARIABLE_PATTERN = /^\$\{[A-Za-z_][A-Za-z0-9_.]*\}$/;
203203
const NUMERIC_STRING_PATTERN = /^-?\d+(\.\d+)?$/;
204204
const INTEGER_STRING_PATTERN = /^-?\d+$/;
205205

0 commit comments

Comments
 (0)