ReviewOS

also looking at this

craft-native/craft

test(mobile-e2e): prove iOS location settles, and that Zig served it

#213
Merged glennmichael123 wants to merge fix/ios-location-settles into main
6 files +157 -28
scripts/mobile-e2e/ios.tsmodified+55-8
Changes to scripts/mobile-e2e/ios.ts
@@ -2,28 +2,42 @@ import type { LegOutcome, RunnerOptions } from './types'
22import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
33import { join } from 'node:path'
44import { bootSimulator, init, pickSimulator } from '../../packages/ios/src/index'
5import { evaluateRun, hasTerminated, ZIG_REFUSED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigRefusals } from './protocol'
5import { evaluateRun, hasTerminated, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol'
66import { command, driverPage, waitForFile } from './support'
77
88/**
99 * The app the suite runs against.
1010 *
11 * Clipboard on, geolocation and sharing off, and that pairing is the whole
12 * point: the success case needs a capability that is enabled and reachable
13 * without a permission prompt, and the rejection cases need ones that are
14 * switched off so the refusal is a property of the configuration rather than
15 * of the machine. Reading a pasteboard the app itself just wrote raises no iOS
16 * paste prompt, which is what makes the round trip scriptable.
11 * Clipboard and geolocation on, sharing off. The success cases need
12 * capabilities that are enabled and reachable without a prompt, and the
13 * rejection case needs one that is switched off so the refusal is a property
14 * of the configuration rather than of the machine. Reading a pasteboard the
15 * app itself just wrote raises no iOS paste prompt, and location is granted
16 * from the host with `simctl privacy` before launch, so nothing waits on a
17 * person.
18 *
19 * Geolocation used to be the switched-off capability. It is on because #197
20 * was a location promise that never settled with location *enabled*, and a
21 * disabled capability cannot reach that code. Sharing took over the rejection.
1722 *
1823 * `enableShare` is spelled out although false is the default, because the
1924 * share case depends on it and a default is not something this file controls.
2025 */
2126const CONFIG = {
2227 enableClipboard: true,
23 enableGeolocation: false,
28 enableGeolocation: true,
2429 enableShare: false,
2530}
2631
32/**
33 * Where the simulator is told it is, for the page to report back.
34 *
35 * Nowhere near the simulator's default, which is Apple Park, so a position
36 * served from a cache or a stale scenario cannot pass for this one.
37 */
38const SIMULATED_POSITION = { latitude: 51.5007, longitude: -0.1246 }
39const POSITION_TOLERANCE = 0.001
40
2741const BUNDLE_ID = 'dev.craft.e2e.probe'
2842const APP_NAME = 'CraftE2EProbe'
2943
@@ -115,6 +129,14 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
115129 await command(['xcrun', 'simctl', 'uninstall', device.udid, BUNDLE_ID], { allowFailure: true })
116130 await command(['xcrun', 'simctl', 'install', device.udid, app])
117131
132 // Location answered from the host, before launch: permission granted so no
133 // prompt waits on a person, and a coordinate the page cannot know. Not
134 // best-effort. Without either, the location cases fail for a reason that
135 // has nothing to do with the bridge.
136 const coordinate = `${SIMULATED_POSITION.latitude},${SIMULATED_POSITION.longitude}`
137 await command(['xcrun', 'simctl', 'privacy', device.udid, 'grant', 'location', BUNDLE_ID], { logPath: join(evidence, 'simctl.log') })
138 await command(['xcrun', 'simctl', 'location', device.udid, 'set', coordinate], { logPath: join(evidence, 'simctl.log') })
139
118140 // Clear the pasteboard first. Without this a nonce left by the previous leg
119141 // would still be there, and the external check below would pass on a value
120142 // this run never wrote.
@@ -187,8 +209,25 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
187209 if (!pasteboard.stdout.includes(nonce))
188210 failures.push(`the simulator pasteboard does not hold the nonce this run wrote; it holds ${JSON.stringify(pasteboard.stdout.trim().slice(0, 80))}`)
189211
212 // The position the page reported against the one the host set. Resolving
213 // is not enough: a bridge that answered with a cached or default fix would
214 // resolve too.
215 const reported = (name: string) => consoleText.match(new RegExp(`"event":"observed","name":"${name}","value":(-?[\\d.]+)`))?.[1]
216 const latitude = Number(reported('latitude'))
217 const longitude = Number(reported('longitude'))
218 if (verdict.planned.includes('geolocation.currentPosition')) {
219 if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
220 failures.push('the page never reported the position getCurrentPosition gave it')
221 }
222 else if (Math.abs(latitude - SIMULATED_POSITION.latitude) > POSITION_TOLERANCE
223 || Math.abs(longitude - SIMULATED_POSITION.longitude) > POSITION_TOLERANCE) {
224 failures.push(`getCurrentPosition answered ${latitude},${longitude} but the simulator was set to ${SIMULATED_POSITION.latitude},${SIMULATED_POSITION.longitude}`)
225 }
226 }
227
190228 const dispatched = zigDispatchedActions(consoleText)
191229 const refused = zigRefusals(consoleText)
230 const handedBack = zigHandBacks(consoleText)
192231
193232 if (leg.requireZigDispatch) {
194233 const missing = ZIG_TESTED_ACTIONS.filter(action => !dispatched.includes(action))
@@ -201,12 +240,20 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
201240 // saw came off Zig's error route.
202241 for (const action of ZIG_REFUSED_ACTIONS.filter(action => !refused.includes(action)))
203242 failures.push(`Zig never refused ${action}; the rejection the page saw came from Swift, not from the Zig gate`)
243
244 // Offered is not served. Zig logs the dispatch line on entry and then
245 // hands an action it does not own back to Swift, which answers with the
246 // same shape, so a case passing says nothing about which side answered.
247 for (const action of ZIG_SERVED_ACTIONS.filter(action => handedBack.includes(action)))
248 failures.push(`Zig handed ${action} back to Swift; the runtime leg expects Zig to serve it`)
204249 }
205250 else {
206251 if (dispatched.length)
207252 failures.push(`the shim leg reached the Zig dispatcher for ${dispatched.join(', ')}; it was supposed to link no runtime`)
208253 if (refused.length)
209254 failures.push(`the shim leg saw Zig refuse ${refused.join(', ')}; it was supposed to link no runtime`)
255 if (handedBack.length)
256 failures.push(`the shim leg saw Zig hand back ${handedBack.join(', ')}; it was supposed to link no runtime`)
210257 }
211258
212259 return {