Skip to content

Commit 3c285bb

Browse files
committed
test(ios): wait out the launch an accepted deep-link confirmation releases
The smoke automation scenario accepted SpringBoard's "Open in …?" prompt and then gave the route one 10 s wait. On a loaded host the released launch reached the foreground 20.7 s after the Open tap (CI run 35991523779; 27.4 s locally), so every read answered the retriable APP_NOT_RUNNING and the step failed although the app later rendered the deep-linked route. The deep-link confirmation helper moves to its own module and keys every step on the typed details.runnerErrorCode: only a destination wait that ends on APP_NOT_RUNNING probes for the confirmation or earns another wait, up to four 15 s waits. Any other miss returns to the caller's own destination assertion, and the alert probe never queries a rendered screen. Refs #2491
1 parent 7286206 commit 3c285bb

5 files changed

Lines changed: 203 additions & 45 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
4+
import type { CliJsonResult } from './cli-json.ts';
5+
import {
6+
answerDeepLinkConfirmation,
7+
type DeepLinkConfirmationDevice,
8+
} from './ios-simulator-e2e/live-deep-link-confirmation.ts';
9+
10+
function result(status: number, json?: unknown): CliJsonResult {
11+
return { json, status, stderr: '', stdout: '' };
12+
}
13+
14+
const LANDED = result(0, { success: true });
15+
const LAUNCH_PENDING = result(1, {
16+
error: {
17+
code: 'COMMAND_FAILED',
18+
details: { reason: 'wait_capture_stalled', runnerErrorCode: 'APP_NOT_RUNNING' },
19+
},
20+
});
21+
const WRONG_ROUTE = result(1, {
22+
error: { code: 'COMMAND_FAILED', details: { reason: 'wait_target_absent' } },
23+
});
24+
const OPEN_PROMPT = result(0, {
25+
data: { message: 'Open in “Agent Device Tester”?', items: ['Cancel', 'Open'] },
26+
});
27+
const NO_ALERT = result(1, { error: { code: 'COMMAND_FAILED' } });
28+
29+
/** A simulator whose destination waits and alert probes answer in the order given. */
30+
function simulator(destinationWaits: CliJsonResult[], alerts: CliJsonResult[] = [OPEN_PROMPT]) {
31+
const log: string[] = [];
32+
const device: DeepLinkConfirmationDevice = {
33+
waitForDestination: async (step) => {
34+
log.push(step);
35+
const next = destinationWaits.shift();
36+
assert.ok(next, `unexpected destination wait: ${step}`);
37+
return next;
38+
},
39+
inspectAlert: async () => {
40+
log.push('alert get');
41+
const next = alerts.shift();
42+
assert.ok(next, 'unexpected alert probe');
43+
return next;
44+
},
45+
acceptAlert: async () => {
46+
log.push('alert accept');
47+
},
48+
};
49+
return { device, log };
50+
}
51+
52+
const waits = (log: string[]) => log.filter((step) => step.startsWith('wait for')).length;
53+
54+
test('a destination that arrives never probes for the confirmation', async () => {
55+
const { device, log } = simulator([LANDED]);
56+
57+
await answerDeepLinkConfirmation(device);
58+
59+
assert.deepEqual(log, ['wait for the deep-link destination (1/4)']);
60+
});
61+
62+
test('the launch an accepted confirmation releases is waited for until it lands', async () => {
63+
// CI run 35991523779: the app reached the foreground 20.7 s after `alert accept` tapped Open.
64+
const { device, log } = simulator([LAUNCH_PENDING, LAUNCH_PENDING, LAUNCH_PENDING, LANDED]);
65+
66+
await answerDeepLinkConfirmation(device);
67+
68+
assert.deepEqual(log, [
69+
'wait for the deep-link destination (1/4)',
70+
'alert get',
71+
'alert accept',
72+
'wait for the deep-link destination (2/4)',
73+
'wait for the deep-link destination (3/4)',
74+
'wait for the deep-link destination (4/4)',
75+
]);
76+
});
77+
78+
test('a miss that is not a pending launch neither probes nor waits again', async () => {
79+
const { device, log } = simulator([WRONG_ROUTE]);
80+
81+
await answerDeepLinkConfirmation(device);
82+
83+
assert.deepEqual(log, ['wait for the deep-link destination (1/4)']);
84+
});
85+
86+
test('after the accept, a miss that is not a pending launch earns no further wait', async () => {
87+
const { device, log } = simulator([LAUNCH_PENDING, WRONG_ROUTE]);
88+
89+
await answerDeepLinkConfirmation(device);
90+
91+
assert.equal(waits(log), 2);
92+
});
93+
94+
test('a confirmation that appears late is still answered once', async () => {
95+
const { device, log } = simulator(
96+
[LAUNCH_PENDING, LAUNCH_PENDING, LAUNCH_PENDING, LANDED],
97+
[NO_ALERT, OPEN_PROMPT],
98+
);
99+
100+
await answerDeepLinkConfirmation(device);
101+
102+
assert.deepEqual(
103+
log.filter((step) => step.startsWith('alert')),
104+
['alert get', 'alert get', 'alert accept'],
105+
);
106+
});
107+
108+
test('the wait budget is bounded when the app never starts', async () => {
109+
const { device, log } = simulator(Array.from({ length: 5 }, () => LAUNCH_PENDING));
110+
111+
await answerDeepLinkConfirmation(device);
112+
113+
assert.equal(waits(log), 4);
114+
});
115+
116+
test('a prompt that is not the deep-link confirmation is never accepted', async () => {
117+
const { device, log } = simulator(
118+
[LAUNCH_PENDING],
119+
[result(0, { data: { message: 'Allow notifications?', items: ['Allow'] } })],
120+
);
121+
122+
await assert.rejects(answerDeepLinkConfirmation(device));
123+
assert.equal(log.includes('alert accept'), false);
124+
});

‎test/integration/ios-simulator-e2e/live-automation-scenario.ts‎

Lines changed: 1 addition & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,13 @@ import {
1111
assertJsonContains,
1212
assertWaitText,
1313
} from './live-assertions.ts';
14+
import { acceptDeepLinkConfirmationIfPresent } from './live-deep-link-confirmation.ts';
1415
import { clearStateLaunchUrlMaestroFlow } from './live-fixtures.ts';
1516
import { type LiveContext, runStep, verifyBehavior, verifyCommand } from './live-harness.ts';
1617

1718
const C = PUBLIC_COMMANDS;
1819
const ALERT_WAIT_TIMEOUT = String(DEFAULT_ALERT_TIMEOUT_MS);
1920
const FIXTURE_HOME_TITLE = 'Agent Device Tester';
20-
/**
21-
* Deliberately generous. This budget decides whether the alert probe below runs at all, so it must
22-
* outlast the slowest honest route mount on a cold CI simulator — the WebView lab took over 2.5 s
23-
* there while rendering correctly. Waiting longer costs nothing when a confirmation really is up,
24-
* because the route never renders until it is accepted; being too short costs the whole scenario.
25-
*/
26-
const DEEP_LINK_DESTINATION_WAIT_MS = '15000';
2721
/** The Automation lab's own first landmark; see `acceptDeepLinkConfirmationIfPresent`. */
2822
const AUTOMATION_LAB_LANDMARK = ['text', 'Automation lab'] as const;
2923
const AUTOMATION_DEEP_LINK =
@@ -253,42 +247,6 @@ async function assertClearStateLaunchUrl(context: LiveContext): Promise<void> {
253247
await assertElementText(context, 'id="automation-event-payload"', '{"source":"deep-link"}');
254248
}
255249

256-
/**
257-
* iOS sometimes puts an "Open in <app>?" confirmation in front of a custom-scheme deep link, so a
258-
* scenario that launched one must accept it before asserting anything. `destination` is the `wait`
259-
* predicate for the route's own first landmark, and it decides whether the alert probe runs at all:
260-
* a landmark that arrived proves no confirmation is in the way. Each caller passes its own, because
261-
* a shared landmark never matches off its route and sends every caller into the probe — and
262-
* `alert get` against a live WKWebView screen is the XCTest query that exceeds the runner's
263-
* execution watchdog, leaving every later command refused as `RUNNER_BUSY` (#2484 follow-up). The
264-
* landmark must therefore be a native node the route renders before its content, and the budget
265-
* above must outlast a cold mount, so the probe is reached only when something really is blocking.
266-
*/
267-
export async function acceptDeepLinkConfirmationIfPresent(
268-
context: LiveContext,
269-
destination: readonly string[],
270-
): Promise<void> {
271-
const arrived = await runStep(
272-
context,
273-
'wait for deep-link destination before inspecting system UI',
274-
['wait', ...destination, DEEP_LINK_DESTINATION_WAIT_MS],
275-
{ allowFailure: true },
276-
);
277-
if (arrived.status === 0) return;
278-
279-
const alert = await runStep(context, 'inspect delayed deep-link system alert', ['alert', 'get'], {
280-
allowFailure: true,
281-
});
282-
if (alert.status !== 0) return;
283-
const alertInfo = alert.json?.data;
284-
assert.match(String(alertInfo?.message), /^Open in\b/, JSON.stringify(alert.json));
285-
assert.ok(
286-
Array.isArray(alertInfo?.items) && alertInfo.items.includes('Open'),
287-
JSON.stringify(alert.json),
288-
);
289-
await runStep(context, 'accept deep-link confirmation', ['alert', 'accept']);
290-
}
291-
292250
async function openAutomationDeepLink(context: LiveContext, step: string): Promise<void> {
293251
await runStep(context, step, [
294252
'open',
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import assert from 'node:assert/strict';
2+
3+
import type { CliJsonResult } from '../cli-json.ts';
4+
import { type LiveContext, runStep } from './live-harness.ts';
5+
6+
/** One destination wait; generous because the WebView lab took over 2.5 s to mount on cold CI. */
7+
const DEEP_LINK_DESTINATION_WAIT_MS = '15000';
8+
/**
9+
* One wait finds the confirmation holding the launch; three more cover the launch its answer
10+
* releases. That launch usually reaches the foreground within 3 s of the tap, but a loaded host has
11+
* held it for 20.7 s (CI run 35991523779) and 27.4 s (a local run), and the route renders after that.
12+
*/
13+
const DESTINATION_WAITS = 4;
14+
/** `details.runnerErrorCode` of a read the runner refused because the session app is not running. */
15+
const APP_NOT_RUNNING = 'APP_NOT_RUNNING';
16+
17+
export type DeepLinkConfirmationDevice = {
18+
waitForDestination: (step: string) => Promise<CliJsonResult>;
19+
inspectAlert: () => Promise<CliJsonResult>;
20+
acceptAlert: () => Promise<unknown>;
21+
};
22+
23+
/**
24+
* iOS can hold a custom-scheme deep link behind an "Open in <app>?" confirmation. `destination` is
25+
* the `wait` predicate for the route's own first landmark, a native node the route renders before
26+
* its content.
27+
*/
28+
export function acceptDeepLinkConfirmationIfPresent(
29+
context: LiveContext,
30+
destination: readonly string[],
31+
): Promise<void> {
32+
return answerDeepLinkConfirmation({
33+
waitForDestination: (step) =>
34+
runStep(context, step, ['wait', ...destination, DEEP_LINK_DESTINATION_WAIT_MS], {
35+
allowFailure: true,
36+
}),
37+
inspectAlert: () =>
38+
runStep(context, 'inspect delayed deep-link system alert', ['alert', 'get'], {
39+
allowFailure: true,
40+
}),
41+
acceptAlert: () => runStep(context, 'accept deep-link confirmation', ['alert', 'accept']),
42+
});
43+
}
44+
45+
/**
46+
* Only a destination wait that ends on `APP_NOT_RUNNING` — the launch is still pending — leads to
47+
* the alert probe or to another wait. Any other outcome returns, so an arrived route skips the probe
48+
* and a wrong route or a stalled capture fails on the caller's own destination assertion. The probe
49+
* therefore never queries a rendered screen, where `alert get` against a live WKWebView exceeds the
50+
* runner's execution watchdog and leaves later commands refused as `RUNNER_BUSY` (#2484 follow-up).
51+
*/
52+
export async function answerDeepLinkConfirmation(
53+
device: DeepLinkConfirmationDevice,
54+
): Promise<void> {
55+
let answered = false;
56+
for (let wait = 1; wait <= DESTINATION_WAITS; wait += 1) {
57+
const arrived = await device.waitForDestination(
58+
`wait for the deep-link destination (${wait}/${DESTINATION_WAITS})`,
59+
);
60+
if (arrived.json?.error?.details?.runnerErrorCode !== APP_NOT_RUNNING) return;
61+
if (!answered) answered = await acceptOpenConfirmation(device);
62+
}
63+
}
64+
65+
async function acceptOpenConfirmation(device: DeepLinkConfirmationDevice): Promise<boolean> {
66+
const alert = await device.inspectAlert();
67+
if (alert.status !== 0) return false;
68+
const alertInfo = alert.json?.data;
69+
assert.match(String(alertInfo?.message), /^Open in\b/, JSON.stringify(alert.json));
70+
assert.ok(
71+
Array.isArray(alertInfo?.items) && alertInfo.items.includes('Open'),
72+
JSON.stringify(alert.json),
73+
);
74+
await device.acceptAlert();
75+
return true;
76+
}

‎test/integration/ios-simulator-e2e/live-snapshot-depth-frontier.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
type LiveSnapshotNode as SnapshotNode,
77
snapshotNodes,
88
} from './live-assertions.ts';
9-
import { acceptDeepLinkConfirmationIfPresent } from './live-automation-scenario.ts';
9+
import { acceptDeepLinkConfirmationIfPresent } from './live-deep-link-confirmation.ts';
1010
import { type LiveContext, runStep, verifyBehavior } from './live-harness.ts';
1111

1212
const VISIBLE_DEPTH_DEEP_LINK = 'agent-device-test-app:///snapshot-depth';

‎test/integration/ios-simulator-e2e/live-webview-remote-content.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import assert from 'node:assert/strict';
22

33
import { assertWaitText, snapshotNodes } from './live-assertions.ts';
4-
import { acceptDeepLinkConfirmationIfPresent } from './live-automation-scenario.ts';
4+
import { acceptDeepLinkConfirmationIfPresent } from './live-deep-link-confirmation.ts';
55
import { type LiveContext, runStep, verifyBehavior } from './live-harness.ts';
66

77
const WEBVIEW_LAB_DEEP_LINK = 'agent-device-test-app:///webview';

0 commit comments

Comments
 (0)