diff --git a/scripts/bootIosSimulator.ts b/scripts/bootIosSimulator.ts index 18557c4..322f46d 100644 --- a/scripts/bootIosSimulator.ts +++ b/scripts/bootIosSimulator.ts @@ -1,10 +1,11 @@ /** - * Boots the simulator that `npm run test:ios` will use, and waits for it to finish starting up. + * Boots the simulator that `npm run test:ios` will use, waits for it to finish starting up, and + * launches Safari on it. * - * Run as a step before the suite (see the `Test: iOS Simulator` job): a cold boot can take - * minutes, and Vitest's `browser.connectTimeout` is already counting down while the provider - * boots, so on a runner where every simulator is shut down the boot eats the whole budget and - * the run fails with "Failed to connect to the browser session within the timeout". + * Run as a step before the suite (see the `Test: iOS Simulator` job), because both of those costs + * are otherwise paid against a timeout that is already counting down. A cold boot takes minutes + * while Vitest's `browser.connectTimeout` runs, and Safari's first launch takes longer than + * `simctl openurl` waits for it. */ import {bootSimulator} from '../test/helpers/iosSimulator.ts' @@ -12,4 +13,6 @@ const started = Date.now() const device = await bootSimulator(process.env['IOS_SIMULATOR_DEVICE']) const seconds = ((Date.now() - started) / 1000).toFixed(1) -console.log(`Booted ${device.name}, ${device.runtime} (${device.udid}) in ${seconds}s`) +console.log( + `Booted ${device.name}, ${device.runtime} (${device.udid}) and started Safari in ${seconds}s`, +) diff --git a/test/helpers/iosSimulator.ts b/test/helpers/iosSimulator.ts index fbbce23..b189f0b 100644 --- a/test/helpers/iosSimulator.ts +++ b/test/helpers/iosSimulator.ts @@ -17,6 +17,17 @@ declare module 'vitest/node' { const SAFARI_BUNDLE_ID = 'com.apple.mobilesafari' +/** + * How many times to ask the simulator to open the tester URL. + * + * `simctl openurl` has to hand the URL to Safari and wait for it to accept it, and it gives up + * after about ten seconds - which a slow runner can exceed just launching the app, failing with + * `NSPOSIXErrorDomain code 60` ("Operation timed out") on a device that is perfectly healthy. + * Safari is launched up front to keep that cost out of the way, so a retry only has to cover the + * case where it still lost the race. + */ +const OPEN_URL_ATTEMPTS = 3 + export interface SimulatorDevice { udid: string name: string @@ -78,7 +89,24 @@ class IosSimulatorProvider implements BrowserProvider { this.project.vitest.logger.log( `Opening ${url} in Safari on ${device.name}, ${device.runtime} (${device.udid})`, ) - await exec('xcrun', ['simctl', 'openurl', device.udid, url]) + + for (let attempt = 1; ; attempt++) { + try { + await exec('xcrun', ['simctl', 'openurl', device.udid, url]) + return + } catch (err) { + if (attempt >= OPEN_URL_ATTEMPTS) throw err + this.project.vitest.logger.log( + `Opening it failed (attempt ${attempt} of ${OPEN_URL_ATTEMPTS}), restarting Safari and retrying: ${ + err instanceof Error ? err.message.split('\n')[0] : err + }`, + ) + // Restart Safari rather than just asking again: a timed-out `openurl` may still have + // navigated, and a second tester page would then connect under the same session id. + await terminateSafari(device.udid) + await launchSafari(device.udid) + } + } } async close(): Promise { @@ -86,7 +114,7 @@ class IosSimulatorProvider implements BrowserProvider { // Leave the simulator booted - booting costs the better part of a minute, and a developer // running the suite repeatedly should not pay it every time - but close Safari, so the next // run starts on a blank page instead of restoring the previous tester page. - await exec('xcrun', ['simctl', 'terminate', this.booted.udid, SAFARI_BUNDLE_ID]).catch(() => {}) + await terminateSafari(this.booted.udid) } private async boot(): Promise { @@ -121,9 +149,29 @@ export async function bootSimulator(name?: string | undefined): Promise { + // Best-effort: if Safari is already running this reports an error, and if the launch itself + // times out the retry in `openPage` is what covers it. Either way the run should carry on. + return exec('xcrun', ['simctl', 'launch', udid, SAFARI_BUNDLE_ID], {timeout: 120_000}).catch( + () => undefined, + ) +} + +function terminateSafari(udid: string): Promise { + // Reports an error when Safari is not running, which is not worth distinguishing here. + return exec('xcrun', ['simctl', 'terminate', udid, SAFARI_BUNDLE_ID], {timeout: 120_000}).catch( + () => undefined, + ) +} + /** * Flattens `simctl list devices --json` into iOS devices, newest runtime first. The JSON keys the * device lists by runtime identifier, eg `com.apple.CoreSimulator.SimRuntime.iOS-18-6`; anything