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
10 changes: 6 additions & 4 deletions packages/host-kit/src/internal/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,11 +688,13 @@ function createExitError(
* than relying on the spawn layer to throw. `extra` accepts a function so
* failure-only work (hint classification) is not paid on the success path.
*/
export function requireExecSuccess(
result: ExecResult,
export function requireExecSuccess<
R extends Pick<ExecResult, 'stdout' | 'stderr'> & Readonly<{ exitCode: number | null }>,
>(
result: R,
message: string,
extra?: Record<string, unknown> | ((result: ExecResult) => Record<string, unknown>),
): ExecResult {
extra?: Record<string, unknown> | ((result: R) => Record<string, unknown>),
): R {
if (result.exitCode === 0) return result;
throw new AppError(
'COMMAND_FAILED',
Expand Down
18 changes: 15 additions & 3 deletions packages/platform-apple/src/__tests__/app-error.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import assert from 'node:assert/strict';
import { AppError, normalizeError } from '@agent-device/kernel/errors';
import { AppError, defaultHintForCode, normalizeError } from '@agent-device/kernel/errors';

type ExpectedAppError = { code: string; message?: RegExp; hint?: string | RegExp };
type ExpectedAppError = {
code: string;
message?: RegExp;
/** Checked against `normalizeError(error).message`, e.g. the stderr excerpt a command failure appends. */
normalizedMessage?: RegExp;
/** `null` asserts the failure attached no hint of its own, so normalization falls back to the code default. */
hint?: string | RegExp | null;
};

function assertAppError(error: unknown, expected: ExpectedAppError): true {
assert.ok(
Expand All @@ -10,7 +17,12 @@ function assertAppError(error: unknown, expected: ExpectedAppError): true {
);
assert.equal(error.code, expected.code);
if (expected.message) assert.match(error.message, expected.message);
if (expected.hint !== undefined) {
if (expected.normalizedMessage) {
assert.match(normalizeError(error).message, expected.normalizedMessage);
}
if (expected.hint === null) {
assert.equal(normalizeError(error).hint, defaultHintForCode(error.code));
} else if (expected.hint !== undefined) {
const { hint } = normalizeError(error);
assert.ok(typeof hint === 'string', `expected a hint on ${error.code}, got ${String(hint)}`);
if (typeof expected.hint === 'string') assert.equal(hint, expected.hint);
Expand Down
69 changes: 0 additions & 69 deletions packages/platform-apple/src/core/__tests__/app-device-io.test.ts

This file was deleted.

35 changes: 2 additions & 33 deletions packages/platform-apple/src/core/__tests__/apps.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { beforeEach, test, vi } from 'vitest';
import assert from 'node:assert/strict';
import { promises as fs, readFileSync, writeFileSync } from 'node:fs';
import { promises as fs, writeFileSync } from 'node:fs';
import path from 'node:path';
import { mkdtempForTest } from '../../__tests__/tmp-dir.ts';

Expand All @@ -26,7 +26,7 @@ const retryActual = await vi.importActual<typeof import('@agent-device/host-kit/
const simulatorActual = await vi.importActual<typeof import('../simulator.ts')>('../simulator.ts');

import { closeIosApp, openIosApp } from '../app-launch.ts';
import { pushIosNotification, readIosClipboardText } from '../app-device-io.ts';
import { readIosClipboardText } from '../app-device-io.ts';
import { resolveIosApp, resolveIosSimulatorDeepLinkBundleId } from '../app-resolution.ts';
import { screenshotIos } from '../screenshot.ts';
import { withMockedMacOsHelper } from './macos-helper-test-utils.ts';
Expand Down Expand Up @@ -571,37 +571,6 @@ test('openIosApp with app and URL on iOS device launches app bundle with payload
);
});

test('pushIosNotification uses simctl push with temporary payload file', async () => {
const device: DeviceInfo = {
platform: 'apple',
id: 'sim-1',
name: 'iPhone',
kind: 'simulator',
booted: true,
};

let capturedPayload: string | undefined;
await withFakeAppleTool(
(args) => {
if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON;
if (args[0] === 'simctl' && args[1] === 'push') {
capturedPayload = readFileSync(args[4] ?? '', 'utf8');
return '';
}
return '';
},
async ({ calls }) => {
await pushIosNotification(device, 'com.example.app', { aps: { alert: 'hello', badge: 4 } });
const pushCall = calls.find((args) => args[0] === 'simctl' && args[1] === 'push');
assert.ok(pushCall);
assert.equal(pushCall[2], 'sim-1');
assert.equal(pushCall[3], 'com.example.app');
assert.match(pushCall[4] ?? '', /payload\.apns$/);
assert.deepEqual(JSON.parse(capturedPayload ?? ''), { aps: { alert: 'hello', badge: 4 } });
},
);
});

test('resolveIosApp resolves app display name on iOS physical devices', async () => {
const device: DeviceInfo = {
platform: 'apple',
Expand Down
27 changes: 0 additions & 27 deletions packages/platform-apple/src/core/app-device-io.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import path from 'node:path';
import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device';
import { requireExecSuccess } from '@agent-device/host-kit/command';
import {
makeHostTemporaryDirectory,
removeHostPath,
writeHostTextFile,
} from '@agent-device/host-kit/host-file';
import { ensureBootedSimulator, requireSimulatorDevice } from './simulator.ts';
import { readMacOsClipboardText, writeMacOsClipboardText } from '../os/macos/apps.ts';
import { runSimctlForDevice } from './simctl.ts';
Expand Down Expand Up @@ -38,24 +32,3 @@ export async function writeIosClipboardText(device: DeviceInfo, text: string): P
'Failed to write iOS simulator clipboard',
);
}

export async function pushIosNotification(
device: DeviceInfo,
bundleId: string,
payload: Record<string, unknown>,
options: Readonly<{ signal?: AbortSignal }> = {},
): Promise<void> {
requireSimulatorDevice(device, 'push');
options.signal?.throwIfAborted();
await ensureBootedSimulator(device, { signal: options.signal });
const tempDir = await makeHostTemporaryDirectory('agent-device-ios-push-');
const payloadPath = path.join(tempDir, 'payload.apns');
try {
await writeHostTextFile(payloadPath, `${JSON.stringify(payload)}\n`);
await runSimctlForDevice(device, ['push', device.id, bundleId, payloadPath], {
signal: options.signal,
});
} finally {
await removeHostPath(tempDir);
}
}
38 changes: 1 addition & 37 deletions packages/platform-apple/src/core/physical-device-apps.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import type { AppsFilter } from '@agent-device/contracts/device';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { IOS_DEVICE_INSTALL_TIMEOUT_MS } from './config.ts';
import {
listIosDeviceApps,
resolveIosDeviceAppProcesses,
runIosDevicectl,
terminateIosDeviceApp,
} from './devicectl.ts';
import type { IosAppInfo, IosDeviceAppProcesses } from './app-info.ts';
Expand All @@ -16,40 +14,6 @@ export async function listCoreDeviceApps(
return await listIosDeviceApps(device, filter);
}

export async function installCoreDeviceApp(
device: DeviceInfo,
installablePath: string,
signal?: AbortSignal,
): Promise<void> {
await runIosDevicectl(
['device', 'install', 'app', '--device', device.id, installablePath],
{
action: 'install iOS app',
deviceId: device.id,
},
{
signal,
timeoutMs: IOS_DEVICE_INSTALL_TIMEOUT_MS,
},
);
}

export async function uninstallCoreDeviceApp(
device: DeviceInfo,
bundleId: string,
signal?: AbortSignal,
): Promise<void> {
await runIosDevicectl(
['device', 'uninstall', 'app', '--device', device.id, bundleId],
{ action: `uninstall iOS app ${bundleId}`, deviceId: device.id },
{
signal,
tolerateOutput: (stdout, stderr) =>
isMissingAppErrorOutput(`${stdout}\n${stderr}`.toLowerCase()),
},
);
}

export async function terminateCoreDeviceApp(device: DeviceInfo, bundleId: string): Promise<void> {
await terminateIosDeviceApp(device, bundleId);
}
Expand All @@ -61,7 +25,7 @@ export async function resolveCoreDeviceAppProcesses(
return await resolveIosDeviceAppProcesses(device, bundleId);
}

function isMissingAppErrorOutput(output: string): boolean {
export function isMissingAppErrorOutput(output: string): boolean {
return (
output.includes('not installed') ||
output.includes('not found') ||
Expand Down
27 changes: 0 additions & 27 deletions packages/platform-apple/src/core/physical-device-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,9 @@ import { execFailureDetails } from '@agent-device/host-kit/command';
import type { AppsFilter } from '@agent-device/contracts/device';
import type { IosAppInfo, IosDeviceAppProcesses } from './app-info.ts';
import {
installCoreDeviceApp,
listCoreDeviceApps,
resolveCoreDeviceAppProcesses,
terminateCoreDeviceApp,
uninstallCoreDeviceApp,
} from './physical-device-apps.ts';
import {
ensureCoreDeviceReady,
Expand Down Expand Up @@ -42,11 +40,8 @@ type IosPhysicalDeviceLaunchOptions = {
};

export type IosPhysicalDeviceControl = IosPhysicalDeviceRunnerControl & {
assertAppInstallationSupported(device: DeviceInfo): void;
ensureReady(device: DeviceInfo, signal?: AbortSignal): Promise<void>;
listApps(device: DeviceInfo, filter: AppsFilter): Promise<IosAppInfo[]>;
installApp(device: DeviceInfo, installablePath: string, signal?: AbortSignal): Promise<void>;
uninstallApp(device: DeviceInfo, bundleId: string, signal?: AbortSignal): Promise<void>;
launchApp(
device: DeviceInfo,
bundleId: string,
Expand Down Expand Up @@ -77,11 +72,8 @@ export type IosPhysicalDeviceControl = IosPhysicalDeviceRunnerControl & {
const CONTROLS: Record<IosPhysicalDeviceBackend, IosPhysicalDeviceControl> = {
coredevice: {
backend: 'coredevice',
assertAppInstallationSupported: () => {},
ensureReady: ensureCoreDeviceReady,
listApps: listCoreDeviceApps,
installApp: installCoreDeviceApp,
uninstallApp: uninstallCoreDeviceApp,
launchApp: launchCoreDeviceApp,
terminateApp: async (device, bundleId) => await terminateCoreDeviceApp(device, bundleId),
resolveAppProcesses: resolveCoreDeviceAppProcesses,
Expand All @@ -94,11 +86,8 @@ const CONTROLS: Record<IosPhysicalDeviceBackend, IosPhysicalDeviceControl> = {
},
xctest: {
backend: 'xctest',
assertAppInstallationSupported: assertXctestAppInstallationUnsupported,
ensureReady: ensureXctestDeviceReady,
listApps: rejectXctestAppInventory,
installApp: rejectXctestAppInstallation,
uninstallApp: rejectXctestAppInstallation,
launchApp: launchXctestDeviceApp,
terminateApp: terminateXctestDeviceApp,
resolveAppProcesses: rejectXctestProcessLookup,
Expand All @@ -113,22 +102,6 @@ export function resolveIosPhysicalDeviceControl(device: DeviceInfo): IosPhysical
return CONTROLS[device.iosPhysicalDeviceBackend === 'xctest' ? 'xctest' : 'coredevice'];
}

function assertXctestAppInstallationUnsupported(device: DeviceInfo): never {
throw new AppError(
'UNSUPPORTED_OPERATION',
'Installing apps is unavailable on this XCTest-backed physical iOS device.',
{
deviceId: device.id,
backend: 'xctest',
hint: 'Install the app with Xcode, then open it in agent-device by bundle ID.',
},
);
}

async function rejectXctestAppInstallation(device: DeviceInfo): Promise<never> {
return assertXctestAppInstallationUnsupported(device);
}

async function rejectXctestAppInventory(device: DeviceInfo): Promise<never> {
throw new AppError(
'UNSUPPORTED_OPERATION',
Expand Down
Loading
Loading