-
-
Notifications
You must be signed in to change notification settings - Fork 313
test(ios): wait out the launch an accepted deep-link confirmation releases #2902
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| }); |
| 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'; | ||
| 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; | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
There was a problem hiding this comment.
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.