Skip to content

Commit 86d719d

Browse files
authored
test(ios): wait out the launch an accepted deep-link confirmation releases (#2902)
* 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 * fix(ios-e2e): recover readable deep-link timeouts
1 parent bb5089a commit 86d719d

5 files changed

Lines changed: 255 additions & 45 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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 READABLE_TIMEOUT = result(1, {
25+
error: {
26+
code: 'COMMAND_FAILED',
27+
details: { reason: 'wait_deadline_exceeded', readableCaptures: 5, captureTruncated: true },
28+
},
29+
});
30+
const OPEN_PROMPT = result(0, {
31+
data: { message: 'Open in “Agent Device Tester”?', items: ['Cancel', 'Open'] },
32+
});
33+
const NO_ALERT = result(1, { error: { code: 'COMMAND_FAILED' } });
34+
35+
/** A simulator whose destination waits and alert probes answer in the order given. */
36+
function simulator(destinationWaits: CliJsonResult[], alerts: CliJsonResult[] = [OPEN_PROMPT]) {
37+
const log: string[] = [];
38+
const device: DeepLinkConfirmationDevice = {
39+
waitForDestination: async (step) => {
40+
log.push(step);
41+
const next = destinationWaits.shift();
42+
assert.ok(next, `unexpected destination wait: ${step}`);
43+
return next;
44+
},
45+
inspectAlert: async () => {
46+
log.push('alert get');
47+
const next = alerts.shift();
48+
assert.ok(next, 'unexpected alert probe');
49+
return next;
50+
},
51+
acceptAlert: async () => {
52+
log.push('alert accept');
53+
},
54+
};
55+
return { device, log };
56+
}
57+
58+
const waits = (log: string[]) => log.filter((step) => step.startsWith('wait for')).length;
59+
60+
test('a destination that arrives never probes for the confirmation', async () => {
61+
const { device, log } = simulator([LANDED]);
62+
63+
await answerDeepLinkConfirmation(device);
64+
65+
assert.deepEqual(log, ['wait for the deep-link destination (1/5)']);
66+
});
67+
68+
test('a readable destination timeout still answers a real Open confirmation', async () => {
69+
const { device, log } = simulator([READABLE_TIMEOUT, LANDED]);
70+
71+
await answerDeepLinkConfirmation(device);
72+
73+
assert.deepEqual(log, [
74+
'wait for the deep-link destination (1/5)',
75+
'alert get',
76+
'alert accept',
77+
'wait for the deep-link destination (2/5)',
78+
]);
79+
});
80+
81+
test('a readable no-match that answers Open waits for the released launch', async () => {
82+
const { device, log } = simulator([WRONG_ROUTE, LAUNCH_PENDING, LANDED]);
83+
84+
await answerDeepLinkConfirmation(device);
85+
86+
assert.deepEqual(log, [
87+
'wait for the deep-link destination (1/5)',
88+
'alert get',
89+
'alert accept',
90+
'wait for the deep-link destination (2/5)',
91+
'wait for the deep-link destination (3/5)',
92+
]);
93+
});
94+
95+
test('a truncated capture retries for four bounded waits without accepting a missing alert', async () => {
96+
const { device, log } = simulator(
97+
[READABLE_TIMEOUT, READABLE_TIMEOUT, READABLE_TIMEOUT, READABLE_TIMEOUT, LANDED],
98+
[NO_ALERT, NO_ALERT, NO_ALERT, NO_ALERT],
99+
);
100+
101+
await answerDeepLinkConfirmation(device);
102+
103+
assert.equal(waits(log), 5);
104+
assert.equal(log.filter((step) => step === 'alert get').length, 4);
105+
assert.equal(log.includes('alert accept'), false);
106+
});
107+
108+
test('the launch an accepted confirmation releases is waited for until it lands', async () => {
109+
// CI run 35991523779: the app reached the foreground 20.7 s after `alert accept` tapped Open.
110+
const { device, log } = simulator([LAUNCH_PENDING, LAUNCH_PENDING, LAUNCH_PENDING, LANDED]);
111+
112+
await answerDeepLinkConfirmation(device);
113+
114+
assert.deepEqual(log, [
115+
'wait for the deep-link destination (1/5)',
116+
'alert get',
117+
'alert accept',
118+
'wait for the deep-link destination (2/5)',
119+
'wait for the deep-link destination (3/5)',
120+
'wait for the deep-link destination (4/5)',
121+
]);
122+
});
123+
124+
test('a readable miss probes once and leaves a wrong route to the caller', async () => {
125+
const { device, log } = simulator([WRONG_ROUTE], [NO_ALERT]);
126+
127+
await answerDeepLinkConfirmation(device);
128+
129+
assert.deepEqual(log, ['wait for the deep-link destination (1/5)', 'alert get']);
130+
});
131+
132+
test('after the accept, a readable no-match still gets a bounded launch wait', async () => {
133+
const { device, log } = simulator([LAUNCH_PENDING, WRONG_ROUTE, LANDED]);
134+
135+
await answerDeepLinkConfirmation(device);
136+
137+
assert.equal(waits(log), 3);
138+
});
139+
140+
test('a confirmation that appears late is still answered once', async () => {
141+
const { device, log } = simulator(
142+
[LAUNCH_PENDING, LAUNCH_PENDING, LAUNCH_PENDING, LANDED],
143+
[NO_ALERT, OPEN_PROMPT],
144+
);
145+
146+
await answerDeepLinkConfirmation(device);
147+
148+
assert.deepEqual(
149+
log.filter((step) => step.startsWith('alert')),
150+
['alert get', 'alert get', 'alert accept'],
151+
);
152+
});
153+
154+
test('the wait budget is bounded when the app never starts', async () => {
155+
const { device, log } = simulator(Array.from({ length: 5 }, () => LAUNCH_PENDING));
156+
157+
await answerDeepLinkConfirmation(device);
158+
159+
assert.equal(waits(log), 5);
160+
});
161+
162+
test('a prompt that is not the deep-link confirmation is never accepted', async () => {
163+
const { device, log } = simulator(
164+
[LAUNCH_PENDING],
165+
[result(0, { data: { message: 'Allow notifications?', items: ['Allow'] } })],
166+
);
167+
168+
await assert.rejects(answerDeepLinkConfirmation(device));
169+
assert.equal(log.includes('alert accept'), false);
170+
});

‎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: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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 can find the confirmation holding the launch; four more cover its release or a stalled
10+
* runner restart. The answered launch usually reaches the foreground within 3 s, but a loaded host
11+
* held it for 20.7 s (CI run 35991523779) and 27.4 s (a local run).
12+
*/
13+
const DESTINATION_WAITS = 5;
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+
* A readable destination timeout can still leave the launch behind a system confirmation. Probe
47+
* once per miss until it is answered; a truncated or stalled capture then gets another bounded
48+
* wait, while a readable wrong-route miss without a prompt goes to the caller's assertion.
49+
*/
50+
export async function answerDeepLinkConfirmation(
51+
device: DeepLinkConfirmationDevice,
52+
): Promise<void> {
53+
let answered = false;
54+
for (let wait = 1; wait <= DESTINATION_WAITS; wait += 1) {
55+
const arrived = await device.waitForDestination(
56+
`wait for the deep-link destination (${wait}/${DESTINATION_WAITS})`,
57+
);
58+
if (arrived.status === 0) return;
59+
const details = arrived.json?.error?.details;
60+
const launchPending = details?.runnerErrorCode === APP_NOT_RUNNING;
61+
const reason = details?.reason;
62+
const readableMiss = reason === 'wait_target_absent' || reason === 'wait_deadline_exceeded';
63+
const interruptedCapture =
64+
reason === 'wait_capture_stalled' || reason === 'wait_runner_restart_exhausted';
65+
if (!launchPending && !readableMiss && !interruptedCapture) return;
66+
if (!answered) answered = await acceptOpenConfirmation(device);
67+
if (!answered && reason === 'wait_target_absent') return;
68+
}
69+
}
70+
71+
async function acceptOpenConfirmation(device: DeepLinkConfirmationDevice): Promise<boolean> {
72+
const alert = await device.inspectAlert();
73+
if (alert.status !== 0) return false;
74+
const alertInfo = alert.json?.data;
75+
assert.match(String(alertInfo?.message), /^Open in\b/, JSON.stringify(alert.json));
76+
assert.ok(
77+
Array.isArray(alertInfo?.items) && alertInfo.items.includes('Open'),
78+
JSON.stringify(alert.json),
79+
);
80+
await device.acceptAlert();
81+
return true;
82+
}

‎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)