also looking at this
test(mobile-e2e): prove iOS location settles, and that Zig served it
#213
6 files
+157
-28
| @@ -2,28 +2,42 @@ import type { LegOutcome, RunnerOptions } from './types' | ||
| 2 | 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 | import { evaluateRun, hasTerminated, ZIG_REFUSED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigRefusals } from './protocol' | |
| 5 | import { evaluateRun, hasTerminated, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol' | |
| 6 | 6 | import { command, driverPage, waitForFile } from './support' |
| 7 | 7 | |
| 8 | 8 | /** |
| 9 | 9 | * The app the suite runs against. |
| 10 | 10 | * |
| 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. | |
| 17 | 22 | * |
| 18 | 23 | * `enableShare` is spelled out although false is the default, because the |
| 19 | 24 | * share case depends on it and a default is not something this file controls. |
| 20 | 25 | */ |
| 21 | 26 | const CONFIG = { |
| 22 | 27 | enableClipboard: true, |
| 23 | enableGeolocation: false, | |
| 28 | enableGeolocation: true, | |
| 24 | 29 | enableShare: false, |
| 25 | 30 | } |
| 26 | 31 | |
| 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 | */ | |
| 38 | const SIMULATED_POSITION = { latitude: 51.5007, longitude: -0.1246 } | |
| 39 | const POSITION_TOLERANCE = 0.001 | |
| 40 | ||
| 27 | 41 | const BUNDLE_ID = 'dev.craft.e2e.probe' |
| 28 | 42 | const APP_NAME = 'CraftE2EProbe' |
| 29 | 43 | |
| @@ -115,6 +129,14 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | ||
| 115 | 129 | await command(['xcrun', 'simctl', 'uninstall', device.udid, BUNDLE_ID], { allowFailure: true }) |
| 116 | 130 | await command(['xcrun', 'simctl', 'install', device.udid, app]) |
| 117 | 131 | |
| 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 | ||
| 118 | 140 | // Clear the pasteboard first. Without this a nonce left by the previous leg |
| 119 | 141 | // would still be there, and the external check below would pass on a value |
| 120 | 142 | // this run never wrote. |
| @@ -187,8 +209,25 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | ||
| 187 | 209 | if (!pasteboard.stdout.includes(nonce)) |
| 188 | 210 | failures.push(`the simulator pasteboard does not hold the nonce this run wrote; it holds ${JSON.stringify(pasteboard.stdout.trim().slice(0, 80))}`) |
| 189 | 211 | |
| 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 | ||
| 190 | 228 | const dispatched = zigDispatchedActions(consoleText) |
| 191 | 229 | const refused = zigRefusals(consoleText) |
| 230 | const handedBack = zigHandBacks(consoleText) | |
| 192 | 231 | |
| 193 | 232 | if (leg.requireZigDispatch) { |
| 194 | 233 | const missing = ZIG_TESTED_ACTIONS.filter(action => !dispatched.includes(action)) |
| @@ -201,12 +240,20 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | ||
| 201 | 240 | // saw came off Zig's error route. |
| 202 | 241 | for (const action of ZIG_REFUSED_ACTIONS.filter(action => !refused.includes(action))) |
| 203 | 242 | 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`) | |
| 204 | 249 | } |
| 205 | 250 | else { |
| 206 | 251 | if (dispatched.length) |
| 207 | 252 | failures.push(`the shim leg reached the Zig dispatcher for ${dispatched.join(', ')}; it was supposed to link no runtime`) |
| 208 | 253 | if (refused.length) |
| 209 | 254 | 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`) | |
| 210 | 257 | } |
| 211 | 258 | |
| 212 | 259 | return { |