Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,30 @@ export type SettingOptions = {
longitude?: number;
};

/** The settings a simulator changes through `simctl` alone, whichever host runs that `simctl`. */
type SimctlSetting = 'appearance' | 'permission' | 'location';

/**
* Runs one `simctl` argv on the simulator. It resolves with the output when `simctl` exits 0 and
* rejects with a `COMMAND_FAILED` AppError whose `details.stderr` is the `simctl` stderr otherwise.
*/
type SimctlSettingRunner = (
args: string[],
) => Promise<{ readonly stdout: string; readonly stderr: string }>;

/** One simulator setting the Apple `simctl` plan changes, whichever host runs that `simctl`. */
export type SimctlSettingRequest = {
runSimctl: SimctlSettingRunner;
/** The simulator UDID `simctl` addresses, or `booted` where the runner reaches one simulator. */
udid: string;
/** The device id a refusal reports to the caller, which `udid` is not on every runner. */
deviceId: string;
setting: SimctlSetting;
state: string;
appBundleId?: string;
options?: SettingOptions;
};

/**
* Whether naming an app for one mutation can mean anything on one target.
*
Expand Down
38 changes: 36 additions & 2 deletions packages/platform-apple/src/core/__tests__/simctl-settings.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test, vi } from 'vitest';
import type { SimctlSettingRequest } from '@agent-device/contracts/settings';
import { AppError } from '@agent-device/kernel/errors';
import { applySimctlSetting, type SimctlSettingRequest } from '../simctl-settings.ts';
import { applySimctlSetting } from '../simctl-settings.ts';

function recordingRunner(outputs: Array<{ stdout: string; stderr: string } | AppError> = []) {
const queue = [...outputs];
Expand All @@ -15,7 +16,14 @@ function request(
runSimctl: SimctlSettingRequest['runSimctl'],
overrides: Partial<SimctlSettingRequest>,
): SimctlSettingRequest {
return { runSimctl, udid: 'SIM-1', setting: 'appearance', state: 'dark', ...overrides };
return {
runSimctl,
udid: 'SIM-1',
deviceId: 'SIM-1',
setting: 'appearance',
state: 'dark',
...overrides,
};
}

test('every simctl argv addresses the udid the runner was given', async () => {
Expand Down Expand Up @@ -72,6 +80,32 @@ test('a privacy service the runtime refuses is unsupported; other failures pass
await expect(applySimctlSetting(grant)).rejects.toBe(failed);
});

test('a refused privacy service reports the device id the caller names, not the udid', async () => {
const refused = new AppError('COMMAND_FAILED', 'simctl exited with code 1', {
stderr: 'Failed to grant access to com.example.app\nOperation not permitted',
});
const runSimctl = recordingRunner([refused]);

await expect(
applySimctlSetting(
request(runSimctl, {
udid: 'booted',
deviceId: 'limrun:ios:lease-a',
setting: 'permission',
state: 'grant',
appBundleId: 'com.example.app',
options: { permissionTarget: 'notifications' },
}),
),
).rejects.toMatchObject({
code: 'UNSUPPORTED_OPERATION',
details: { deviceId: 'limrun:ios:lease-a', appBundleId: 'com.example.app' },
});
expect(runSimctl.mock.calls).toEqual([
[['privacy', 'booted', 'grant', 'notifications', 'com.example.app']],
]);
});

test('an app-scoped setting without an app refuses before running simctl', async () => {
const runSimctl = recordingRunner();

Expand Down
1 change: 1 addition & 0 deletions packages/platform-apple/src/core/app-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export async function setIosSetting(
return await applySimctlSetting({
runSimctl: (args) => runSimctlForDevice(device, args),
udid: device.id,
deviceId: device.id,
setting: normalized,
state,
appBundleId,
Expand Down
27 changes: 3 additions & 24 deletions packages/platform-apple/src/core/simctl-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,11 @@ import {
parsePermissionAction,
parsePermissionTarget,
parseSettingState,
type SettingOptions,
type SimctlSettingRequest,
} from '@agent-device/contracts/settings';
import { AppError, sessionAppRequiredDetails } from '@agent-device/kernel/errors';
import { requireLocationCoordinates } from '@agent-device/kernel/location-coordinates';

/** The settings a simulator changes through `simctl` alone, whichever host runs that `simctl`. */
type SimctlSetting = 'appearance' | 'permission' | 'location';

/**
* Runs one `simctl` argv on the simulator. It resolves with the output when `simctl` exits 0 and
* rejects with a `COMMAND_FAILED` AppError whose `details.stderr` is the `simctl` stderr otherwise.
*/
type SimctlSettingRunner = (
args: string[],
) => Promise<{ readonly stdout: string; readonly stderr: string }>;

export type SimctlSettingRequest = {
runSimctl: SimctlSettingRunner;
/** The simulator UDID `simctl` addresses, or `booted` where the runner reaches one simulator. */
udid: string;
setting: SimctlSetting;
state: string;
appBundleId?: string;
options?: SettingOptions;
};

/** Changes one simulator setting through `simctl`; the Apple package owns this plan for every runner. */
export async function applySimctlSetting(
request: SimctlSettingRequest,
Expand Down Expand Up @@ -68,7 +47,7 @@ async function setAppearance({ runSimctl, udid, state }: SimctlSettingRequest):
}

async function setPermission(request: SimctlSettingRequest): Promise<void> {
const { runSimctl, udid, state, options } = request;
const { runSimctl, udid, deviceId, state, options } = request;
const appBundleId = requireAppBundleId(request);
const permissionAction = parsePermissionAction(state);
const action = permissionAction === 'deny' ? 'revoke' : permissionAction;
Expand All @@ -77,7 +56,7 @@ async function setPermission(request: SimctlSettingRequest): Promise<void> {
await runSimctl(['privacy', udid, action, target, appBundleId]);
} catch (error) {
if (!isIosPrivacyServiceRefusal(error)) throw error;
throw iosPrivacyServiceRefusedError(action, target, appBundleId, udid, error);
throw iosPrivacyServiceRefusedError(action, target, appBundleId, deviceId, error);
}
}

Expand Down
7 changes: 4 additions & 3 deletions packages/provider-limrun/src/ios-settings.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test, vi } from 'vitest';
import type { SimctlSettingRequest } from '@agent-device/contracts/settings';
import type { LimrunIosSession } from './ios.ts';
import type { LimrunIosSimctlSettingRequest } from './runtime-dependencies.ts';
import { setLimrunIosSetting } from './ios-settings.ts';

function sessionWithClient(results: Array<{ code: number; stdout: string; stderr: string }> = []) {
Expand All @@ -12,7 +12,7 @@ function sessionWithClient(results: Array<{ code: number; stdout: string; stderr
softReset: vi.fn(async (_bundleId: string, _options?: unknown) => ({})),
terminateApp: vi.fn(async (_bundleId: string) => {}),
};
const applySimctlSetting = vi.fn(async (request: LimrunIosSimctlSettingRequest) => {
const applySimctlSetting = vi.fn(async (request: SimctlSettingRequest) => {
const output = await request.runSimctl(['ui', request.udid, 'appearance']);
return { output };
});
Expand All @@ -27,7 +27,7 @@ function sessionWithClient(results: Array<{ code: number; stdout: string; stderr
return { session, client, applySimctlSetting };
}

test('simctl settings run the Apple plan on the booted simulator with the resolved app', async () => {
test('simctl settings run the Apple plan on the booted simulator, reporting the session device', async () => {
const { session, client, applySimctlSetting } = sessionWithClient([
{ code: 0, stdout: 'dark\n', stderr: '' },
]);
Expand All @@ -38,6 +38,7 @@ test('simctl settings run the Apple plan on the booted simulator with the resolv
expect(applySimctlSetting).toHaveBeenCalledWith({
runSimctl: expect.any(Function),
udid: 'booted',
deviceId: 'limrun:ios:settings',
setting: 'permission',
state: 'grant',
appBundleId: 'resolved.camera-app',
Expand Down
1 change: 1 addition & 0 deletions packages/provider-limrun/src/ios-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export async function setLimrunIosSetting(
return await session.dependencies.ios.applySimctlSetting({
runSimctl: (args) => runLimrunSimctl(session, args),
udid: 'booted',
deviceId: session.device.id,
setting: normalized,
state,
appBundleId:
Expand Down
20 changes: 3 additions & 17 deletions packages/provider-limrun/src/runtime-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Interactor } from '@agent-device/contracts/interactor-types';
import type { AndroidInputOwner } from '@agent-device/contracts/android-input-ownership';
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { AppError } from '@agent-device/kernel/errors';
import type { SettingOptions } from '@agent-device/contracts/settings';
import type { SimctlSettingRequest } from '@agent-device/contracts/settings';

export type LimrunAdbCommandOptions = {
allowFailure?: boolean;
Expand Down Expand Up @@ -141,23 +141,9 @@ export type LimrunHostAdapter = {
downloadFile(options: LimrunFileDownload): Promise<void>;
};

/**
* One simulator setting the Apple package changes through `simctl`, in that package's terms. The
* composition root supplies the Apple plan, so the provider does not import a platform package.
*/
export type LimrunIosSimctlSettingRequest = {
runSimctl(args: string[]): Promise<{ readonly stdout: string; readonly stderr: string }>;
udid: string;
setting: 'appearance' | 'permission' | 'location';
state: string;
appBundleId?: string;
options?: SettingOptions;
};

export type LimrunIosRuntimeAdapter = {
applySimctlSetting(
request: LimrunIosSimctlSettingRequest,
): Promise<Record<string, unknown> | void>;
/** Runs the Apple simctl plan, supplied by the composition root so this provider imports no platform package. */
applySimctlSetting(request: SimctlSettingRequest): Promise<Record<string, unknown> | void>;
resolveAppAlias(app: string): Promise<string>;
readBundleAppName(appPath: string): Promise<string | undefined>;
};
Expand Down
25 changes: 12 additions & 13 deletions src/sdk/limrun-runtime-dependencies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,8 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) =>
}));

vi.mock('@agent-device/platform-apple/simctl-settings', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@agent-device/platform-apple/simctl-settings')>();
return {
...actual,
applySimctlSetting: vi.fn(async (request: Parameters<typeof actual.applySimctlSetting>[0]) => {
moduleLoads.appleSimctlSettings += 1;
return await actual.applySimctlSetting(request);
}),
};
moduleLoads.appleSimctlSettings += 1;
return await importOriginal<typeof import('@agent-device/platform-apple/simctl-settings')>();
});

vi.mock('@agent-device/platform-apple/install-artifact', async (importOriginal) => ({
Expand Down Expand Up @@ -254,22 +247,28 @@ test('the adb invocation adapters address through the platform builders', async
assert.deepEqual(serializeAndroidAdbInvocation(host), ['disconnect', '127.0.0.1:62001']);
});

test('Limrun iOS settings run the Apple simctl plan, loaded on the first setting', async () => {
test('Limrun iOS settings evaluate the Apple simctl plan module on the first setting', async () => {
const { createLimrunRuntimeDependencies } = await import('./limrun-runtime-dependencies.ts');
const dependencies = createLimrunRuntimeDependencies();
const runSimctl = vi.fn(async (_args: string[]) => ({ stdout: '', stderr: '' }));

await dependencies.ios.applySimctlSetting({
const grantPhotos = {
runSimctl,
udid: 'booted',
deviceId: 'limrun:ios:lease-a',
setting: 'permission',
state: 'grant',
appBundleId: 'com.example.app',
options: { permissionTarget: 'photos', permissionMode: 'limited' },
});
} as const;

assert.equal(moduleLoads.appleSimctlSettings, 0);

await dependencies.ios.applySimctlSetting(grantPhotos);
await dependencies.ios.applySimctlSetting(grantPhotos);

assert.equal(moduleLoads.appleSimctlSettings, 1);
assert.deepEqual(runSimctl.mock.calls, [
[['privacy', 'booted', 'grant', 'photos-add', 'com.example.app']],
[['privacy', 'booted', 'grant', 'photos-add', 'com.example.app']],
]);
});
Loading