diff --git a/test/integration/ios-simulator-e2e-deep-link-confirmation.test.ts b/test/integration/ios-simulator-e2e-deep-link-confirmation.test.ts index e42b03476f..abff021f87 100644 --- a/test/integration/ios-simulator-e2e-deep-link-confirmation.test.ts +++ b/test/integration/ios-simulator-e2e-deep-link-confirmation.test.ts @@ -112,12 +112,29 @@ test('target discovery exhaustion still answers a deep-link confirmation', async ]); }); -test('runner startup exhaustion returns without probing for a deep-link confirmation', async () => { - const { device, log } = simulator([RUNNER_START_TIMEOUT]); +test('runner startup exhaustion waits once more, then answers the confirmation the runner sees', async () => { + const { device, log } = simulator([RUNNER_START_TIMEOUT, LAUNCH_PENDING, LANDED]); await answerDeepLinkConfirmation(device); - assert.deepEqual(log, ['wait for the deep-link destination (1/5)']); + assert.deepEqual(log, [ + 'wait for the deep-link destination (1/5)', + 'wait for the deep-link destination (2/5)', + 'alert get', + 'alert accept', + 'wait for the deep-link destination (3/5)', + ]); +}); + +test('a second runner startup exhaustion returns without probing for a deep-link confirmation', async () => { + const { device, log } = simulator([RUNNER_START_TIMEOUT, RUNNER_START_TIMEOUT]); + + await answerDeepLinkConfirmation(device); + + assert.deepEqual(log, [ + 'wait for the deep-link destination (1/5)', + 'wait for the deep-link destination (2/5)', + ]); }); test('a readable no-match that answers Open waits for the released launch', async () => { diff --git a/test/integration/ios-simulator-e2e/live-deep-link-confirmation.ts b/test/integration/ios-simulator-e2e/live-deep-link-confirmation.ts index e167068d02..8267d1ab15 100644 --- a/test/integration/ios-simulator-e2e/live-deep-link-confirmation.ts +++ b/test/integration/ios-simulator-e2e/live-deep-link-confirmation.ts @@ -61,27 +61,60 @@ export async function answerDeepLinkConfirmation( device: DeepLinkConfirmationDevice, ): Promise { let answered = false; + let runnerStartWaited = 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_REASONS.targetAbsent || reason === WAIT_REASONS.deadlineExceeded; - const interruptedObservation = - reason === WAIT_REASONS.captureStalled || - (reason === WAIT_REASONS.readinessExhausted && - details?.readinessPhase === 'target-discovery') || - reason === WAIT_REASONS.runnerRestartExhausted; - if (!launchPending && !readableMiss && !interruptedObservation) return; + const miss = classifyDestinationMiss(arrived.json?.error?.details); + // A relaunch can restart the runner, and on a loaded host its start outlasts one wait; the + // next wait is the first that can see the confirmation. A second start timeout is a failure. + if (miss === 'runner-start' && !runnerStartWaited) { + runnerStartWaited = true; + continue; + } + if (miss === 'final' || miss === 'runner-start') return; if (!answered) answered = await acceptOpenConfirmation(device); - if (!answered && reason === WAIT_REASONS.targetAbsent) return; + if (!answered && miss === 'target-absent') return; } } +/** Why a destination wait missed, as far as the confirmation loop acts on it. */ +type DestinationMiss = 'probe' | 'target-absent' | 'runner-start' | 'final'; + +type DestinationMissDetails = { + reason?: string; + readinessPhase?: string; + runnerErrorCode?: string; +}; + +/** Wait failures that can leave the launch behind the confirmation: worth one `alert get`. */ +const PROBE_REASONS: ReadonlySet = new Set([ + WAIT_REASONS.deadlineExceeded, + WAIT_REASONS.captureStalled, + WAIT_REASONS.runnerRestartExhausted, +]); + +/** + * Sorts a destination wait's failure: a pending launch, a readable miss, or an interrupted + * observation may hide the confirmation (`probe`, or `target-absent` for a readable no-match); + * a runner still starting saw nothing yet; anything else is the caller's to assert. + */ +function classifyDestinationMiss(details: DestinationMissDetails | undefined): DestinationMiss { + const readinessPhase = readinessExhaustedPhase(details); + if (readinessPhase === 'runner-start') return 'runner-start'; + if (details?.reason === WAIT_REASONS.targetAbsent) return 'target-absent'; + if (details?.runnerErrorCode === APP_NOT_RUNNING) return 'probe'; + if (PROBE_REASONS.has(details?.reason) || readinessPhase === 'target-discovery') return 'probe'; + return 'final'; +} + +/** The readiness phase a wait ran out of time in, when readiness is what it exhausted. */ +function readinessExhaustedPhase(details: DestinationMissDetails | undefined): string | undefined { + return details?.reason === WAIT_REASONS.readinessExhausted ? details.readinessPhase : undefined; +} + async function acceptOpenConfirmation(device: DeepLinkConfirmationDevice): Promise { const alert = await device.inspectAlert(); if (alert.status !== 0) return false;