Skip to content

Commit f64615d

Browse files
authored
fix(apple): read the launch confirmation whenever the open cannot see the app (#3115)
* fix(apple): read the launch confirmation whenever the open cannot see the app An iOS Simulator open with --launch-url answered the "Open in <App>?" prompt only when the post-open observation was `unobservable`. A target discovery that failed on its own deadline, an open bridge circuit or a failed probe produced `probe-failed`, the read was skipped, and the open returned green for a launch that never happened; every later command then failed `app is not running`. - `settleAppleOpen` reads the sheet on any verdict short of `observable`, and keeps the typed `postOpenObservationFailure` for a verdict the bridge could not produce. - An answer attempt now reports its typed outcome instead of swallowing to `undefined`. Only an accept changes the device, so only its outcome is re-observed: when the app is then proven to have no process, the accept died with the runner session that raised it or the device dropped a held URL, and the open hands the same URL over once more and reads again. - A launch still proven not running afterwards fails with `launch_confirmation_unanswered` rather than returning success. - `LaunchObservation.unobservable` carries how it was proven, because only a settled target discovery proves no process; an expired launch-transition window proves only that the app was still coming up. - Bounded `simctl openurl` (#3101): a CoreSimulator that never answers fails the open within 20s and kills the child, instead of the request being cancelled around a still-running `openurl`. Closes #3096 Closes #3101 * docs(apple): state the launch-confirmation contract the settle acts on * test(apple): cancel a running openurl through the request signal The pre-seeded rejection only proved the catch rethrows a foreign failure. Drive the abort for real so the forwarded `runnerOptions.signal` is what ends the child, and keep the dispatch disclosure out of the producer: `open` is declared `mutates-app`, so the daemon seam already answers `unknown` for a failure no producer classified. * test(apple): keep one source of truth for the confirmation shape The review found three P3s on the same split: - The openurl-timeout test compared `hint` against itself, so nothing pinned the operator-facing text. Export `IOS_SIMULATOR_OPENURL_TIMEOUT_HINT` and assert it. - `CONFIRMATION` and `alertNotFound()` existed twice, one copy per test file, so prompt-text changes could diverge. Both now come from `launch-confirmation.fixtures.ts`, which `lifecycle.fixtures.ts` no longer duplicates. - The docs claimed every later command would have failed; `open` and `activate` can relaunch a stopped app, so the claim is limited to commands that require a running app.
1 parent 41b3804 commit f64615d

16 files changed

Lines changed: 776 additions & 309 deletions

‎packages/platform-apple/src/core/__tests__/app-launch-url.test.ts‎

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { beforeEach, test, vi } from 'vitest';
22
import assert from 'node:assert/strict';
33
import { IOS_TEST_SIMULATOR } from './apple-core-stub-helpers.ts';
4-
import { IOS_SIMULATOR_TERMINATE_TIMEOUT_MS } from '../config.ts';
4+
import { IOS_SIMULATOR_OPENURL_TIMEOUT_MS, IOS_SIMULATOR_TERMINATE_TIMEOUT_MS } from '../config.ts';
55

66
vi.mock('@agent-device/host-kit/command', async (importOriginal) => {
77
const actual = await importOriginal<typeof import('@agent-device/host-kit/command')>();
@@ -12,9 +12,14 @@ vi.mock('../simulator.ts', async (importOriginal) => {
1212
return { ...actual, ensureBootedSimulator: vi.fn(actual.ensureBootedSimulator) };
1313
});
1414

15+
import { AppError } from '@agent-device/kernel/errors';
1516
import { runCmd } from '@agent-device/host-kit/command';
1617
import { ensureBootedSimulator } from '../simulator.ts';
17-
import { openIosApp } from '../app-launch.ts';
18+
import {
19+
IOS_SIMULATOR_OPENURL_TIMEOUT_HINT,
20+
IOS_SIMULATOR_OPENURL_TIMEOUT_REASON,
21+
openIosApp,
22+
} from '../app-launch.ts';
1823

1924
const mockRunCmd = vi.mocked(runCmd);
2025
const mockEnsureBootedSimulator = vi.mocked(ensureBootedSimulator);
@@ -41,6 +46,51 @@ test('iOS simulator URL relaunch terminates the app before opening the URL', asy
4146
timeoutMs: IOS_SIMULATOR_TERMINATE_TIMEOUT_MS,
4247
},
4348
],
44-
['xcrun', ['simctl', 'openurl', 'sim-1', 'myapp://automation'], undefined],
49+
[
50+
'xcrun',
51+
['simctl', 'openurl', 'sim-1', 'myapp://automation'],
52+
{ timeoutMs: IOS_SIMULATOR_OPENURL_TIMEOUT_MS },
53+
],
4554
]);
4655
});
56+
57+
test('a Simulator that never answers openurl fails the open at its own bound', async () => {
58+
mockRunCmd.mockRejectedValueOnce(
59+
new AppError('COMMAND_FAILED', 'xcrun timed out after 20000ms', { timeoutMs: 20_000 }),
60+
);
61+
62+
const failure = await openIosApp(IOS_TEST_SIMULATOR, 'myapp://automation', {})
63+
.then(() => undefined)
64+
.catch((error: unknown) => error);
65+
66+
assert.ok(failure instanceof AppError);
67+
assert.equal(failure.code, 'COMMAND_FAILED');
68+
assert.deepEqual(failure.details, {
69+
timeoutMs: IOS_SIMULATOR_OPENURL_TIMEOUT_MS,
70+
reason: IOS_SIMULATOR_OPENURL_TIMEOUT_REASON,
71+
deviceId: IOS_TEST_SIMULATOR.id,
72+
hint: IOS_SIMULATOR_OPENURL_TIMEOUT_HINT,
73+
});
74+
});
75+
76+
test('a request canceled while openurl runs stays a canceled request, not a timeout', async () => {
77+
const open = new AbortController();
78+
mockRunCmd.mockImplementation(
79+
(_cmd, _args, options) =>
80+
new Promise((_resolve, reject) => {
81+
options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), {
82+
once: true,
83+
});
84+
}),
85+
);
86+
87+
const pending = openIosApp(IOS_TEST_SIMULATOR, 'myapp://automation', {
88+
runnerOptions: { signal: open.signal },
89+
}).catch((error: unknown) => error);
90+
91+
await vi.waitFor(() => assert.equal(mockRunCmd.mock.calls.length, 1));
92+
const canceled = new AppError('COMMAND_FAILED', 'request canceled');
93+
open.abort(canceled);
94+
95+
assert.equal(await pending, canceled);
96+
});

‎packages/platform-apple/src/core/__tests__/apps.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { screenshotIos } from '../screenshot.ts';
3232
import { withMockedMacOsHelper } from './macos-helper-test-utils.ts';
3333
import { quitMacOsApp, resolveMacOsHelperPackageRootFrom } from '../../os/macos/helper.ts';
3434
import { ensureBootedSimulator } from '../simulator.ts';
35-
import { IOS_SIMULATOR_TERMINATE_TIMEOUT_MS } from '../config.ts';
35+
import { IOS_SIMULATOR_OPENURL_TIMEOUT_MS, IOS_SIMULATOR_TERMINATE_TIMEOUT_MS } from '../config.ts';
3636
import type { DeviceInfo } from '@agent-device/kernel/device';
3737
import { runCmd } from '@agent-device/host-kit/command';
3838
import { retryWithPolicy } from '@agent-device/host-kit/retry';
@@ -407,7 +407,7 @@ test('openIosApp opens custom-scheme iOS simulator URLs directly when launch arg
407407
assert.deepEqual(mockRunCmd.mock.calls[0], [
408408
'xcrun',
409409
['simctl', 'openurl', 'sim-1', 'myapp://item/42'],
410-
undefined,
410+
{ timeoutMs: IOS_SIMULATOR_OPENURL_TIMEOUT_MS },
411411
]);
412412
});
413413

@@ -432,7 +432,7 @@ test('openIosApp launches iOS simulator app before opening custom-scheme URL wit
432432
assert.deepEqual(mockRunCmd.mock.calls[1], [
433433
'xcrun',
434434
['simctl', 'openurl', 'sim-1', 'myapp://item/42'],
435-
undefined,
435+
{ timeoutMs: IOS_SIMULATOR_OPENURL_TIMEOUT_MS },
436436
]);
437437
});
438438

@@ -457,7 +457,7 @@ test('openIosApp launches iOS simulator app before opening https URL with launch
457457
assert.deepEqual(mockRunCmd.mock.calls[1], [
458458
'xcrun',
459459
['simctl', 'openurl', 'sim-1', 'https://example.com/item/42'],
460-
undefined,
460+
{ timeoutMs: IOS_SIMULATOR_OPENURL_TIMEOUT_MS },
461461
]);
462462
});
463463

‎packages/platform-apple/src/core/app-launch.ts‎

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import path from 'node:path';
22
import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device';
33
import { AppError } from '@agent-device/kernel/errors';
4-
import { execFailureDetails } from '@agent-device/host-kit/command';
4+
import { isCommandTimeoutError, execFailureDetails } from '@agent-device/host-kit/command';
55
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
66
import { ensureHostDirectory, writeHostTextFile } from '@agent-device/host-kit/host-file';
77
import { Deadline, retryWithPolicy } from '@agent-device/host-kit/retry';
@@ -16,7 +16,11 @@ import {
1616
isWebUrl,
1717
resolveIosDeviceDeepLinkBundleId,
1818
} from '@agent-device/contracts/command';
19-
import { IOS_APP_LAUNCH_TIMEOUT_MS, IOS_SIMULATOR_TERMINATE_TIMEOUT_MS } from './config.ts';
19+
import {
20+
IOS_APP_LAUNCH_TIMEOUT_MS,
21+
IOS_SIMULATOR_OPENURL_TIMEOUT_MS,
22+
IOS_SIMULATOR_TERMINATE_TIMEOUT_MS,
23+
} from './config.ts';
2024
import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts';
2125
import { runAppleRunnerCommand } from './runner-client.ts';
2226
import type { AppleRunnerCommandOptions } from '../runner/index.ts';
@@ -30,12 +34,22 @@ import { ensureBootedSimulator } from './simulator.ts';
3034
import { runXcrun, type ScopedSimctlCommand } from './tool-provider.ts';
3135
import { closeMacOsApp, openMacOsApp } from '../os/macos/apps.ts';
3236
import { resolveIosApp } from './app-resolution.ts';
33-
import { buildSimctlArgsForDevice, runSimctlForDevice } from './simctl.ts';
37+
import { buildSimctlArgsForDevice } from './simctl.ts';
3438

3539
const IOS_SIMULATOR_CONSOLE_CAPTURE_MS = 25_000;
3640
const IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE =
3741
'--launch-args is not supported with iOS simulator URL opens (simctl openurl ignores launch args). Launch the app first with --launch-args, then issue the URL open in a separate call.';
3842

43+
/** Why an open failed because CoreSimulator never answered its `simctl openurl` within the bound. */
44+
export const IOS_SIMULATOR_OPENURL_TIMEOUT_REASON = 'ios-simulator-openurl-timeout';
45+
46+
/**
47+
* A killed child answers nothing, so the bound cannot report whether the hand-off landed; the hint is
48+
* the operator's only notice that a retry can duplicate the deep link.
49+
*/
50+
export const IOS_SIMULATOR_OPENURL_TIMEOUT_HINT =
51+
'The Simulator may still have received the URL; CoreSimulator is slow to answer. Retry the open, or answer any "Open in" prompt with alert accept.';
52+
3953
// fallow-ignore-next-line complexity
4054
export async function openIosApp(
4155
device: DeviceInfo,
@@ -85,7 +99,7 @@ export async function openIosApp(
8599
await terminateIosSimulatorApp(device, bundleId);
86100
}
87101
}
88-
await openIosSimulatorUrl(device, explicitUrl, undefined);
102+
await openIosSimulatorUrl(device, explicitUrl, undefined, options?.runnerOptions);
89103
return;
90104
}
91105
const appBundleId = options?.appBundleId ?? (await resolveIosApp(device, app));
@@ -110,7 +124,7 @@ export async function openIosApp(
110124
throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE);
111125
}
112126
if (device.kind === 'simulator') {
113-
await openIosSimulatorUrl(device, deepLinkTarget, launchArgs);
127+
await openIosSimulatorUrl(device, deepLinkTarget, launchArgs, options?.runnerOptions);
114128
return;
115129
}
116130
const bundleId = resolveIosDeviceDeepLinkBundleId(options?.appBundleId, deepLinkTarget);
@@ -148,12 +162,33 @@ async function openIosSimulatorUrl(
148162
device: DeviceInfo,
149163
url: string,
150164
launchArgs: string[] | undefined,
165+
runnerOptions?: AppleRunnerCommandOptions,
151166
): Promise<void> {
152167
if (launchArgs && launchArgs.length > 0) {
153168
throw new AppError('INVALID_ARGS', IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE);
154169
}
155170
await ensureBootedSimulator(device);
156-
await runSimctlForDevice(device, ['openurl', device.id, url]);
171+
const args = buildSimctlArgsForDevice(device, ['openurl', device.id, url]);
172+
try {
173+
await runXcrun(args, {
174+
timeoutMs: IOS_SIMULATOR_OPENURL_TIMEOUT_MS,
175+
...(runnerOptions?.signal ? { signal: runnerOptions.signal } : {}),
176+
});
177+
} catch (error) {
178+
// Only a bound this call armed is relabelled; a cancelled request stays a cancelled request.
179+
if (!isCommandTimeoutError(error)) throw error;
180+
throw new AppError(
181+
'COMMAND_FAILED',
182+
`xcrun simctl openurl did not answer within ${IOS_SIMULATOR_OPENURL_TIMEOUT_MS}ms.`,
183+
{
184+
...error.details,
185+
reason: IOS_SIMULATOR_OPENURL_TIMEOUT_REASON,
186+
timeoutMs: IOS_SIMULATOR_OPENURL_TIMEOUT_MS,
187+
deviceId: device.id,
188+
hint: IOS_SIMULATOR_OPENURL_TIMEOUT_HINT,
189+
},
190+
);
191+
}
157192
}
158193

159194
export async function openIosDevice(device: DeviceInfo): Promise<void> {

‎packages/platform-apple/src/core/config.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ export const IOS_SIMULATOR_FOCUS_TIMEOUT_MS = 10_000;
1414

1515
export const IOS_SIMULATOR_TERMINATE_TIMEOUT_MS = 15_000;
1616

17+
/**
18+
* How long a Simulator `simctl openurl` may take. CoreSimulator can hold an URL hand-off for
19+
* minutes while it is wedged, and every other simctl call this open makes is bounded; the bound
20+
* stays well under the daemon request budget so the open answers with its own typed reason and
21+
* kills the child instead of being cancelled around a still-running `openurl`.
22+
*/
23+
export const IOS_SIMULATOR_OPENURL_TIMEOUT_MS = 20_000;
24+
1725
export const IOS_SIMULATOR_SCREENSHOT_TIMEOUT_MS = 20_000;
1826

1927
// The CoreDevice panel probe runs on the same request budget as the capture it
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { ALERT_NOT_FOUND_RUNNER_CODE } from '@agent-device/contracts/alert-contract';
2+
import { AppError } from '@agent-device/kernel/errors';
3+
4+
/** The prompt a session app's own launch URL raises. */
5+
export const CONFIRMATION = { message: 'Open in “Example App”?', items: ['Cancel', 'Open'] };
6+
7+
/** The runner's typed absence: `alert get` looked once and found no alert. */
8+
export function alertNotFound(): AppError {
9+
return new AppError('COMMAND_FAILED', 'alert not found', {
10+
runnerErrorCode: ALERT_NOT_FOUND_RUNNER_CODE,
11+
});
12+
}

0 commit comments

Comments
 (0)