Skip to content

Commit 8d08026

Browse files
committed
feat: support Maestro setPermissions
1 parent bd08e6e commit 8d08026

20 files changed

Lines changed: 783 additions & 48 deletions

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,98 @@ 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+
469+
test('parses launchApp permissions maps', () => {
470+
const program = parseMaestroProgram(`appId: example.app
471+
---
472+
- launchApp:
473+
clearState: true
474+
permissions:
475+
all: deny
476+
camera: \${CAMERA_STATE}
477+
`);
478+
479+
assert.deepEqual(program.commands[0], {
480+
kind: 'launchApp',
481+
source: { line: 3 },
482+
clearState: true,
483+
permissions: { all: 'deny', camera: '${CAMERA_STATE}' },
484+
});
485+
assert.throws(
486+
() =>
487+
parseMaestroProgram(`---
488+
- launchApp:
489+
permissions: {}
490+
`),
491+
/launchApp\.permissions requires at least one permission.*line 2/i,
492+
);
493+
assert.throws(
494+
() =>
495+
parseMaestroProgram(`---
496+
- launchApp:
497+
permissions:
498+
camera: sometimes
499+
`),
500+
/allow\|deny\|unset.*line 4/i,
501+
);
502+
});
503+
412504
test('reports source lines for unsupported and invalid command shapes', () => {
413505
assert.throws(
414506
() =>

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: 108 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,13 @@ export type CanonicalGesture =
3737
| { mode: 'element'; from: CanonicalSelector; direction?: string; duration?: number | string };
3838

3939
export type CanonicalCommand =
40-
| { kind: 'launchApp'; appId?: string; clearState?: boolean; stopApp?: boolean }
40+
| {
41+
kind: 'launchApp';
42+
appId?: string;
43+
clearState?: boolean;
44+
stopApp?: boolean;
45+
permissions?: Record<string, string>;
46+
}
4147
// Upstream models `doubleTapOn` as a tap with repeat.repeat == 2, so the repeat
4248
// COUNT is the canonical field on both sides rather than a `double` variant on
4349
// one — that keeps our distinct tapOn/doubleTapOn kinds comparable to upstream
@@ -77,6 +83,7 @@ export type CanonicalCommand =
7783
| { kind: 'takeScreenshot' }
7884
| { kind: 'waitForAnimationToEnd'; timeout?: number | string }
7985
| { kind: 'stopApp' }
86+
| { kind: 'setPermissions'; appId?: string; permissions?: Record<string, string> }
8087
| { kind: 'repeat'; times: string | number }
8188
| { kind: 'retry'; maxRetries?: string | number }
8289
| { kind: 'runFlow'; label?: string; source: 'file' | 'commands' }
@@ -98,7 +105,19 @@ export function canonicalizeUpstreamFlow(commands: UpstreamCommand[]): Canonical
98105
.map(canonicalizeUpstreamCommand);
99106
}
100107

108+
/** Upstream commands that canonicalize to a bare kind with no fields. */
109+
const BARE_UPSTREAM_CANONICAL: Record<string, CanonicalCommand> = {
110+
ScrollCommand: { kind: 'scroll' },
111+
BackPressCommand: { kind: 'back' },
112+
HideKeyboardCommand: { kind: 'hideKeyboard' },
113+
TakeScreenshotCommand: { kind: 'takeScreenshot' },
114+
StopAppCommand: { kind: 'stopApp' },
115+
RunScriptCommand: { kind: 'runScript' },
116+
};
117+
101118
function canonicalizeUpstreamCommand(command: UpstreamCommand): CanonicalCommand {
119+
const bare = BARE_UPSTREAM_CANONICAL[command.type];
120+
if (bare) return bare;
102121
const f = command.fields;
103122
switch (command.type) {
104123
case 'LaunchAppCommand':
@@ -107,6 +126,7 @@ function canonicalizeUpstreamCommand(command: UpstreamCommand): CanonicalCommand
107126
appId: str(f.appId),
108127
clearState: bool(f.clearState),
109128
stopApp: bool(f.stopApp),
129+
permissions: permissionsRecord(f.permissions),
110130
});
111131
case 'TapOnElementCommand': {
112132
const repeat = asRecord(f.repeat);
@@ -164,8 +184,6 @@ function canonicalizeUpstreamCommand(command: UpstreamCommand): CanonicalCommand
164184
}
165185
case 'SwipeCommand':
166186
return dropUndefined({ kind: 'swipe', label: str(f.label), gesture: upstreamGesture(f) });
167-
case 'ScrollCommand':
168-
return { kind: 'scroll' };
169187
case 'ScrollUntilVisibleCommand':
170188
return dropUndefined({
171189
kind: 'scrollUntilVisible',
@@ -185,19 +203,17 @@ function canonicalizeUpstreamCommand(command: UpstreamCommand): CanonicalCommand
185203
return dropUndefined({ kind: 'openLink', link: str(f.link) });
186204
case 'PressKeyCommand':
187205
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' };
194206
case 'WaitForAnimationToEndCommand':
195207
return dropUndefined({
196208
kind: 'waitForAnimationToEnd',
197209
timeout: numLike(f.timeout) ?? str(f.timeout),
198210
});
199-
case 'StopAppCommand':
200-
return { kind: 'stopApp' };
211+
case 'SetPermissionsCommand':
212+
return dropUndefined({
213+
kind: 'setPermissions',
214+
appId: str(f.appId),
215+
permissions: permissionsRecord(f.permissions),
216+
});
201217
case 'RepeatCommand':
202218
return { kind: 'repeat', times: numLike(f.times) ?? str(f.times) ?? '' };
203219
case 'RetryCommand':
@@ -211,8 +227,6 @@ function canonicalizeUpstreamCommand(command: UpstreamCommand): CanonicalCommand
211227
label: str(f.label),
212228
source: f.sourceDescription != null ? 'file' : 'commands',
213229
});
214-
case 'RunScriptCommand':
215-
return { kind: 'runScript' };
216230
default:
217231
return { kind: 'unsupported', command: unsupportedName(command.type) };
218232
}
@@ -286,6 +300,18 @@ function lower(value: string | undefined): string | undefined {
286300
return value?.toLowerCase();
287301
}
288302

303+
function permissionsRecord(value: unknown): Record<string, string> | undefined {
304+
const record = asRecord(value);
305+
if (!record) return undefined;
306+
const permissions: Record<string, string> = {};
307+
for (const [key, entry] of Object.entries(record)) {
308+
const coerced = str(entry)?.toLowerCase();
309+
if (coerced === undefined) return undefined;
310+
permissions[key] = coerced;
311+
}
312+
return permissions;
313+
}
314+
289315
// ---------------------------------------------------------------------------
290316
// agent-device engine IR → canonical
291317
// ---------------------------------------------------------------------------
@@ -302,18 +328,32 @@ export function canonicalizeAgentCommands(
302328
return program.commands.map((command) => canonicalizeAgentCommand(command, program.config));
303329
}
304330

305-
function canonicalizeAgentCommand(
306-
command: MaestroCommand,
307-
config: MaestroProgram['config'],
308-
): CanonicalCommand {
331+
/** Agent commands that canonicalize to a bare kind with no fields. */
332+
const BARE_AGENT_CANONICAL = {
333+
scroll: { kind: 'scroll' },
334+
back: { kind: 'back' },
335+
hideKeyboard: { kind: 'hideKeyboard' },
336+
takeScreenshot: { kind: 'takeScreenshot' },
337+
stopApp: { kind: 'stopApp' },
338+
runScript: { kind: 'runScript' },
339+
} satisfies Record<string, CanonicalCommand>;
340+
341+
type BareAgentCommand = Extract<MaestroCommand, { kind: keyof typeof BARE_AGENT_CANONICAL }>;
342+
343+
function isBareAgentCommand(command: MaestroCommand): command is BareAgentCommand {
344+
return command.kind in BARE_AGENT_CANONICAL;
345+
}
346+
347+
type AgentTapCommand = Extract<MaestroCommand, { kind: 'tapOn' | 'doubleTapOn' | 'longPressOn' }>;
348+
349+
function isAgentTapCommand(command: MaestroCommand): command is AgentTapCommand {
350+
return (
351+
command.kind === 'tapOn' || command.kind === 'doubleTapOn' || command.kind === 'longPressOn'
352+
);
353+
}
354+
355+
function canonicalizeAgentTapCommand(command: AgentTapCommand): CanonicalCommand {
309356
switch (command.kind) {
310-
case 'launchApp':
311-
return dropUndefined({
312-
kind: 'launchApp',
313-
appId: command.appId ?? config.appId,
314-
clearState: command.clearState,
315-
stopApp: command.stopApp,
316-
});
317357
case 'tapOn': {
318358
const repeat = numLike(command.repeat) ?? 1;
319359
const repeatIsNumber = typeof repeat === 'number';
@@ -343,6 +383,25 @@ function canonicalizeAgentCommand(
343383
label: command.label,
344384
target: canonicalizeAgentTarget(command.target),
345385
});
386+
}
387+
}
388+
389+
type AgentAssertCommand = Extract<
390+
MaestroCommand,
391+
{ kind: 'assertVisible' | 'assertNotVisible' | 'assertTrue' | 'extendedWaitUntil' }
392+
>;
393+
394+
function isAgentAssertCommand(command: MaestroCommand): command is AgentAssertCommand {
395+
return (
396+
command.kind === 'assertVisible' ||
397+
command.kind === 'assertNotVisible' ||
398+
command.kind === 'assertTrue' ||
399+
command.kind === 'extendedWaitUntil'
400+
);
401+
}
402+
403+
function canonicalizeAgentAssertCommand(command: AgentAssertCommand): CanonicalCommand {
404+
switch (command.kind) {
346405
case 'assertVisible':
347406
return dropUndefined({
348407
kind: 'assert',
@@ -376,6 +435,25 @@ function canonicalizeAgentCommand(
376435
label: command.label,
377436
selector: canonicalizeAgentSelector(command.notVisible ?? command.visible),
378437
});
438+
}
439+
}
440+
441+
function canonicalizeAgentCommand(
442+
command: MaestroCommand,
443+
config: MaestroProgram['config'],
444+
): CanonicalCommand {
445+
if (isBareAgentCommand(command)) return BARE_AGENT_CANONICAL[command.kind];
446+
if (isAgentTapCommand(command)) return canonicalizeAgentTapCommand(command);
447+
if (isAgentAssertCommand(command)) return canonicalizeAgentAssertCommand(command);
448+
switch (command.kind) {
449+
case 'launchApp':
450+
return dropUndefined({
451+
kind: 'launchApp',
452+
appId: command.appId ?? config.appId,
453+
clearState: command.clearState,
454+
stopApp: command.stopApp,
455+
permissions: command.permissions,
456+
});
379457
case 'swipe':
380458
return { kind: 'swipe', label: command.label, gesture: agentGesture(command.gesture) };
381459
case 'inputText':
@@ -384,8 +462,6 @@ function canonicalizeAgentCommand(
384462
return dropUndefined({ kind: 'eraseText', count: numLike(command.charactersToErase) });
385463
case 'openLink':
386464
return dropUndefined({ kind: 'openLink', link: command.link });
387-
case 'scroll':
388-
return { kind: 'scroll' };
389465
case 'scrollUntilVisible':
390466
// Upstream materializes the DOWN default onto the command at parse time;
391467
// our engine defers it to execution (runtime-port-commands.ts). Materialize
@@ -400,16 +476,14 @@ function canonicalizeAgentCommand(
400476
});
401477
case 'pressKey':
402478
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' };
409479
case 'waitForAnimationToEnd':
410480
return dropUndefined({ kind: 'waitForAnimationToEnd', timeout: numLike(command.timeout) });
411-
case 'stopApp':
412-
return { kind: 'stopApp' };
481+
case 'setPermissions':
482+
return dropUndefined({
483+
kind: 'setPermissions',
484+
appId: command.appId ?? config.appId,
485+
permissions: command.permissions,
486+
});
413487
case 'repeat':
414488
return { kind: 'repeat', times: numLike(command.times) ?? str(command.times) ?? '' };
415489
case 'retry':
@@ -423,8 +497,6 @@ function canonicalizeAgentCommand(
423497
label: command.label,
424498
source: command.include.kind === 'file' ? 'file' : 'commands',
425499
});
426-
case 'runScript':
427-
return { kind: 'runScript' };
428500
default: {
429501
const exhaustive: never = command;
430502
throw new Error(`Unhandled agent command: ${JSON.stringify(exhaustive)}`);

0 commit comments

Comments
 (0)