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

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.

.github/workflows/README.mdmodified+9-2
Changes to .github/workflows/README.md
@@ -30,10 +30,17 @@ bun run test:mobile-e2e:ios
3030```
3131
3232```
33ok ios-shim — 5/5 cases, no zig
34ok ios-runtime — 5/5 cases, zig served clipboardRead, clipboardWrite, getCurrentPosition, getDeviceInfo, log, share
33ok ios-shim — 6/6 cases, no zig
34ok ios-runtime — 6/6 cases, zig served checkPermission, clipboardRead, clipboardWrite, getCurrentPosition, getDeviceInfo, log, requestPermission, share
3535```
3636
37Before 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
41under 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
3744Android needs JDK 17, Gradle 8.11.1, an Android SDK with `platforms;android-36`
3845and `build-tools;36.0.0`, a booted emulator or attached device, and the Zig
3946Android libraries — it runs twice as well, once with `libcraft.so` packaged
packages/zig/src/ios_dispatch.zigmodified+9-1
Changes to packages/zig/src/ios_dispatch.zig
@@ -588,7 +588,15 @@ export fn craft_ios_handle_action(
588588 // is a served action with an unhappy result, not an unserved one.
589589 return true;
590590 };
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;
592600}
593601
594602/// Deliver a result the host shim produced.
scripts/mobile-e2e/driver.htmlmodified+35-10
Changes to scripts/mobile-e2e/driver.html
@@ -166,18 +166,43 @@
166166 }
167167 },
168168 {
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',
175174 platforms: ['ios'],
176175 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)
181206 })
182207 }
183208 },
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 {
scripts/mobile-e2e/protocol.test.tsmodified+20-5
Changes to scripts/mobile-e2e/protocol.test.ts
@@ -1,7 +1,7 @@
11import { describe, expect, it } from 'bun:test'
22import { readFileSync } from 'node:fs'
33import { join } from 'node:path'
4import { 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'
4import { 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'
55
66const ESC = String.fromCharCode(27)
77
@@ -138,7 +138,7 @@ describe('evaluateRun', () => {
138138 it('fails when the suite is hollowed out to the easy cases', () => {
139139 const verdict = evaluateRun('ios', iosTranscript({ cases: ['bridge.ready'] }))
140140 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')
142142 expect(verdict.failures).toContain('required case clipboard.roundTrip is not in the suite the page ran')
143143 })
144144
@@ -270,11 +270,26 @@ describe('zig attribution', () => {
270270 })
271271
272272 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', () => {
273278 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',
276283 ].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'])
278293 })
279294
280295 it('expects refusals only for actions the suite actually exercises', () => {
scripts/mobile-e2e/protocol.tsmodified+29-2
Changes to scripts/mobile-e2e/protocol.ts
@@ -78,7 +78,8 @@ export const REQUIRED_CASES: Record<MobilePlatform, string[]> = {
7878 'bridge.ready',
7979 'deviceInfo.isSimulator',
8080 'clipboard.roundTrip',
81 'geolocation.disabled.rejects',
81 'permissions.location.granted',
82 'geolocation.currentPosition',
8283 'share.disabled.rejects',
8384 ],
8485 android: [
@@ -400,19 +401,45 @@ export function zigRefusals(text: string): string[] {
400401 * reducing what the assertion covers.
401402 */
402403export const ZIG_TESTED_ACTIONS = [
404 'checkPermission',
403405 'clipboardRead',
404406 'clipboardWrite',
405407 'getCurrentPosition',
406408 'getDeviceInfo',
409 'requestPermission',
407410 'share',
408411]
409412
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 */
421export const ZIG_SERVED_ACTIONS = ZIG_TESTED_ACTIONS.filter(action => action !== 'requestPermission')
422
410423/** The actions the suite configures off, so Zig must refuse each by name. */
411424export const ZIG_REFUSED_ACTIONS = [
412 'getCurrentPosition',
413425 'share',
414426]
415427
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 */
435export 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
416443/**
417444 * The three ways an Android native says it gave up, as `android_dispatch.zig`
418445 * writes them.