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
170 changes: 170 additions & 0 deletions test/integration/ios-simulator-e2e-deep-link-confirmation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import type { CliJsonResult } from './cli-json.ts';
import {
answerDeepLinkConfirmation,
type DeepLinkConfirmationDevice,
} from './ios-simulator-e2e/live-deep-link-confirmation.ts';

function result(status: number, json?: unknown): CliJsonResult {
return { json, status, stderr: '', stdout: '' };
}

const LANDED = result(0, { success: true });
const LAUNCH_PENDING = result(1, {
error: {
code: 'COMMAND_FAILED',
details: { reason: 'wait_capture_stalled', runnerErrorCode: 'APP_NOT_RUNNING' },
},
});
const WRONG_ROUTE = result(1, {
error: { code: 'COMMAND_FAILED', details: { reason: 'wait_target_absent' } },
});
const READABLE_TIMEOUT = result(1, {
error: {
code: 'COMMAND_FAILED',
details: { reason: 'wait_deadline_exceeded', readableCaptures: 5, captureTruncated: true },
},
});
const OPEN_PROMPT = result(0, {
data: { message: 'Open in “Agent Device Tester”?', items: ['Cancel', 'Open'] },
});
const NO_ALERT = result(1, { error: { code: 'COMMAND_FAILED' } });

/** A simulator whose destination waits and alert probes answer in the order given. */
function simulator(destinationWaits: CliJsonResult[], alerts: CliJsonResult[] = [OPEN_PROMPT]) {
const log: string[] = [];
const device: DeepLinkConfirmationDevice = {
waitForDestination: async (step) => {
log.push(step);
const next = destinationWaits.shift();
assert.ok(next, `unexpected destination wait: ${step}`);
return next;
},
inspectAlert: async () => {
log.push('alert get');
const next = alerts.shift();
assert.ok(next, 'unexpected alert probe');
return next;
},
acceptAlert: async () => {
log.push('alert accept');
},
};
return { device, log };
}

const waits = (log: string[]) => log.filter((step) => step.startsWith('wait for')).length;

test('a destination that arrives never probes for the confirmation', async () => {
const { device, log } = simulator([LANDED]);

await answerDeepLinkConfirmation(device);

assert.deepEqual(log, ['wait for the deep-link destination (1/5)']);
});

test('a readable destination timeout still answers a real Open confirmation', async () => {
const { device, log } = simulator([READABLE_TIMEOUT, LANDED]);

await answerDeepLinkConfirmation(device);

assert.deepEqual(log, [
'wait for the deep-link destination (1/5)',
'alert get',
'alert accept',
'wait for the deep-link destination (2/5)',
]);
});

test('a readable no-match that answers Open waits for the released launch', async () => {
const { device, log } = simulator([WRONG_ROUTE, LAUNCH_PENDING, LANDED]);

await answerDeepLinkConfirmation(device);

assert.deepEqual(log, [
'wait for the deep-link destination (1/5)',
'alert get',
'alert accept',
'wait for the deep-link destination (2/5)',
'wait for the deep-link destination (3/5)',
]);
});

test('a truncated capture retries for four bounded waits without accepting a missing alert', async () => {
const { device, log } = simulator(
[READABLE_TIMEOUT, READABLE_TIMEOUT, READABLE_TIMEOUT, READABLE_TIMEOUT, LANDED],
[NO_ALERT, NO_ALERT, NO_ALERT, NO_ALERT],
);

await answerDeepLinkConfirmation(device);

assert.equal(waits(log), 5);
assert.equal(log.filter((step) => step === 'alert get').length, 4);
assert.equal(log.includes('alert accept'), false);
});

test('the launch an accepted confirmation releases is waited for until it lands', async () => {
// CI run 35991523779: the app reached the foreground 20.7 s after `alert accept` tapped Open.
const { device, log } = simulator([LAUNCH_PENDING, LAUNCH_PENDING, LAUNCH_PENDING, LANDED]);

await answerDeepLinkConfirmation(device);

assert.deepEqual(log, [
'wait for the deep-link destination (1/5)',
'alert get',
'alert accept',
'wait for the deep-link destination (2/5)',
'wait for the deep-link destination (3/5)',
'wait for the deep-link destination (4/5)',
]);
});

test('a readable miss probes once and leaves a wrong route to the caller', async () => {
const { device, log } = simulator([WRONG_ROUTE], [NO_ALERT]);

await answerDeepLinkConfirmation(device);

assert.deepEqual(log, ['wait for the deep-link destination (1/5)', 'alert get']);
});

test('after the accept, a readable no-match still gets a bounded launch wait', async () => {
const { device, log } = simulator([LAUNCH_PENDING, WRONG_ROUTE, LANDED]);

await answerDeepLinkConfirmation(device);

assert.equal(waits(log), 3);
});

test('a confirmation that appears late is still answered once', async () => {
const { device, log } = simulator(
[LAUNCH_PENDING, LAUNCH_PENDING, LAUNCH_PENDING, LANDED],
[NO_ALERT, OPEN_PROMPT],
);

await answerDeepLinkConfirmation(device);

assert.deepEqual(
log.filter((step) => step.startsWith('alert')),
['alert get', 'alert get', 'alert accept'],
);
});

test('the wait budget is bounded when the app never starts', async () => {
const { device, log } = simulator(Array.from({ length: 5 }, () => LAUNCH_PENDING));

await answerDeepLinkConfirmation(device);

assert.equal(waits(log), 5);
});

test('a prompt that is not the deep-link confirmation is never accepted', async () => {
const { device, log } = simulator(
[LAUNCH_PENDING],
[result(0, { data: { message: 'Allow notifications?', items: ['Allow'] } })],
);

await assert.rejects(answerDeepLinkConfirmation(device));
assert.equal(log.includes('alert accept'), false);
});
44 changes: 1 addition & 43 deletions test/integration/ios-simulator-e2e/live-automation-scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,13 @@ import {
assertJsonContains,
assertWaitText,
} from './live-assertions.ts';
import { acceptDeepLinkConfirmationIfPresent } from './live-deep-link-confirmation.ts';
import { clearStateLaunchUrlMaestroFlow } from './live-fixtures.ts';
import { type LiveContext, runStep, verifyBehavior, verifyCommand } from './live-harness.ts';

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

/**
* iOS sometimes puts an "Open in <app>?" confirmation in front of a custom-scheme deep link, so a
* scenario that launched one must accept it before asserting anything. `destination` is the `wait`
* predicate for the route's own first landmark, and it decides whether the alert probe runs at all:
* a landmark that arrived proves no confirmation is in the way. Each caller passes its own, because
* a shared landmark never matches off its route and sends every caller into the probe — and
* `alert get` against a live WKWebView screen is the XCTest query that exceeds the runner's
* execution watchdog, leaving every later command refused as `RUNNER_BUSY` (#2484 follow-up). The
* landmark must therefore be a native node the route renders before its content, and the budget
* above must outlast a cold mount, so the probe is reached only when something really is blocking.
*/
export async function acceptDeepLinkConfirmationIfPresent(
context: LiveContext,
destination: readonly string[],
): Promise<void> {
const arrived = await runStep(
context,
'wait for deep-link destination before inspecting system UI',
['wait', ...destination, DEEP_LINK_DESTINATION_WAIT_MS],
{ allowFailure: true },
);
if (arrived.status === 0) return;

const alert = await runStep(context, 'inspect delayed deep-link system alert', ['alert', 'get'], {
allowFailure: true,
});
if (alert.status !== 0) return;
const alertInfo = alert.json?.data;
assert.match(String(alertInfo?.message), /^Open in\b/, JSON.stringify(alert.json));
assert.ok(
Array.isArray(alertInfo?.items) && alertInfo.items.includes('Open'),
JSON.stringify(alert.json),
);
await runStep(context, 'accept deep-link confirmation', ['alert', 'accept']);
}

async function openAutomationDeepLink(context: LiveContext, step: string): Promise<void> {
await runStep(context, step, [
'open',
Expand Down
82 changes: 82 additions & 0 deletions test/integration/ios-simulator-e2e/live-deep-link-confirmation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';

import type { CliJsonResult } from '../cli-json.ts';
import { type LiveContext, runStep } from './live-harness.ts';

/** One destination wait; generous because the WebView lab took over 2.5 s to mount on cold CI. */
const DEEP_LINK_DESTINATION_WAIT_MS = '15000';
/**
* One wait can find the confirmation holding the launch; four more cover its release or a stalled
* runner restart. The answered launch usually reaches the foreground within 3 s, but a loaded host
* held it for 20.7 s (CI run 35991523779) and 27.4 s (a local run).
*/
const DESTINATION_WAITS = 5;
/** `details.runnerErrorCode` of a read the runner refused because the session app is not running. */
const APP_NOT_RUNNING = 'APP_NOT_RUNNING';

export type DeepLinkConfirmationDevice = {
waitForDestination: (step: string) => Promise<CliJsonResult>;
inspectAlert: () => Promise<CliJsonResult>;
acceptAlert: () => Promise<unknown>;
};

/**
* iOS can hold a custom-scheme deep link behind an "Open in <app>?" confirmation. `destination` is
* the `wait` predicate for the route's own first landmark, a native node the route renders before
* its content.
*/
export function acceptDeepLinkConfirmationIfPresent(
context: LiveContext,
destination: readonly string[],
): Promise<void> {
return answerDeepLinkConfirmation({
waitForDestination: (step) =>
runStep(context, step, ['wait', ...destination, DEEP_LINK_DESTINATION_WAIT_MS], {
allowFailure: true,
}),
inspectAlert: () =>
runStep(context, 'inspect delayed deep-link system alert', ['alert', 'get'], {
allowFailure: true,
}),
acceptAlert: () => runStep(context, 'accept deep-link confirmation', ['alert', 'accept']),
});
}

/**
* A readable destination timeout can still leave the launch behind a system confirmation. Probe
* once per miss until it is answered; a truncated or stalled capture then gets another bounded
* wait, while a readable wrong-route miss without a prompt goes to the caller's assertion.
*/
export async function answerDeepLinkConfirmation(
device: DeepLinkConfirmationDevice,
): Promise<void> {
let answered = false;
for (let wait = 1; wait <= DESTINATION_WAITS; wait += 1) {
const arrived = await device.waitForDestination(
`wait for the deep-link destination (${wait}/${DESTINATION_WAITS})`,
);
if (arrived.status === 0) return;
const details = arrived.json?.error?.details;
const launchPending = details?.runnerErrorCode === APP_NOT_RUNNING;
const reason = details?.reason;
const readableMiss = reason === 'wait_target_absent' || reason === 'wait_deadline_exceeded';

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These literals duplicate the canonical wait vocabulary in packages/contracts/src/wait.ts. The helper is deciding routing from the same wait reasons that wait polling already classifies, so importing WAIT_REASONS would keep the spelling and the mapping from drifting when wait-polling changes.

const interruptedCapture =
reason === 'wait_capture_stalled' || reason === 'wait_runner_restart_exhausted';
if (!launchPending && !readableMiss && !interruptedCapture) return;
if (!answered) answered = await acceptOpenConfirmation(device);
if (!answered && reason === 'wait_target_absent') return;
}
}

async function acceptOpenConfirmation(device: DeepLinkConfirmationDevice): Promise<boolean> {
const alert = await device.inspectAlert();
if (alert.status !== 0) return false;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Treating every nonzero alert.status as "no prompt" conflates absence with other alert-get failures. Apple's no-alert case is a typed ALERT_NOT_FOUND failure, but busy, transport, and malformed failures also surface as nonzero. Distinguish the typed absence shape from other errors; the helper should not answer an Open-in probe as though it had just proved there is no system prompt.

const alertInfo = alert.json?.data;
assert.match(String(alertInfo?.message), /^Open in\b/, JSON.stringify(alert.json));
assert.ok(
Array.isArray(alertInfo?.items) && alertInfo.items.includes('Open'),
JSON.stringify(alert.json),
);
await device.acceptAlert();
return true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
type LiveSnapshotNode as SnapshotNode,
snapshotNodes,
} from './live-assertions.ts';
import { acceptDeepLinkConfirmationIfPresent } from './live-automation-scenario.ts';
import { acceptDeepLinkConfirmationIfPresent } from './live-deep-link-confirmation.ts';
import { type LiveContext, runStep, verifyBehavior } from './live-harness.ts';

const VISIBLE_DEPTH_DEEP_LINK = 'agent-device-test-app:///snapshot-depth';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';

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

const WEBVIEW_LAB_DEEP_LINK = 'agent-device-test-app:///webview';
Expand Down
Loading