Skip to content

Commit d654cc3

Browse files
committed
fix(ios): keep bridge-only behavior to iOS Simulators
The launch observation, the runner-free find admission, and the relaunch policy apply only where the host AX bridge exists: iOS Simulators. A tvOS Simulator keeps its awaited prewarm and asks for no observation, which the tvOS provider scenario now pins.
1 parent 646b2a5 commit d654cc3

5 files changed

Lines changed: 53 additions & 15 deletions

File tree

packages/platform-apple/src/lifecycle.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,3 +483,29 @@ test('a Simulator whose bridge cannot answer keeps the fixed settle', async () =
483483
expect(outcome.timing.postOpenObservation).toBe('unobservable');
484484
expect(events).toEqual(['open', 'sleep']);
485485
});
486+
487+
test('a tvOS Simulator relaunch keeps the awaited prewarm and asks for no observation', async () => {
488+
const events: string[] = [];
489+
const { host, prewarmRunnerSession, notifyRunnerAppRelaunched } = simulatorHost({ events });
490+
const awaitObservable = vi.fn(async () => 'observable' as const);
491+
const tvos = { ...simulator, appleOs: 'tvos', target: 'tv' } as const satisfies DeviceInfo;
492+
const lifecycle = bindAppleApplicationLifecycle({
493+
host,
494+
device: tvos,
495+
signal: new AbortController().signal,
496+
observation: { awaitObservable },
497+
});
498+
499+
const outcome = await lifecycle.openApplication({
500+
...openInput(),
501+
relaunch: true,
502+
execution: { plannedOperations: ['captureSnapshot'] },
503+
});
504+
505+
expect(outcome.timing.runnerDemand).toBeUndefined();
506+
expect(outcome.timing.runnerPrewarmWaited).toBe(true);
507+
expect(outcome.timing.postOpenObservation).toBeUndefined();
508+
expect(awaitObservable).not.toHaveBeenCalled();
509+
expect(prewarmRunnerSession).toHaveBeenCalledOnce();
510+
expect(notifyRunnerAppRelaunched).not.toHaveBeenCalled();
511+
});

packages/platform-apple/src/open-policy.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations';
77
import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device';
88
import { resolveAppleSimulatorRunnerDemand } from './runner-demand.ts';
9-
import type { LaunchObservationPort } from './snapshot-observability.ts';
9+
import { hasSimulatorBridge, type LaunchObservationPort } from './snapshot-observability.ts';
1010

1111
const POST_OPEN_SETTLE_MS = 300;
1212

@@ -31,7 +31,10 @@ export function resolveRunnerPrewarmPolicy(
3131
input: OpenApplicationInput,
3232
localIosSimulator: boolean,
3333
): RunnerPrewarmPolicy {
34-
const runnerDemand = localIosSimulator
34+
// Only a Simulator with the host AX bridge has a runner-free observation path, so only it
35+
// consults the plan and skips the relaunch wait; every other Apple target keeps its lifecycle.
36+
const bridge = localIosSimulator && hasSimulatorBridge(device);
37+
const runnerDemand = bridge
3538
? resolveAppleSimulatorRunnerDemand(input.execution.plannedOperations)
3639
: undefined;
3740
const shouldPrewarmRunner =
@@ -43,7 +46,7 @@ export function resolveRunnerPrewarmPolicy(
4346
return {
4447
...(runnerDemand ? { runnerDemand } : {}),
4548
shouldPrewarmRunner,
46-
awaitPrewarmAfterOpen: input.relaunch && !localIosSimulator,
49+
awaitPrewarmAfterOpen: input.relaunch && !bridge,
4750
};
4851
}
4952

@@ -62,7 +65,7 @@ export async function settleAppleOpen(
6265
timing: MutableOpenTiming,
6366
): Promise<void> {
6467
const startedAtMs = Date.now();
65-
if (localIosSimulator && observation && input.appBundleId) {
68+
if (localIosSimulator && hasSimulatorBridge(binding.device) && observation && input.appBundleId) {
6669
timing.postOpenObservation = await observation.awaitObservable(
6770
binding.device,
6871
input.appBundleId,

packages/platform-apple/src/runtime-snapshot.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import type {
1313
PlatformRuntimeHost,
1414
PlatformRuntimeOperations,
1515
} from '@agent-device/contracts/platform-runtime-operations';
16-
import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device';
16+
import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device';
17+
import { hasSimulatorBridge } from './snapshot-observability.ts';
1718
import type { AppleSnapshotRoute } from './snapshot-route.ts';
1819

1920
/** Apple-owned selection between app snapshots and explicit macOS surface snapshots. */
@@ -142,15 +143,15 @@ async function admitAppleNativeFind(
142143
}
143144

144145
/**
145-
* Whether the runner can answer a native find without a startup wait. Off a local Simulator the
146-
* runner is the only reader, so it always answers; on one, only a ready session does (see the
147-
* find-runtime doc above).
146+
* Whether the runner can answer a native find without a startup wait. Without the Simulator
147+
* bridge the runner is the only reader, so it always answers; with it, only a ready session does
148+
* (see the find-runtime doc above).
148149
*/
149150
async function runnerCanAnswerNow(
150151
host: Pick<PlatformRuntimeHost, 'appleApplications'>,
151152
device: DeviceInfo,
152153
execution: Readonly<{ requestId?: string }> | undefined,
153154
): Promise<boolean> {
154-
if (!isIosFamily(device) || device.kind !== 'simulator') return true;
155+
if (!hasSimulatorBridge(device)) return true;
155156
return await host.appleApplications.hasLiveRunnerSession(device, execution ?? {});
156157
}

packages/platform-apple/src/snapshot-observability.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,14 @@ test('a failure outside the launch transition ends the wait at once', async () =
138138
expect(sleep).not.toHaveBeenCalled();
139139
});
140140

141-
test('a device without a bridge is not eligible', async () => {
141+
test.each([
142+
['a physical iOS device', { ...simulator, kind: 'device' as const }],
143+
['a tvOS Simulator', { ...simulator, appleOs: 'tvos' as const, target: 'tv' as const }],
144+
])('%s has no bridge and is not eligible', async (_name, device) => {
142145
const { observe, acquire } = probe([acquired()], { now: () => 0, sleep: async () => {} });
143-
await expect(
144-
observe.awaitObservable({ ...simulator, kind: 'device' }, 'com.example.app', signal()),
145-
).resolves.toBe('not-eligible');
146+
await expect(observe.awaitObservable(device, 'com.example.app', signal())).resolves.toBe(
147+
'not-eligible',
148+
);
146149
expect(acquire).not.toHaveBeenCalled();
147150
});
148151

packages/platform-apple/src/snapshot-observability.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
deriveIosCaptureHint,
44
} from '@agent-device/capture-kit/ios-snapshot-planning';
55
import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations';
6-
import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device';
6+
import type { DeviceInfo } from '@agent-device/kernel/device';
77
import type { SimulatorSnapshotSource } from './snapshot-source-facade.ts';
88
import type { SimulatorSnapshotTargetResolver } from './snapshot-target.ts';
99

@@ -41,6 +41,11 @@ const LAUNCH_TRANSITION_WINDOW_MS: ReadonlyMap<string, number> = new Map([
4141
['foreground-owner-changed', 1_000],
4242
]);
4343

44+
/** Only iOS Simulators carry the host AX bridge; other Apple simulators observe through XCTest. */
45+
export function hasSimulatorBridge(device: DeviceInfo): boolean {
46+
return device.platform === 'apple' && device.appleOs === 'ios' && device.kind === 'simulator';
47+
}
48+
4449
export function createLaunchObservationProbe(
4550
deps: Readonly<{
4651
source: SimulatorSnapshotSource;
@@ -51,7 +56,7 @@ export function createLaunchObservationProbe(
5156
const hint = deriveIosCaptureHint(createIosSnapshotRequest({ depth: 1, interactiveOnly: true }));
5257
return Object.freeze({
5358
awaitObservable: async (device, appBundleId, signal) => {
54-
if (!isIosFamily(device) || device.kind !== 'simulator') return 'not-eligible';
59+
if (!hasSimulatorBridge(device)) return 'not-eligible';
5560
let deadline: number | undefined;
5661
for (;;) {
5762
const target = await deps.resolveTarget(device, appBundleId, signal).catch(() => undefined);

0 commit comments

Comments
 (0)