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
3 changes: 2 additions & 1 deletion packages/platform-apple/src/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,8 @@ async function dispatchAppleOpen(
positionals: launch.positionals,
appBundleId: input.appBundleId,
execution: input.execution,
...(localIosSimulator && input.relaunch && !input.execution.clearAppState
...((localIosSimulator && input.relaunch && !input.execution.clearAppState) ||
isDirectAppLaunch(input)
? { terminateRunningApp: true }
: {}),
});
Expand Down
84 changes: 84 additions & 0 deletions packages/platform-apple/src/readiness/runtime-headless.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { expect, test, vi } from 'vitest';
import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { platformRuntimeHostFixture } from '../runtime.fixtures.ts';
import { ensureAppleReady } from './runtime.ts';

type CommandCall = { executable: string; args: readonly string[] };

/** A host whose simctl reports Shutdown until the boot command runs, then
* Booted (boot + bootstatus succeed); records every host.commands.run call
* (the `open -a Simulator` surface). */
function coldBootHost(bootedFromStart = false): {
host: PlatformRuntimeHost;
commandCalls: CommandCall[];
} {
const commandCalls: CommandCall[] = [];
let booted = bootedFromStart;
const host: PlatformRuntimeHost = {
...platformRuntimeHostFixture(),
appleTools: {
...platformRuntimeHostFixture().appleTools,
run: vi.fn(async (request: { args: readonly string[] }) => {
if (request.args.includes('boot')) booted = true;
return {
stdout: JSON.stringify({
devices: { ios: [{ udid: 'sim-1', state: booted ? 'Booted' : 'Shutdown' }] },
}),
stderr: '',
exitCode: 0,
};
}),
},
commands: {
...platformRuntimeHostFixture().commands,
run: async (request: { executable: string; args: readonly string[] }) => {
commandCalls.push({ executable: request.executable, args: request.args });
return { stdout: '', stderr: '', exitCode: 0 };
},
} as PlatformRuntimeHost['commands'],
};
return { host, commandCalls };
}

function simulatorOpenedSimulator(calls: readonly CommandCall[]): boolean {
return calls.some((c) => c.executable === 'open' && c.args.includes('Simulator'));
}

test('cold boot without headless opens the Simulator GUI app', async () => {
const { host, commandCalls } = coldBootHost();
await ensureAppleReady(host, simulatorShutdown(), new AbortController().signal);
expect(simulatorOpenedSimulator(commandCalls)).toBe(true);
});

test('cold boot with headless skips the Simulator GUI app', async () => {
const { host, commandCalls } = coldBootHost();
await ensureAppleReady(host, simulatorShutdown(), new AbortController().signal, {
headless: true,
});
expect(simulatorOpenedSimulator(commandCalls)).toBe(false);
});

test('warm boot never opens the Simulator GUI app regardless of headless', async () => {
const { host, commandCalls } = coldBootHost(true);
await ensureAppleReady(host, simulator({ booted: true }), new AbortController().signal, {
headless: true,
});
await ensureAppleReady(host, simulator({ booted: true }), new AbortController().signal);
expect(commandCalls).toHaveLength(0);
});

function simulator(overrides: Partial<DeviceInfo> = {}): DeviceInfo {
return {
platform: 'apple',
appleOs: 'ios',
kind: 'simulator',
id: 'sim-1',
name: 'iPhone 16',
...overrides,
} as DeviceInfo;
}

function simulatorShutdown(): DeviceInfo {
return simulator({ booted: false });
}
7 changes: 6 additions & 1 deletion packages/platform-apple/src/readiness/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export type AppleReadinessOptions = Readonly<{
* runner cache in parallel with the boot, which is the whole reason the hook exists.
*/
onColdBootStart?: () => void;
/**
* Cold-boot without focusing the Simulator GUI app. The default keeps the
* human-facing behavior; automation callers (daemon `boot --headless`) opt in.
*/
headless?: boolean;
}>;

export async function ensureAppleReady(
Expand All @@ -40,7 +45,7 @@ export async function ensureAppleReady(
options.onColdBootStart?.();
host.deviceReadiness.appleAutomation.keepHot(device);
await bootSimulator(host, device, signal);
await showSimulator(host, signal);
if (!options.headless) await showSimulator(host, signal);
}
host.deviceReadiness.appleAutomation.keepHot(device);
// Publish the fresh observation so the boot checks later in this flow skip their own listing.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1310,9 +1310,7 @@ test('runAppleRunnerCommand does NOT resend a mutating command on a generic retr
new AppError('COMMAND_FAILED', 'fetch failed'),
);

await assert.rejects(
runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 1, y: 2 }),
);
await assert.rejects(runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 1, y: 2 }));
// Only ONE tap dispatch. (A status probe may follow as recovery — that is
// not a resend; every tap-shaped call must be the original alone.)
const tapDispatches = mockExecuteRunnerCommandWithSession.mock.calls.filter(
Expand Down
5 changes: 4 additions & 1 deletion packages/platform-apple/src/runner/runner-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,10 @@ function matchesRunnerErrorRule(error: AppError, match: RunnerErrorMatch): boole

function matchesRunnerErrorCode(error: AppError, runnerErrorCode: string | undefined): boolean {
if (!runnerErrorCode) return true;
return (error.details as { runnerErrorCode?: unknown } | undefined)?.runnerErrorCode === runnerErrorCode;
return (
(error.details as { runnerErrorCode?: unknown } | undefined)?.runnerErrorCode ===
runnerErrorCode
);
}

function matchesRunnerErrorDetails(error: AppError, details: RunnerErrorMatch['details']): boolean {
Expand Down
145 changes: 75 additions & 70 deletions packages/platform-apple/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,90 +57,95 @@ test('tvOS audio capture availability follows the exact host-owned runtime fact'
});

test.each([
['iOS simulator', leaves.ios, true, undefined],
['iOS simulator', leaves.ios, true, undefined, true],
[
'iOS physical CoreDevice',
appleDevice({ kind: 'device', iosPhysicalDeviceBackend: 'coredevice' }),
true,
undefined,
false,
],
[
'iOS physical XCTest',
appleDevice({ kind: 'device', iosPhysicalDeviceBackend: 'xctest' }),
false,
'CoreDevice-backed physical iOS device',
false,
],
['iPadOS simulator', leaves.ipados, true, undefined],
['tvOS simulator', leaves.tvos, true, undefined],
['macOS host', leaves.macos, true, undefined],
['visionOS simulator', leaves.visionos, true, undefined],
['watchOS sentinel', leaves.watchos, false, 'watchOS app logs are not supported'],
])('classifies the %s leaf explicitly', async (_name, device, available, hint) => {
const binding = await createApplePlatformRuntime(platformRuntimeHostFixture()).bind({
device,
intent: { kind: 'ordinary' },
scope: {
signal: new AbortController().signal,
diagnostics: { emit: () => {} },
progress: { report: () => {} },
},
});
const { facts } = binding;
expect(facts.device.providerMode).toBe('local');
expect(facts.operations.appState).toEqual({
available: false,
reason: 'unsupported-platform-leaf',
hint: expect.stringContaining('session state'),
});
expect(binding.operations.appState).toBeUndefined();
expect(facts.operations.networkDump).toEqual({ available: true });
expect(facts.operations.listApps.available).toBe(
device.appleOs !== 'watchos' && device.iosPhysicalDeviceBackend !== 'xctest',
);
// R40/R41: touch and text ride the Apple interactor, which exists for the simulator and
// physical device kinds — every leaf in this table is one of those two, so both cells are
// available across it (parity with the retired buckets).
expect(facts.operations.focusPoint).toEqual({ available: true });
expect(facts.operations.typeText).toEqual({ available: true });
expect(binding.operations.focusPoint).toBeTypeOf('function');
expect(binding.operations.typeText).toBeTypeOf('function');
for (const operation of ['appLogInspect', 'appLogDoctor', 'appLogStart'] as const) {
const fact = facts.operations[operation];
expect(fact.available).toBe(available);
if (!available && hint) expect(fact).toHaveProperty('hint', expect.stringContaining(hint));
}
for (const operation of [
'screenRecordingStart',
'screenRecordingReattach',
'screenRecordingCleanup',
] as const) {
expect(facts.operations[operation].available).toBe(available);
}
expectApplePerfAvailability(binding, available);
if (device.iosPhysicalDeviceBackend === 'xctest') {
expect(facts.operations.screenRecordingStart).toMatchObject({
hint: expect.stringContaining('CoreDevice-backed physical iOS device'),
['iPadOS simulator', leaves.ipados, true, undefined, true],
['tvOS simulator', leaves.tvos, true, undefined, true],
['macOS host', leaves.macos, true, undefined, false],
['visionOS simulator', leaves.visionos, true, undefined, true],
['watchOS sentinel', leaves.watchos, false, 'watchOS app logs are not supported', false],
])(
'classifies the %s leaf explicitly',
async (_name, device, available, hint, bootHeadlessExpected) => {
const binding = await createApplePlatformRuntime(platformRuntimeHostFixture()).bind({
device,
intent: { kind: 'ordinary' },
scope: {
signal: new AbortController().signal,
diagnostics: { emit: () => {} },
progress: { report: () => {} },
},
});
}
if (device.appleOs === 'watchos') {
expect(facts.operations.screenRecordingStart).toMatchObject({
hint: 'watchOS recording is not supported.',
const { facts } = binding;
expect(facts.device.providerMode).toBe('local');
expect(facts.operations.appState).toEqual({
available: false,
reason: 'unsupported-platform-leaf',
hint: expect.stringContaining('session state'),
});
}
expect(facts.operations.ensureReady.available).toBe(device.appleOs !== 'watchos');
expect(facts.operations.bootTarget.available).toBe(
device.appleOs !== 'macos' && device.appleOs !== 'watchos',
);
expect(facts.operations.bootTargetHeadless.available).toBe(false);
expect(facts.operations.setViewport).toEqual({
available: false,
reason: 'unsupported-platform-leaf',
hint: 'viewport resizes web targets only (--platform web). Apple screen geometry is fixed by the selected simulator or device type — open a different simulator to test another screen size.',
});
expect(binding.operations.setViewport).toBeUndefined();
expectAppleCaptureAvailability(binding, device);
expectAppleSnapshotAvailability(binding, device);
});
expect(binding.operations.appState).toBeUndefined();
expect(facts.operations.networkDump).toEqual({ available: true });
expect(facts.operations.listApps.available).toBe(
device.appleOs !== 'watchos' && device.iosPhysicalDeviceBackend !== 'xctest',
);
// R40/R41: touch and text ride the Apple interactor, which exists for the simulator and
// physical device kinds — every leaf in this table is one of those two, so both cells are
// available across it (parity with the retired buckets).
expect(facts.operations.focusPoint).toEqual({ available: true });
expect(facts.operations.typeText).toEqual({ available: true });
expect(binding.operations.focusPoint).toBeTypeOf('function');
expect(binding.operations.typeText).toBeTypeOf('function');
for (const operation of ['appLogInspect', 'appLogDoctor', 'appLogStart'] as const) {
const fact = facts.operations[operation];
expect(fact.available).toBe(available);
if (!available && hint) expect(fact).toHaveProperty('hint', expect.stringContaining(hint));
}
for (const operation of [
'screenRecordingStart',
'screenRecordingReattach',
'screenRecordingCleanup',
] as const) {
expect(facts.operations[operation].available).toBe(available);
}
expectApplePerfAvailability(binding, available);
if (device.iosPhysicalDeviceBackend === 'xctest') {
expect(facts.operations.screenRecordingStart).toMatchObject({
hint: expect.stringContaining('CoreDevice-backed physical iOS device'),
});
}
if (device.appleOs === 'watchos') {
expect(facts.operations.screenRecordingStart).toMatchObject({
hint: 'watchOS recording is not supported.',
});
}
expect(facts.operations.ensureReady.available).toBe(device.appleOs !== 'watchos');
expect(facts.operations.bootTarget.available).toBe(
device.appleOs !== 'macos' && device.appleOs !== 'watchos',
);
expect(facts.operations.bootTargetHeadless.available).toBe(bootHeadlessExpected);
expect(facts.operations.setViewport).toEqual({
available: false,
reason: 'unsupported-platform-leaf',
hint: 'viewport resizes web targets only (--platform web). Apple screen geometry is fixed by the selected simulator or device type — open a different simulator to test another screen size.',
});
expect(binding.operations.setViewport).toBeUndefined();
expectAppleCaptureAvailability(binding, device);
expectAppleSnapshotAvailability(binding, device);
},
);

function expectApplePerfAvailability(
binding: DeviceBinding<PlatformRuntimeOperations>,
Expand Down
24 changes: 22 additions & 2 deletions packages/platform-apple/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ const appStateUnavailable = Object.freeze({
const headlessUnavailable = Object.freeze({
available: false,
reason: 'unsupported-provider-mode',
hint: 'Headless boot is supported only for local Android emulators.',
hint: 'Headless boot is supported only for local Android emulators and Apple simulators.',
} as const);
const elementTextLeafUnavailable = Object.freeze({
available: false,
Expand Down Expand Up @@ -266,6 +266,15 @@ function appleFocusFact(device: DeviceInfo): RuntimeOperationFact {
return device.kind === 'simulator' || device.kind === 'device' ? available : focusKindUnavailable;
}

/** Headless boot rides the boot cell wherever the target has a GUI window to
* suppress: simulators only (macOS has no window; watchOS cannot boot at all). */
function appleBootHeadlessFact(
device: DeviceInfo,
boot: RuntimeOperationFact,
): RuntimeOperationFact {
return device.kind === 'simulator' && boot.available ? boot : headlessUnavailable;
}

export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner {
const appLogs = createAppleAppLogRuntime(host);
const inspectFacts = async (device: DeviceInfo) => {
Expand All @@ -285,6 +294,7 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR
: leafRecordingFacts;
const readiness = device.appleOs === 'watchos' ? unavailable : available;
const boot = isMacOs(device) || device.appleOs === 'watchos' ? unavailable : available;
const bootHeadless = appleBootHeadlessFact(device, boot);
const apps = appInventoryFacts(device);
return Object.freeze({
device: logs.device,
Expand Down Expand Up @@ -328,7 +338,7 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR
...perfRuntimeOperationFacts(applePerfFacts(device)),
ensureReady: readiness,
bootTarget: boot,
bootTargetHeadless: headlessUnavailable,
bootTargetHeadless: bootHeadless,
listApps: apps,
...appleApplicationLifecycleFacts(device),
shutdownTarget: shutdownFact(device),
Expand Down Expand Up @@ -461,6 +471,16 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR
bootTarget: async () =>
await ensureAppleReady(host, request.device, request.scope.signal),
})),
...(facts.operations.bootTargetHeadless.available
? {
bootTargetHeadless: async () =>
// Daemon input is serial/allowlist-only; headless is a caller-side
// mode of this cell, not request data (parity with Android).
await ensureAppleReady(host, request.device, request.scope.signal, {
headless: true,
}),
}
: {}),
...whenAdmitted(facts.operations.listApps, () => ({
listApps: async (input: { device: DeviceInfo; filter: 'all' | 'user-installed' }) =>
await host.appInventory.apple.listApps(
Expand Down
21 changes: 21 additions & 0 deletions src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,27 @@ test('apps.installFromSource forwards GitHub Actions artifact sources unchanged'
});
});

test('interactions.keyboardDismiss issues system keyboard dismiss for the device', async () => {
const setup = createTransport(async (req) => {
assert.equal(req.command, 'keyboard');
assert.deepEqual(req.positionals, ['dismiss']);
assert.equal((req.flags as Record<string, unknown>).udid, 'UDID-1');
return {
ok: true,
data: {
kind: 'keyboardDismissed',
action: 'dismiss',
state: { visible: false },
message: 'Keyboard hidden',
},
};
});
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });

const result = await client.interactions.keyboardDismiss({ udid: 'UDID-1' });
assert.equal(result.kind, 'keyboardDismissed');
});

test('interactions.rotateGesture rejects partial centers on the client side', async () => {
const setup = createTransport(async () => {
throw new Error('transport should not run for invalid input');
Expand Down
Loading