Skip to content
Open
4 changes: 3 additions & 1 deletion packages/contracts/src/client-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { SettingsUpdateOptions } from './client-settings.ts';
type Permission = Extract<SettingsUpdateOptions, { setting: 'permission' }>;

const MOBILE_TARGETS = [
'all',
'camera',
'microphone',
'photos',
Expand All @@ -23,6 +24,8 @@ const MACOS_ONLY_TARGETS = ['accessibility', 'screen-recording', 'input-monitori

// Fixed expected data (#2614): the public client vocabulary is written out here so a shared
// declaration can neither widen the accepted permission names nor drop the macOS-only ones.
// The one deliberate widening is `all`: the Maestro setPermissions merge needs it to travel
// as one `settings permission` call while each backend resolves it.
describe('public client permission vocabulary', () => {
test('names exactly the app-scoped targets plus the macOS-only ones', () => {
expectTypeOf<Permission['permission']>().toEqualTypeOf<
Expand All @@ -31,7 +34,6 @@ describe('public client permission vocabulary', () => {
});

test('does not name a permission the vocabulary does not declare', () => {
expectTypeOf<'all'>().not.toMatchTypeOf<Permission['permission']>();
expectTypeOf<'bluetooth'>().not.toMatchTypeOf<Permission['permission']>();
});

Expand Down
5 changes: 3 additions & 2 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import {
// Fixed expected data on purpose (#2614): this file is the witness that a shared permission
// declaration neither widened nor narrowed what any settings surface already accepted, and that it
// kept the accepted names in the order `settings` help has always listed them.
// The one deliberate widening is `all`, first in the list: the Maestro setPermissions merge
// needs it to travel as one `settings permission` call while each backend resolves it.
const MOBILE_TARGETS = [
'all',
'camera',
'microphone',
'photos',
Expand Down Expand Up @@ -61,7 +64,6 @@ const NORMALIZATIONS = [

const REJECTED_TARGETS = [
...MACOS_ONLY_TARGETS,
'all',
'bluetooth',
'camera-x',
'camera limited',
Expand Down Expand Up @@ -172,6 +174,5 @@ describe('permission vocabulary types', () => {
expectTypeOf<'accessibility'>().not.toMatchTypeOf<PermissionTarget>();
expectTypeOf<'screen-recording'>().not.toMatchTypeOf<PermissionTarget>();
expectTypeOf<'input-monitoring'>().not.toMatchTypeOf<PermissionTarget>();
expectTypeOf<'all'>().not.toMatchTypeOf<PermissionTarget>();
});
});
1 change: 1 addition & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const PERMISSION_MODES = ['full', 'limited'] as const;

/** The app-scoped targets, the only ones `parsePermissionTarget` accepts. */
export const MOBILE_PERMISSION_TARGETS = [
'all',
'camera',
'microphone',
'photos',
Expand Down
2 changes: 2 additions & 0 deletions packages/maestro/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export {
MAESTRO_COMPAT_SUPPORTED_CAPABILITIES,
} from './internal/facade-support.ts';

export { MAESTRO_PERMISSION_VALUES } from './internal/program-ir-values.ts';

export {
createMaestroRuntimePort,
literalFromMaestroRegex,
Expand Down
104 changes: 104 additions & 0 deletions packages/maestro/src/internal/__tests__/program-ir-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,110 @@ describe('parseMaestroProgram', () => {
});
});

test('parses setPermissions maps, variables, and optional/label', () => {
const program = parseMaestroProgram(`appId: example.app
---
- setPermissions:
permissions:
all: deny
notifications: unset
- setPermissions:
appId: child.app
permissions:
camera: \${CAMERA_STATE}
location: always
optional: true
label: Prepare scan
`);

assert.deepEqual(program.commands[0], {
kind: 'setPermissions',
source: { line: 3 },
permissions: { all: 'deny', notifications: 'unset' },
});
assert.deepEqual(program.commands[1], {
kind: 'setPermissions',
source: { line: 7 },
appId: 'child.app',
permissions: { camera: '${CAMERA_STATE}', location: 'always' },
optional: true,
label: 'Prepare scan',
});
// Prototype names are not duplicates: the YAML layer already rejects real
// duplicate keys, so parsing accepts them and the backend verdict applies.
const prototype = parseMaestroProgram(`---
- setPermissions:
permissions:
constructor: allow
`);
assert.deepEqual(prototype.commands[0], {
kind: 'setPermissions',
source: { line: 2 },
permissions: { constructor: 'allow' },
});
assert.throws(
() =>
parseMaestroProgram(`---
- setPermissions:
appId: example.app
`),
/requires permissions.*line 2/i,
);
assert.throws(
() =>
parseMaestroProgram(`---
- setPermissions:
permissions:
camera: sometimes
`),
/allow\|deny\|unset.*line 4/i,
);
assert.throws(
() =>
parseMaestroProgram(`---
- setPermissions:
permissions:
camera: \${ALLOW + 1}
`),
/not supported.*line 4/i,
);
});

test('parses launchApp permissions maps', () => {
const program = parseMaestroProgram(`appId: example.app
---
- launchApp:
clearState: true
permissions:
all: deny
camera: \${CAMERA_STATE}
`);

assert.deepEqual(program.commands[0], {
kind: 'launchApp',
source: { line: 3 },
clearState: true,
permissions: { all: 'deny', camera: '${CAMERA_STATE}' },
});
assert.throws(
() =>
parseMaestroProgram(`---
- launchApp:
permissions: {}
`),
/launchApp\.permissions requires at least one permission.*line 3/i,
);
assert.throws(
() =>
parseMaestroProgram(`---
- launchApp:
permissions:
camera: sometimes
`),
/allow\|deny\|unset.*line 4/i,
);
});

test('parses evalScript as a scalar script string', () => {
const program = parseMaestroProgram(['---', '- evalScript: ${output.sum = 1 + 2}'].join('\n'));
assert.deepEqual(program.commands[0], {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function makeOperations(
resolveGestureViewport: async () => ({ x: 0, y: 0, width: 402, height: 874 }),
launchApp: noOp,
stopApp: noOp,
setPermissions: noOp,
clearState: noOp,
openLink: noOp,
tapOn: noOp,
Expand Down
34 changes: 34 additions & 0 deletions packages/maestro/src/internal/__tests__/runtime-port.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,40 @@ import {
} from './runtime-port-fixtures.ts';

describe('MaestroRuntimePort', () => {
test('dispatches setPermissions with the flow appId and resolved values', async () => {
const calls: RecordedCall[] = [];
const operations = makeOperations({
setPermissions: vi.fn(async (input, context) =>
record(calls, 'setPermissions', input, context),
),
});
const program = parseMaestroProgram(
[
'appId: com.example.checkout',
'env:',
' CAMERA_STATE: allow',
'---',
'- setPermissions:',
' permissions:',
' all: deny',
' camera: ${CAMERA_STATE}',
].join('\n'),
);

const result = await executeMaestroProgram(program, createMaestroRuntimePort(operations));

expect(result).toMatchObject({ executed: 1, skipped: 0 });
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({
kind: 'setPermissions',
input: {
appId: 'com.example.checkout',
permissions: { all: 'deny', camera: 'allow' },
},
appId: 'com.example.checkout',
});
});

test('delegates typed lifecycle, input, keyboard, screenshot, and script operations', async () => {
const calls: RecordedCall[] = [];
const operations = makeOperations({
Expand Down
Loading
Loading