also looking at this
test(mobile-e2e): prove iOS location settles, and that Zig served it
#213
6 files
+157
-28
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -30,10 +30,17 @@ bun run test:mobile-e2e:ios | ||
| 30 | 30 | ``` |
| 31 | 31 | |
| 32 | 32 | ``` |
| 33 | ok ios-shim — 5/5 cases, no zig | |
| 34 | ok ios-runtime — 5/5 cases, zig served clipboardRead, clipboardWrite, getCurrentPosition, getDeviceInfo, log, share | |
| 33 | ok ios-shim — 6/6 cases, no zig | |
| 34 | ok ios-runtime — 6/6 cases, zig served checkPermission, clipboardRead, clipboardWrite, getCurrentPosition, getDeviceInfo, log, requestPermission, share | |
| 35 | 35 | ``` |
| 36 | 36 | |
| 37 | Before launch the harness grants the app location permission with | |
| 38 | `simctl privacy` and puts the simulator at a fixed coordinate with | |
| 39 | `simctl location`. The page must report that coordinate back from | |
| 40 | `getCurrentPosition`. On the runtime leg, Zig must also serve every action | |
| 41 | under test itself: its log names each action it hands back to Swift, and only | |
| 42 | `requestPermission`, whose location answer Swift owns, may appear there. | |
| 43 | ||
| 37 | 44 | Android needs JDK 17, Gradle 8.11.1, an Android SDK with `platforms;android-36` |
| 38 | 45 | and `build-tools;36.0.0`, a booted emulator or attached device, and the Zig |
| 39 | 46 | Android libraries — it runs twice as well, once with `libcraft.so` packaged |
| @@ -588,7 +588,15 @@ export fn craft_ios_handle_action( | ||
| 588 | 588 | // is a served action with an unhappy result, not an unserved one. |
| 589 | 589 | return true; |
| 590 | 590 | }; |
| 591 | return offer == .answered; | |
| 591 | if (offer == .answered) return true; | |
| 592 | ||
| 593 | // Said out loud for the same reason the dispatch line is. The page gets | |
| 594 | // the same answer from Swift and cannot tell, so without this an action | |
| 595 | // Zig has quietly stopped serving looks exactly like one it serves - the | |
| 596 | // dispatch line above is logged either way. The mobile E2E runtime leg | |
| 597 | // fails when an action it expects Zig to serve shows up here. | |
| 598 | std.log.info("ios: {s} is not served here; handing it back to the host", .{action}); | |
| 599 | return false; | |
| 592 | 600 | } |
| 593 | 601 | |
| 594 | 602 | /// Deliver a result the host shim produced. |
| @@ -166,18 +166,43 @@ | ||
| 166 | 166 | } |
| 167 | 167 | }, |
| 168 | 168 | { |
| 169 | // The failure path. Geolocation is off in the harness config, so the | |
| 170 | // native side must answer — with a code that names the configuration — | |
| 171 | // rather than leave the promise pending. A dropped callback here is | |
| 172 | // indistinguishable from a slow one to the page, which is exactly why | |
| 173 | // the case asserts the rejection and not merely "did not resolve". | |
| 174 | name: 'geolocation.disabled.rejects', | |
| 169 | // The second half of #197: asking for location permission the app | |
| 170 | // already holds used to wait for an authorization change that never | |
| 171 | // comes, because nothing changes. The harness grants it with | |
| 172 | // `simctl privacy` before launch, so both calls must answer at once. | |
| 173 | name: 'permissions.location.granted', | |
| 175 | 174 | platforms: ['ios'], |
| 176 | 175 | run: function () { |
| 177 | return withTimeout(function () { return window.craft.getCurrentPosition() }).then(function (outcome) { | |
| 178 | if (outcome.state === 'timeout') return 'getCurrentPosition never settled' | |
| 179 | if (outcome.state === 'resolved') return 'getCurrentPosition resolved on a disabled capability' | |
| 180 | if (outcome.error.code !== 'CAPABILITY_DISABLED') return 'code is ' + JSON.stringify(outcome.error.code) | |
| 176 | return withTimeout(function () { return window.craft.permissions.check('location') }).then(function (checked) { | |
| 177 | if (checked.state !== 'resolved') return 'permissions.check ' + checked.state + ' ' + (checked.error ? checked.error.message : '') | |
| 178 | if (checked.value !== 'granted') return 'permissions.check answered ' + JSON.stringify(checked.value) | |
| 179 | return withTimeout(function () { return window.craft.permissions.request('location') }).then(function (requested) { | |
| 180 | if (requested.state === 'timeout') return 'permissions.request never settled for a permission already granted' | |
| 181 | if (requested.state !== 'resolved') return 'permissions.request rejected: ' + requested.error.message | |
| 182 | if (requested.value !== 'granted') return 'permissions.request answered ' + JSON.stringify(requested.value) | |
| 183 | }) | |
| 184 | }) | |
| 185 | } | |
| 186 | }, | |
| 187 | { | |
| 188 | // #197 as reported: high accuracy, no cached fix, a fifteen-second | |
| 189 | // timeout, on a simulator with an explicit coordinate. It never | |
| 190 | // settled. The harness sets the coordinate with `simctl location` | |
| 191 | // and compares it with what the page reports, so a position the | |
| 192 | // bridge invented would not match. | |
| 193 | name: 'geolocation.currentPosition', | |
| 194 | platforms: ['ios'], | |
| 195 | run: function () { | |
| 196 | var options = { enableHighAccuracy: true, maximumAge: 0, timeout: 15000 } | |
| 197 | return withTimeout(function () { return window.craft.location.getCurrentPosition(options) }, 20000).then(function (outcome) { | |
| 198 | if (outcome.state === 'timeout') return 'getCurrentPosition never settled, not even by its own 15s timeout' | |
| 199 | if (outcome.state === 'rejected') | |
| 200 | return 'getCurrentPosition rejected: ' + outcome.error.code + ' ' + outcome.error.message | |
| 201 | if (!outcome.value) return 'getCurrentPosition resolved with nothing' | |
| 202 | emit({ event: 'observed', name: 'latitude', value: outcome.value.latitude }) | |
| 203 | emit({ event: 'observed', name: 'longitude', value: outcome.value.longitude }) | |
| 204 | if (typeof outcome.value.latitude !== 'number' || typeof outcome.value.longitude !== 'number') | |
| 205 | return 'position is ' + JSON.stringify(outcome.value) | |
| 181 | 206 | }) |
| 182 | 207 | } |
| 183 | 208 | }, |
| @@ -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 { |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from 'bun:test' |
| 2 | 2 | import { readFileSync } from 'node:fs' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | import { ANDROID_DECLINE_PHRASES, androidDeclines, awaitedNeeds, DISMISS_SHARE_MENU, evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, shareMenuInFront, ZIG_REFUSED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigRefusals } from './protocol' | |
| 4 | import { ANDROID_DECLINE_PHRASES, androidDeclines, awaitedNeeds, DISMISS_SHARE_MENU, evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, shareMenuInFront, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol' | |
| 5 | 5 | |
| 6 | 6 | const ESC = String.fromCharCode(27) |
| 7 | 7 | |
| @@ -138,7 +138,7 @@ describe('evaluateRun', () => { | ||
| 138 | 138 | it('fails when the suite is hollowed out to the easy cases', () => { |
| 139 | 139 | const verdict = evaluateRun('ios', iosTranscript({ cases: ['bridge.ready'] })) |
| 140 | 140 | expect(verdict.ok).toBe(false) |
| 141 | expect(verdict.failures).toContain('required case geolocation.disabled.rejects is not in the suite the page ran') | |
| 141 | expect(verdict.failures).toContain('required case share.disabled.rejects is not in the suite the page ran') | |
| 142 | 142 | expect(verdict.failures).toContain('required case clipboard.roundTrip is not in the suite the page ran') |
| 143 | 143 | }) |
| 144 | 144 | |
| @@ -270,11 +270,26 @@ describe('zig attribution', () => { | ||
| 270 | 270 | }) |
| 271 | 271 | |
| 272 | 272 | it('reads the refusal Zig writes through its own capability gate', () => { |
| 273 | const text = 'info: ios: refusing share; enableShare is not enabled in craft.config.json' | |
| 274 | expect(zigRefusals(text)).toEqual(ZIG_REFUSED_ACTIONS) | |
| 275 | }) | |
| 276 | ||
| 277 | it('reads both ways Zig hands an action back to Swift', () => { | |
| 273 | 278 | const text = [ |
| 274 | 'info: ios: refusing share; enableShare is not enabled in craft.config.json', | |
| 275 | 'info: ios: refusing getCurrentPosition; enableGeolocation is not enabled in craft.config.json', | |
| 279 | 'info: craft-bridge dispatch t=mobile a=requestPermission i=9', | |
| 280 | 'info: ios: requestPermission is not served here; handing it back to the host', | |
| 281 | `${ESC}[32minfo: ios: openSettings is declared unavailable; leaving it to the host${ESC}[0m`, | |
| 282 | 'info: craft-bridge dispatch t=mobile a=getCurrentPosition i=10', | |
| 276 | 283 | ].join('\n') |
| 277 | expect(zigRefusals(text)).toEqual(ZIG_REFUSED_ACTIONS) | |
| 284 | ||
| 285 | expect(zigHandBacks(text)).toEqual(['openSettings', 'requestPermission']) | |
| 286 | expect(zigHandBacks(iosTranscript())).toEqual([]) | |
| 287 | }) | |
| 288 | ||
| 289 | // The one deliberate hand-back. Swift owns location authorization, and | |
| 290 | // listing it as served would fail every run for doing what it was designed to. | |
| 291 | it('expects Zig to serve every tested action except the one it leaves to Swift', () => { | |
| 292 | expect(ZIG_TESTED_ACTIONS.filter(action => !ZIG_SERVED_ACTIONS.includes(action))).toEqual(['requestPermission']) | |
| 278 | 293 | }) |
| 279 | 294 | |
| 280 | 295 | it('expects refusals only for actions the suite actually exercises', () => { |
| @@ -78,7 +78,8 @@ export const REQUIRED_CASES: Record<MobilePlatform, string[]> = { | ||
| 78 | 78 | 'bridge.ready', |
| 79 | 79 | 'deviceInfo.isSimulator', |
| 80 | 80 | 'clipboard.roundTrip', |
| 81 | 'geolocation.disabled.rejects', | |
| 81 | 'permissions.location.granted', | |
| 82 | 'geolocation.currentPosition', | |
| 82 | 83 | 'share.disabled.rejects', |
| 83 | 84 | ], |
| 84 | 85 | android: [ |
| @@ -400,19 +401,45 @@ export function zigRefusals(text: string): string[] { | ||
| 400 | 401 | * reducing what the assertion covers. |
| 401 | 402 | */ |
| 402 | 403 | export const ZIG_TESTED_ACTIONS = [ |
| 404 | 'checkPermission', | |
| 403 | 405 | 'clipboardRead', |
| 404 | 406 | 'clipboardWrite', |
| 405 | 407 | 'getCurrentPosition', |
| 406 | 408 | 'getDeviceInfo', |
| 409 | 'requestPermission', | |
| 407 | 410 | 'share', |
| 408 | 411 | ] |
| 409 | 412 | |
| 413 | /** | |
| 414 | * The tested actions Zig must answer itself rather than hand back to Swift. | |
| 415 | * | |
| 416 | * Everything in ZIG_TESTED_ACTIONS except `requestPermission`, which Zig | |
| 417 | * deliberately leaves to Swift for location (`bridge_mobile_permissions.zig` | |
| 418 | * explains why: the answer arrives through the app's own | |
| 419 | * `CLLocationManagerDelegate`). | |
| 420 | */ | |
| 421 | export const ZIG_SERVED_ACTIONS = ZIG_TESTED_ACTIONS.filter(action => action !== 'requestPermission') | |
| 422 | ||
| 410 | 423 | /** The actions the suite configures off, so Zig must refuse each by name. */ |
| 411 | 424 | export const ZIG_REFUSED_ACTIONS = [ |
| 412 | 'getCurrentPosition', | |
| 413 | 425 | 'share', |
| 414 | 426 | ] |
| 415 | 427 | |
| 428 | /** | |
| 429 | * The actions Zig was offered and handed back to the Swift host. | |
| 430 | * | |
| 431 | * Swift answers those with the same shapes, so the page cannot tell, and a | |
| 432 | * dispatch line is logged whether or not Zig then serves the call. This is | |
| 433 | * the line that says it did not. | |
| 434 | */ | |
| 435 | export function zigHandBacks(text: string): string[] { | |
| 436 | const plain = text.replace(ANSI, '') | |
| 437 | const seen = new Set<string>() | |
| 438 | for (const match of plain.matchAll(/ios: (\w+) (?:is not served here; handing it back|is declared unavailable; leaving it) to the host/g)) | |
| 439 | seen.add(match[1]!) | |
| 440 | return [...seen].sort() | |
| 441 | } | |
| 442 | ||
| 416 | 443 | /** |
| 417 | 444 | * The three ways an Android native says it gave up, as `android_dispatch.zig` |
| 418 | 445 | * writes them. |