also looking at this
fix(mobile-e2e): launch iOS apps without a pty, so a cold simulator cannot eat the budget
#212
5 files
+74
-23
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import type { LegOutcome, RunnerOptions } from './types' |
| 2 | import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 2 | import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | import { bootSimulator, init, pickSimulator } from '../../packages/ios/src/index' |
| 5 | 5 | import { 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> { | ||
| 120 | 120 | // this run never wrote. |
| 121 | 121 | await command(['xcrun', 'simctl', 'pbcopy', device.udid], { stdin: 'craft-e2e-pasteboard-cleared', allowFailure: true }) |
| 122 | 122 | |
| 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') | |
| 128 | 146 | 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('') | |
| 134 | 150 | |
| 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. | |
| 137 | 159 | // |
| 138 | 160 | // `hasTerminated` parses rather than matching a substring, because the very |
| 139 | 161 | // next statement kills the writer: a poll landing mid-write of the final |
| 140 | 162 | // line would otherwise leave a truncated event in the file and turn a |
| 141 | 163 | // 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 | }) | |
| 143 | 167 | |
| 144 | launched.kill() | |
| 145 | closeSync(consoleFd) | |
| 146 | 168 | await command(['xcrun', 'simctl', 'terminate', device.udid, BUNDLE_ID], { allowFailure: true }) |
| 169 | writeFileSync(consolePath, readConsole()) | |
| 147 | 170 | |
| 148 | 171 | // Evidence before assertions, always: a leg that dies here is the one whose |
| 149 | 172 | // screenshot is worth the most. |