ReviewOS

also looking at this

craft-native/craft

fix(mobile-e2e): launch iOS apps without a pty, so a cold simulator cannot eat the budget

#212
Merged glennmichael123 wants to merge fix/mobile-e2e-ios-launch into main
5 files +74 -23
scripts/mobile-e2e/ios.tsmodified+39-16
Changes to scripts/mobile-e2e/ios.ts
@@ -1,5 +1,5 @@
11import type { LegOutcome, RunnerOptions } from './types'
2import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
33import { join } from 'node:path'
44import { bootSimulator, init, pickSimulator } from '../../packages/ios/src/index'
55import { evaluateRun, hasTerminated, ZIG_REFUSED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigRefusals } from './protocol'
@@ -120,30 +120,53 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
120120 // this run never wrote.
121121 await command(['xcrun', 'simctl', 'pbcopy', device.udid], { stdin: 'craft-e2e-pasteboard-cleared', allowFailure: true })
122122
123 // The app's own stdout and stderr, straight to a file. `--console-pty` is
124 // what makes Swift's `print` - and therefore the page's `craft.log` - leave
125 // the device at all; without it the launch returns a pid and says nothing.
126 // A raw descriptor rather than a pipe, so the writing outlives this process
127 // reading it and the poll below sees the file grow.
123 // The app's stdout and stderr, each to its own file, read back while it
124 // runs. Swift's `print` carries the page's `craft.log`; Zig's logger writes
125 // to stderr.
126 //
127 // Not `simctl launch --console-pty`, which is what this was. On a freshly
128 // booted simulator the first pty launch took about 200 seconds to start the
129 // app, measured on a new device, against 10 for a plain launch. The clock
130 // below used to start at that spawn, so the shim leg, always the first
131 // launch after boot, could spend its whole budget waiting for the app to
132 // exist and fail with "the test page never announced a plan". It did, on
133 // main (run 35091847509) and on #208 and #210. The runtime leg, second on
134 // the same device, never did.
135 //
136 // `NSUnbufferedIO=YES` is what makes plain files usable. A pty is line
137 // buffered and a file is not: without the variable, `print` wrote nothing
138 // at all, not even once the app was terminated. Xcode sets it for the same
139 // reason. `SIMCTL_CHILD_` is how simctl hands a variable to the app.
140 //
141 // And the launch now fails on its own terms. `simctl launch` returns once
142 // the app has a pid, so a launch that cannot happen is an exception naming
143 // simctl's error, not a timeout blamed on the page.
144 const stdoutPath = join(evidence, 'app-stdout.log')
145 const stderrPath = join(evidence, 'app-stderr.log')
128146 const consolePath = join(evidence, 'console.log')
129 const consoleFd = openSync(consolePath, 'w')
130 const launched = Bun.spawn(['xcrun', 'simctl', 'launch', '--console-pty', device.udid, BUNDLE_ID], {
131 stdout: consoleFd,
132 stderr: consoleFd,
133 })
147 const readConsole = () => [stdoutPath, stderrPath]
148 .map(path => existsSync(path) ? readFileSync(path, 'utf8') : '')
149 .join('')
134150
135 // The suite is quick once the app is up, but a cold simulator is not, so
136 // poll for the terminator rather than sleeping a fixed amount.
151 await command([
152 'xcrun', 'simctl', 'launch', '--terminate-running-process',
153 `--stdout=${stdoutPath}`, `--stderr=${stderrPath}`,
154 device.udid, BUNDLE_ID,
155 ], { env: { SIMCTL_CHILD_NSUnbufferedIO: 'YES' }, logPath: join(evidence, 'simctl.log') })
156
157 // The suite is quick once the app is up, so poll for the terminator rather
158 // than sleeping a fixed amount.
137159 //
138160 // `hasTerminated` parses rather than matching a substring, because the very
139161 // next statement kills the writer: a poll landing mid-write of the final
140162 // line would otherwise leave a truncated event in the file and turn a
141163 // passing suite into "driver emitted an unparseable event".
142 const finished = await waitForFile(consolePath, options.timeoutMs, hasTerminated)
164 const finished = await waitForFile(consolePath, options.timeoutMs, hasTerminated, async () => {
165 writeFileSync(consolePath, readConsole())
166 })
143167
144 launched.kill()
145 closeSync(consoleFd)
146168 await command(['xcrun', 'simctl', 'terminate', device.udid, BUNDLE_ID], { allowFailure: true })
169 writeFileSync(consolePath, readConsole())
147170
148171 // Evidence before assertions, always: a leg that dies here is the one whose
149172 // screenshot is worth the most.