Skip to content

Commit 06b6999

Browse files
committed
refactor: centralize permission target parsing and options
1 parent d56979b commit 06b6999

6 files changed

Lines changed: 129 additions & 179 deletions

File tree

src/core/dispatch.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,13 @@ export async function dispatchCommand(
413413
}
414414
case 'settings': {
415415
const [setting, state, target, mode, appBundleId] = positionals;
416+
const permissionOptions =
417+
setting === 'permission'
418+
? {
419+
permissionTarget: target,
420+
permissionMode: mode,
421+
}
422+
: undefined;
416423
emitDiagnostic({
417424
level: 'debug',
418425
phase: 'settings_apply',
@@ -425,16 +432,10 @@ export async function dispatchCommand(
425432
},
426433
});
427434
if (device.platform === 'ios') {
428-
await setIosSetting(device, setting, state, appBundleId ?? context?.appBundleId, {
429-
permissionTarget: target,
430-
permissionMode: mode,
431-
});
435+
await setIosSetting(device, setting, state, appBundleId ?? context?.appBundleId, permissionOptions);
432436
return { setting, state };
433437
}
434-
await setAndroidSetting(device, setting, state, appBundleId ?? context?.appBundleId, {
435-
permissionTarget: target,
436-
permissionMode: mode,
437-
});
438+
await setAndroidSetting(device, setting, state, appBundleId ?? context?.appBundleId, permissionOptions);
438439
return { setting, state };
439440
}
440441
case 'snapshot': {

src/daemon/handlers/snapshot.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,7 @@ export async function handleSnapshotCommands(params: {
411411
}
412412
return await withSessionlessRunnerCleanup(session, device, async () => {
413413
const appBundleId = session?.appBundleId;
414+
// Settings positional layout for dispatch: setting, state, [target, mode], appBundleId.
414415
const positionals =
415416
setting === 'permission'
416417
? [setting, state, permissionTarget, req.positionals?.[3] ?? '', appBundleId ?? '']

src/platforms/android/__tests__/index.test.ts

Lines changed: 75 additions & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,43 @@ import type { DeviceInfo } from '../../../utils/device.ts';
1515
import { AppError } from '../../../utils/errors.ts';
1616
import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';
1717

18+
async function withMockedAdb(
19+
tempPrefix: string,
20+
script: string,
21+
run: (ctx: { argsLogPath: string; device: DeviceInfo }) => Promise<void>,
22+
): Promise<void> {
23+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tempPrefix));
24+
const adbPath = path.join(tmpDir, 'adb');
25+
const argsLogPath = path.join(tmpDir, 'args.log');
26+
await fs.writeFile(adbPath, script, 'utf8');
27+
await fs.chmod(adbPath, 0o755);
28+
29+
const previousPath = process.env.PATH;
30+
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
31+
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
32+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
33+
34+
const device: DeviceInfo = {
35+
platform: 'android',
36+
id: 'emulator-5554',
37+
name: 'Pixel',
38+
kind: 'emulator',
39+
booted: true,
40+
};
41+
42+
try {
43+
await run({ argsLogPath, device });
44+
} finally {
45+
process.env.PATH = previousPath;
46+
if (previousArgsFile === undefined) {
47+
delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
48+
} else {
49+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
50+
}
51+
await fs.rm(tmpDir, { recursive: true, force: true });
52+
}
53+
}
54+
1855
test('parseUiHierarchy reads double-quoted Android node attributes', () => {
1956
const xml =
2057
'<hierarchy><node class="android.widget.TextView" text="Hello" content-desc="Greeting" resource-id="com.demo:id/title" bounds="[10,20][110,60]" clickable="true" enabled="true"/></hierarchy>';
@@ -281,126 +318,45 @@ test('swipeAndroid invokes adb input swipe with duration', async () => {
281318
});
282319

283320
test('setAndroidSetting permission grant camera uses pm grant', async () => {
284-
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-permission-camera-'));
285-
const adbPath = path.join(tmpDir, 'adb');
286-
const argsLogPath = path.join(tmpDir, 'args.log');
287-
await fs.writeFile(
288-
adbPath,
321+
await withMockedAdb(
322+
'agent-device-android-permission-camera-',
289323
'#!/bin/sh\nprintf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
290-
'utf8',
324+
async ({ argsLogPath, device }) => {
325+
await setAndroidSetting(device, 'permission', 'grant', 'com.example.app', {
326+
permissionTarget: 'camera',
327+
});
328+
const logged = await fs.readFile(argsLogPath, 'utf8');
329+
assert.match(logged, /shell\npm\ngrant\ncom\.example\.app\nandroid\.permission\.CAMERA/);
330+
},
291331
);
292-
await fs.chmod(adbPath, 0o755);
293-
294-
const previousPath = process.env.PATH;
295-
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
296-
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
297-
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
298-
299-
const device: DeviceInfo = {
300-
platform: 'android',
301-
id: 'emulator-5554',
302-
name: 'Pixel',
303-
kind: 'emulator',
304-
booted: true,
305-
};
306-
307-
try {
308-
await setAndroidSetting(device, 'permission', 'grant', 'com.example.app', {
309-
permissionTarget: 'camera',
310-
});
311-
const logged = await fs.readFile(argsLogPath, 'utf8');
312-
assert.match(logged, /shell\npm\ngrant\ncom\.example\.app\nandroid\.permission\.CAMERA/);
313-
} finally {
314-
process.env.PATH = previousPath;
315-
if (previousArgsFile === undefined) {
316-
delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
317-
} else {
318-
process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
319-
}
320-
await fs.rm(tmpDir, { recursive: true, force: true });
321-
}
322332
});
323333

324334
test('setAndroidSetting permission deny notifications uses appops', async () => {
325-
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-permission-notifications-'));
326-
const adbPath = path.join(tmpDir, 'adb');
327-
const argsLogPath = path.join(tmpDir, 'args.log');
328-
await fs.writeFile(
329-
adbPath,
335+
await withMockedAdb(
336+
'agent-device-android-permission-notifications-',
330337
'#!/bin/sh\nprintf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
331-
'utf8',
338+
async ({ argsLogPath, device }) => {
339+
await setAndroidSetting(device, 'permission', 'deny', 'com.example.app', {
340+
permissionTarget: 'notifications',
341+
});
342+
const logged = await fs.readFile(argsLogPath, 'utf8');
343+
assert.match(logged, /shell\nappops\nset\ncom\.example\.app\nPOST_NOTIFICATION\ndeny/);
344+
},
332345
);
333-
await fs.chmod(adbPath, 0o755);
334-
335-
const previousPath = process.env.PATH;
336-
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
337-
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
338-
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
339-
340-
const device: DeviceInfo = {
341-
platform: 'android',
342-
id: 'emulator-5554',
343-
name: 'Pixel',
344-
kind: 'emulator',
345-
booted: true,
346-
};
347-
348-
try {
349-
await setAndroidSetting(device, 'permission', 'deny', 'com.example.app', {
350-
permissionTarget: 'notifications',
351-
});
352-
const logged = await fs.readFile(argsLogPath, 'utf8');
353-
assert.match(logged, /shell\nappops\nset\ncom\.example\.app\nPOST_NOTIFICATION\ndeny/);
354-
} finally {
355-
process.env.PATH = previousPath;
356-
if (previousArgsFile === undefined) {
357-
delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
358-
} else {
359-
process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
360-
}
361-
await fs.rm(tmpDir, { recursive: true, force: true });
362-
}
363346
});
364347

365348
test('setAndroidSetting permission reset camera maps to pm revoke', async () => {
366-
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-permission-reset-'));
367-
const adbPath = path.join(tmpDir, 'adb');
368-
const argsLogPath = path.join(tmpDir, 'args.log');
369-
await fs.writeFile(
370-
adbPath,
349+
await withMockedAdb(
350+
'agent-device-android-permission-reset-',
371351
'#!/bin/sh\nprintf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
372-
'utf8',
352+
async ({ argsLogPath, device }) => {
353+
await setAndroidSetting(device, 'permission', 'reset', 'com.example.app', {
354+
permissionTarget: 'camera',
355+
});
356+
const logged = await fs.readFile(argsLogPath, 'utf8');
357+
assert.match(logged, /shell\npm\nrevoke\ncom\.example\.app\nandroid\.permission\.CAMERA/);
358+
},
373359
);
374-
await fs.chmod(adbPath, 0o755);
375-
376-
const previousPath = process.env.PATH;
377-
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
378-
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
379-
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
380-
381-
const device: DeviceInfo = {
382-
platform: 'android',
383-
id: 'emulator-5554',
384-
name: 'Pixel',
385-
kind: 'emulator',
386-
booted: true,
387-
};
388-
389-
try {
390-
await setAndroidSetting(device, 'permission', 'reset', 'com.example.app', {
391-
permissionTarget: 'camera',
392-
});
393-
const logged = await fs.readFile(argsLogPath, 'utf8');
394-
assert.match(logged, /shell\npm\nrevoke\ncom\.example\.app\nandroid\.permission\.CAMERA/);
395-
} finally {
396-
process.env.PATH = previousPath;
397-
if (previousArgsFile === undefined) {
398-
delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
399-
} else {
400-
process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
401-
}
402-
await fs.rm(tmpDir, { recursive: true, force: true });
403-
}
404360
});
405361

406362
test('setAndroidSetting permission rejects mode argument', async () => {
@@ -420,18 +376,15 @@ test('setAndroidSetting permission rejects mode argument', async () => {
420376
(error: unknown) => {
421377
assert.equal(error instanceof AppError, true);
422378
assert.equal((error as AppError).code, 'INVALID_ARGS');
423-
assert.match((error as AppError).message, /mode is only supported for iOS photos/i);
379+
assert.match((error as AppError).message, /mode is only supported for photos/i);
424380
return true;
425381
},
426382
);
427383
});
428384

429385
test('setAndroidSetting permission grant photos falls back to legacy permission on older SDK', async () => {
430-
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-permission-photos-fallback-'));
431-
const adbPath = path.join(tmpDir, 'adb');
432-
const argsLogPath = path.join(tmpDir, 'args.log');
433-
await fs.writeFile(
434-
adbPath,
386+
await withMockedAdb(
387+
'agent-device-android-permission-photos-fallback-',
435388
[
436389
'#!/bin/sh',
437390
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
@@ -451,37 +404,13 @@ test('setAndroidSetting permission grant photos falls back to legacy permission
451404
'exit 1',
452405
'',
453406
].join('\n'),
454-
'utf8',
407+
async ({ argsLogPath, device }) => {
408+
await setAndroidSetting(device, 'permission', 'grant', 'com.example.app', {
409+
permissionTarget: 'photos',
410+
});
411+
const logged = await fs.readFile(argsLogPath, 'utf8');
412+
assert.match(logged, /shell\ngetprop\nro\.build\.version\.sdk/);
413+
assert.match(logged, /shell\npm\ngrant\ncom\.example\.app\nandroid\.permission\.READ_EXTERNAL_STORAGE/);
414+
},
455415
);
456-
await fs.chmod(adbPath, 0o755);
457-
458-
const previousPath = process.env.PATH;
459-
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
460-
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
461-
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
462-
463-
const device: DeviceInfo = {
464-
platform: 'android',
465-
id: 'emulator-5554',
466-
name: 'Pixel',
467-
kind: 'emulator',
468-
booted: true,
469-
};
470-
471-
try {
472-
await setAndroidSetting(device, 'permission', 'grant', 'com.example.app', {
473-
permissionTarget: 'photos',
474-
});
475-
const logged = await fs.readFile(argsLogPath, 'utf8');
476-
assert.match(logged, /shell\ngetprop\nro\.build\.version\.sdk/);
477-
assert.match(logged, /shell\npm\ngrant\ncom\.example\.app\nandroid\.permission\.READ_EXTERNAL_STORAGE/);
478-
} finally {
479-
process.env.PATH = previousPath;
480-
if (previousArgsFile === undefined) {
481-
delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
482-
} else {
483-
process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
484-
}
485-
await fs.rm(tmpDir, { recursive: true, force: true });
486-
}
487416
});

src/platforms/android/index.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import type { RawSnapshotNode, SnapshotOptions } from '../../utils/snapshot.ts';
77
import { isDeepLinkTarget } from '../../core/open-target.ts';
88
import { waitForAndroidBoot } from './devices.ts';
99
import { findBounds, parseBounds, parseUiHierarchy, readNodeAttributes } from './ui-hierarchy.ts';
10-
import { parsePermissionAction } from '../permission-utils.ts';
10+
import {
11+
parsePermissionAction,
12+
parsePermissionTarget,
13+
type PermissionSettingOptions,
14+
} from '../permission-utils.ts';
1115

1216
const ALIASES: Record<string, { type: 'intent' | 'package'; value: string }> = {
1317
settings: { type: 'intent', value: 'android.settings.SETTINGS' },
@@ -553,10 +557,7 @@ export async function setAndroidSetting(
553557
setting: string,
554558
state: string,
555559
appPackage?: string,
556-
options?: {
557-
permissionTarget?: string;
558-
permissionMode?: string;
559-
},
560+
options?: PermissionSettingOptions,
560561
): Promise<void> {
561562
const normalized = setting.toLowerCase();
562563
switch (normalized) {
@@ -713,17 +714,11 @@ function parseAndroidPermissionTarget(
713714
):
714715
| { kind: 'pm'; value: string; type: 'camera' | 'microphone' | 'photos' | 'contacts' }
715716
| { kind: 'appops'; value: string } {
716-
const normalized = permissionTarget?.trim().toLowerCase();
717-
if (!normalized) {
718-
throw new AppError(
719-
'INVALID_ARGS',
720-
'permission setting requires a target: camera|microphone|photos|contacts|notifications',
721-
);
722-
}
717+
const normalized = parsePermissionTarget(permissionTarget);
723718
if (permissionMode?.trim()) {
724719
throw new AppError(
725720
'INVALID_ARGS',
726-
`Permission mode is only supported for iOS photos. Received: ${permissionMode}.`,
721+
`Permission mode is only supported for photos. Received: ${permissionMode}.`,
727722
);
728723
}
729724
if (normalized === 'camera') return { kind: 'pm', value: 'android.permission.CAMERA', type: 'camera' };

src/platforms/ios/apps.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { AppError } from '../../utils/errors.ts';
33
import { runCmd } from '../../utils/exec.ts';
44
import { Deadline, retryWithPolicy } from '../../utils/retry.ts';
55
import { isDeepLinkTarget, resolveIosDeviceDeepLinkBundleId } from '../../core/open-target.ts';
6-
import { parsePermissionAction } from '../permission-utils.ts';
6+
import {
7+
parsePermissionAction,
8+
parsePermissionTarget,
9+
type PermissionSettingOptions,
10+
} from '../permission-utils.ts';
711

812
import { IOS_APP_LAUNCH_TIMEOUT_MS, IOS_DEVICECTL_TIMEOUT_MS } from './config.ts';
913
import {
@@ -222,10 +226,7 @@ export async function setIosSetting(
222226
setting: string,
223227
state: string,
224228
appBundleId?: string,
225-
options?: {
226-
permissionTarget?: string;
227-
permissionMode?: string;
228-
},
229+
options?: PermissionSettingOptions,
229230
): Promise<void> {
230231
ensureSimulator(device, 'settings');
231232
await ensureBootedSimulator(device);
@@ -360,13 +361,7 @@ function mapIosPermissionAction(action: 'grant' | 'deny' | 'reset'): 'grant' | '
360361
}
361362

362363
function parseIosPermissionTarget(permissionTarget: string | undefined, permissionMode: string | undefined): string {
363-
const normalized = permissionTarget?.trim().toLowerCase();
364-
if (!normalized) {
365-
throw new AppError(
366-
'INVALID_ARGS',
367-
'permission setting requires a target: camera|microphone|photos|contacts|notifications',
368-
);
369-
}
364+
const normalized = parsePermissionTarget(permissionTarget);
370365
if (normalized !== 'photos' && permissionMode?.trim()) {
371366
throw new AppError(
372367
'INVALID_ARGS',

0 commit comments

Comments
 (0)