ReviewOS

also looking at this

craft-native/craft

test(mobile): run the generated apps on a simulator and an emulator

#199
Merged glennmichael123 wants to merge fix/mobile-e2e-real-assertions into main
26 files +3009 -293
.github/workflows/README.mdmodified+57-9
Changes to .github/workflows/README.md
@@ -1,13 +1,61 @@
11# GitHub Actions
22
3This folder contains the following GitHub Actions:
3| Workflow | What it proves |
4|---|---|
5| [CI](./ci.yml) | Lint, typecheck, the Zig and TypeScript suites, and that the generated iOS, Watch, Live Activity and Android projects still **compile** |
6| [Mobile E2E Testing](./mobile-e2e.yml) | That a generated app **runs**: a call from JavaScript reaches native code and comes back, on a real simulator and a real emulator |
7| [Native Package Lifecycle](./native-lifecycle.yml) | Install, update, rollback and uninstall of the packaged app on macOS, Linux and Windows |
8| [Benchmark](./benchmarks.yml) | Startup and runtime numbers, tracked over time |
9| [Binary Size](./binary-size.yml) | The shipped binary has not grown unexpectedly |
10| [SBOM](./sbom.yml) | A software bill of materials for each build |
11| [Release](./release.yml) | Tagging, changelog, pantry publish and npm publish |
412
5- [CI][CI] - all CI jobs for the project
6 - lints the code
7 - `typecheck`s the code
8 - runs test suite
9 - runs on `ubuntu-latest`
10- [Release][Release] - automates the release process & changelog generation
13## Running the mobile E2E locally
1114
12[CI]: ./workflows/ci.yml
13[Release]: ./workflows/release.yml
15The harness generates an app, drops a self-driving page into its web assets,
16builds it, installs it on a device and reads the page's report back off the
17console. It asserts a required list of cases per platform — at least one
18success path and one rejection path — and nothing in it skips: a missing
19toolchain or an app that printed nothing is a failure with a named cause.
20
21iOS needs Xcode, `xcodegen` (`brew install xcodegen`), and the Zig archives,
22because the suite runs twice — once with the Zig runtime linked and once
23without. `build-ios-all` rather than `build-ios-simulator`: the generated
24project links a device slice too, and project generation refuses a runtime
25directory that has none.
26
27```bash
28cd packages/zig && zig build build-ios-all -Doptimize=ReleaseSafe && cd -
29bun run test:mobile-e2e:ios
30```
31
32```
33ok ios-shim — 4/4 cases, no zig
34ok ios-runtime — 4/4 cases, zig served clipboardRead, clipboardWrite, getCurrentPosition, getDeviceInfo, log
35```
36
37Android needs JDK 17, Gradle 8.11.1, an Android SDK with `platforms;android-36`
38and `build-tools;36.0.0`, a booted emulator or attached device, and the Zig
39Android libraries — it runs twice as well, once with `libcraft.so` packaged
40into the APK and once without:
41
42The Zig Android library links bionic, so building it needs the NDK —
43`sdkmanager --install "ndk;26.1.10909125"`. Without it `build-android` refuses
44rather than producing a library that installs and then fails to load.
45
46```bash
47cd packages/zig && zig build build-android-all -Doptimize=ReleaseSafe -Dandroid-ndk="$ANDROID_NDK_HOME" && cd -
48bun run test:mobile-e2e:android
49```
50
51```
52ok android-shim — 4/4 cases, no zig
53ok android-runtime — 4/4 cases, zig served registered:59
54```
55
56Both write everything they saw — console logs, build logs, a screenshot, the
57exact page that ran and a `report.json` — to
58`artifacts/mobile-e2e/<label>/`, which is what CI uploads.
59
60The cases themselves are in [`scripts/mobile-e2e/driver.html`](../../scripts/mobile-e2e/driver.html);
61which of them are mandatory is in [`scripts/mobile-e2e/protocol.ts`](../../scripts/mobile-e2e/protocol.ts).
.github/workflows/mobile-e2e.ymlmodified+276-173
Changes to .github/workflows/mobile-e2e.yml
@@ -1,156 +1,208 @@
11name: Mobile E2E Testing
22
3# Manual only. This workflow has been red since May and cannot go green from
4# a workflow edit: the iOS Zig sources stopped compiling in the 0.17 migration
5# (1b6f285) and need real source work — see the notes in the PR that made this
6# manual. It also runs no end-to-end tests today; every step that would has an
7# `if: hashFiles(...)` guard pointing at a TestApp/project that is not in the
8# tree, so they all skip. Firing it on push meant a red X on unrelated commits
9# for a signal nobody could act on, and two of its path filters
10# (packages/zig/src/ios/**, packages/zig/src/android/**) match directories that
11# do not exist at all.
3# Runs a generated Craft app on a real iOS simulator and a real Android
4# emulator and asserts that a call made from JavaScript reached native code and
5# came back.
6#
7# The version this replaces asserted nothing. Every iOS step that would have
8# tested something was guarded by
9# `if: hashFiles('packages/ios/TestApp/TestApp.xcodeproj') != ''` against a
10# project that has never existed in the tree, and the Android job's only test
11# invocation was gated on `packages/android/gradlew`, which the generator does
12# not write. Both skipped on every run, and the job reported success. Its
13# header also blamed the iOS Zig sources for not compiling since the 0.17
14# migration (1b6f285); that is no longer true —
15# `zig build build-ios-simulator` succeeds at this commit, and the iOS job
16# below depends on it.
17#
18# What the harness asserts, per platform, is in scripts/mobile-e2e/protocol.ts:
19# a required list of cases that must all run and all pass, covering at least
20# one success path and one rejection path. Nothing here skips. A missing
21# toolchain, an absent device, or an app that printed nothing is a failure with
22# a named cause.
23
24# GitHub Actions does not resolve YAML anchors, so the two path filters are
25# written out rather than shared. Keep them identical.
1226on:
27 push:
28 branches: [main]
29 paths:
30 - packages/ios/**
31 - packages/android/**
32 # The mobile Zig sources are flat files under src/, not src/ios/ and
33 # src/android/ as the previous filters claimed — those two directories
34 # have never existed, so the filter matched nothing.
35 - packages/zig/src/ios*.zig
36 - packages/zig/src/android*.zig
37 - packages/zig/src/mobile*.zig
38 - packages/zig/src/bridge_mobile_*.zig
39 - packages/zig/src/bridge_android_*.zig
40 - packages/zig/src/jni*.zig
41 - packages/zig/build.zig
42 - scripts/mobile-e2e.ts
43 - scripts/mobile-e2e/**
44 - .github/workflows/mobile-e2e.yml
45 pull_request:
46 paths:
47 - packages/ios/**
48 - packages/android/**
49 - packages/zig/src/ios*.zig
50 - packages/zig/src/android*.zig
51 - packages/zig/src/mobile*.zig
52 - packages/zig/src/bridge_mobile_*.zig
53 - packages/zig/src/bridge_android_*.zig
54 - packages/zig/src/jni*.zig
55 - packages/zig/build.zig
56 - scripts/mobile-e2e.ts
57 - scripts/mobile-e2e/**
58 - .github/workflows/mobile-e2e.yml
1359 workflow_dispatch:
1460
1561concurrency:
1662 group: mobile-e2e-${{ github.head_ref || github.ref }}
1763 cancel-in-progress: true
1864
65permissions:
66 contents: read
67
1968defaults:
2069 run:
2170 shell: bash
2271
2372jobs:
24 ios-simulator:
25 # name: iOS Simulator Tests
26 runs-on: macos-14 # M1 runner for better iOS simulator performance
73 # The transcript reader and the required-case list are the part of this
74 # harness that can be tested without a device, and nothing ran those tests:
75 # every `bun test` in this repository is scoped to a package directory or to
76 # one explicit file, and the root `test` script is the Zig suite. So the
77 # ratchet that stops the suite being hollowed out was itself unenforced.
78 harness:
79 name: Harness unit tests
80 runs-on: ubuntu-latest
81 timeout-minutes: 10
2782
2883 steps:
2984 - uses: actions/checkout@v6
3085
31 - name: Setup Zig
86 - name: Setup Pantry (provides Bun, Zig, etc.)
3287 uses: pantry-pm/pantry/packages/action@235036fa0f48bae99b2293df5a3dc35c809b1777 # pinned: last SHA whose bundled typescript resolves on linux-x64
33 - name: Setup Xcode
34 uses: maxim-lobanov/setup-xcode@v1
35 with:
36 xcode-version: latest-stable
37
38 - name: Cache Zig artifacts
39 uses: actions/cache@v5
40 with:
41 path: |
42 ~/.cache/zig
43 packages/zig/.zig-cache
44 key: ios-${{ runner.os }}-zig-master-${{ hashFiles('packages/zig/build.zig') }}
4588
46 - name: List available simulators
47 run: |
48 xcrun simctl list devices available
89 - name: Install dependencies
90 run: bun install --frozen-lockfile
4991
50 - name: Boot iOS Simulator
51 run: |
52 # Find an iPhone 15 simulator
53 DEVICE_ID=$(xcrun simctl list devices available | grep "iPhone 15" | head -1 | grep -oE '[0-9A-F-]{36}')
92 # The explicit path is load-bearing: a bare filter makes bun walk the
93 # repository and run out of file descriptors under the hoisted linker.
94 # See the note in native-lifecycle.yml.
95 - name: Test the report protocol
96 run: bun test ./scripts/mobile-e2e/protocol.test.ts
5497
55 if [ -z "$DEVICE_ID" ]; then
56 # Create one if not found
57 DEVICE_ID=$(xcrun simctl create "iPhone 15 Test" "iPhone 15" "iOS17.0")
58 fi
98 ios-simulator:
99 name: iOS simulator
100 runs-on: macos-15
101 # A hung simulator launch would otherwise burn the six-hour default. The
102 # harness has its own per-app timeout; this is the backstop for everything
103 # around it.
104 timeout-minutes: 45
105 env:
106 CRAFT_EVIDENCE_LABEL: ios-simulator
59107
60 echo "DEVICE_ID=$DEVICE_ID" >> $GITHUB_ENV
108 steps:
109 - uses: actions/checkout@v6
61110
62 # Boot the simulator
63 xcrun simctl boot "$DEVICE_ID" || true
111 - name: Setup Pantry (provides Bun, Zig, etc.)
112 uses: pantry-pm/pantry/packages/action@235036fa0f48bae99b2293df5a3dc35c809b1777 # pinned: last SHA whose bundled typescript resolves on linux-x64
64113
65 # Wait for simulator to finish booting. `bootstatus -b` blocks until
66 # the device reaches a Booted state, eliminating the flaky fixed sleep.
67 xcrun simctl bootstatus "$DEVICE_ID" -b
114 - name: Install XcodeGen
115 run: brew install xcodegen
68116
69 - name: First-party Zig dependencies
70 uses: ./.github/actions/first-party-zig-deps
117 - name: Install dependencies
118 run: bun install --frozen-lockfile
71119
72 - name: Build iOS library
73 working-directory: packages/zig
74 run: |
75 # Build iOS simulator static libraries using dedicated build step
76 # (not the default target which builds macOS executables)
77 zig build build-ios-simulator -Doptimize=ReleaseSafe -Dmacos-sdk="$(xcrun --sdk iphonesimulator --show-sdk-path)"
120 - name: Cache Zig artifacts
121 uses: actions/cache@v5
122 with:
123 path: |
124 ~/.cache/zig
125 packages/zig/.zig-cache
126 key: mobile-e2e-ios-${{ runner.os }}-${{ hashFiles('packages/zig/build.zig') }}
78127
79128 - name: First-party Zig dependencies
80129 uses: ./.github/actions/first-party-zig-deps
81130
82 - name: Run iOS unit tests
131 # build-ios-all, not build-ios-simulator: installRuntime() resolves every
132 # SDK in RUNTIME_ARCHIVES before it writes anything and throws when one
133 # has no archive at all, so a zig-out holding only the simulator slices
134 # fails at project generation. It passes locally only because a device
135 # archive is usually already sitting there from an earlier build.
136 #
137 # No -Dmacos-sdk: the iOS steps resolve their SDK through xcrun at
138 # configure time (build.zig's iosSdkPath), and the option is read only by
139 # desktop and host-test artifacts, so passing it was a no-op.
140 - name: Build the Zig iOS runtime
83141 working-directory: packages/zig
84 run: |
85 # `zig build test` reports through its exit code. The old form piped
86 # it to a file and grepped for "mobile"/"ios", which fails under
87 # `set -e` whenever the run is clean and the file is therefore empty.
88 zig build test
89
90 - name: Build iOS test app
91 if: hashFiles('packages/ios/TestApp/TestApp.xcodeproj') != ''
92 run: |
93 cd packages/ios/TestApp
94 xcodebuild build-for-testing \
95 -scheme TestApp \
96 -destination "platform=iOS Simulator,id=$DEVICE_ID" \
97 -configuration Debug \
98 CODE_SIGN_IDENTITY="" \
99 CODE_SIGNING_REQUIRED=NO
100
101 - name: Run iOS E2E tests
102 if: hashFiles('packages/ios/TestApp/TestApp.xcodeproj') != ''
103 run: |
104 cd packages/ios/TestApp
105 xcodebuild test-without-building \
106 -scheme TestApp \
107 -destination "platform=iOS Simulator,id=$DEVICE_ID" \
108 -configuration Debug
109
110 - name: Capture iOS screenshots
111 run: |
112 mkdir -p screenshots/ios
113
114 # Take a screenshot of the simulator
115 xcrun simctl io "$DEVICE_ID" screenshot screenshots/ios/simulator.png || true
116
117 - name: Shutdown simulator
118 if: always()
119 run: |
120 xcrun simctl shutdown "$DEVICE_ID" || true
121
122 - name: Upload iOS screenshots
142 run: zig build build-ios-all -Doptimize=ReleaseSafe
143
144 - name: Run mobile E2E on the simulator
145 id: harness
146 run: bun scripts/mobile-e2e.ts --platform ios --ios-runtime packages/zig/zig-out/lib
147
148 - name: Upload iOS evidence
149 # Not a bare `always()`: when an earlier step fails the harness never
150 # runs, the evidence directory is never created, and `if-no-files-found:
151 # error` would then add a "No files were found" error on top of the real
152 # one. Skipped-means-skipped; ran-and-produced-nothing still fails.
153 if: always() && steps.harness.conclusion != 'skipped'
123154 uses: actions/upload-artifact@v4
124155 with:
125 name: ios-screenshots
126 path: screenshots/ios/
127 retention-days: 7
128
129 - name: Generate iOS test report
130 run: |
131 echo "## iOS Simulator Tests" >> $GITHUB_STEP_SUMMARY
132 echo "" >> $GITHUB_STEP_SUMMARY
133 echo "- Simulator: iPhone 15" >> $GITHUB_STEP_SUMMARY
134 echo "- iOS Version: 17.0" >> $GITHUB_STEP_SUMMARY
135 echo "- Status: Completed" >> $GITHUB_STEP_SUMMARY
156 name: mobile-e2e-ios
157 # Console logs, build logs, screenshots, the generated project.yml and
158 # the exact page that ran. Excludes work/ — the generated projects and
159 # their DerivedData are hundreds of megabytes and the interesting
160 # parts are copied out.
161 path: |
162 artifacts/mobile-e2e/ios-simulator/**
163 !artifacts/mobile-e2e/ios-simulator/work/**
164 # A run that produced no evidence is a broken harness, not a pass.
165 if-no-files-found: error
166 retention-days: 30
136167
137168 android-emulator:
138 # name: Android Emulator Tests
169 name: Android emulator
139170 runs-on: ubuntu-latest
171 timeout-minutes: 60
172 env:
173 CRAFT_EVIDENCE_LABEL: android-emulator
140174
141175 steps:
142176 - uses: actions/checkout@v6
143177
144 - name: Setup Zig
178 - name: Setup Pantry (provides Bun, Zig, etc.)
145179 uses: pantry-pm/pantry/packages/action@235036fa0f48bae99b2293df5a3dc35c809b1777 # pinned: last SHA whose bundled typescript resolves on linux-x64
180
146181 - name: Setup Java
147 uses: actions/setup-java@v4
182 uses: actions/setup-java@v6
148183 with:
149 distribution: 'temurin'
184 distribution: temurin
150185 java-version: '17'
151186
152187 - name: Setup Android SDK
153 uses: android-actions/setup-android@v3
188 uses: android-actions/setup-android@v4
189 with:
190 # The action defaults to `tools platform-tools`, but the obsolete
191 # `tools` package is no longer offered by the Android SDK repository.
192 packages: platform-tools
193
194 # Same platform and build-tools as ci.yml's android-builder, so the
195 # emulator job and the compile gate cannot drift onto different SDKs.
196 - name: Install Android platform
197 run: sdkmanager "platforms;android-36" "build-tools;36.0.0"
198
199 - name: Setup Gradle
200 uses: gradle/actions/setup-gradle@v6
201 with:
202 gradle-version: '8.11.1'
203
204 - name: Install dependencies
205 run: bun install --frozen-lockfile
154206
155207 - name: Cache Zig artifacts
156208 uses: actions/cache@v5
@@ -158,36 +210,25 @@ jobs:
158210 path: |
159211 ~/.cache/zig
160212 packages/zig/.zig-cache
161 key: android-${{ runner.os }}-zig-master-${{ hashFiles('packages/zig/build.zig') }}
162
163 - name: Install Android NDK
164 run: |
165 sdkmanager --install "ndk;26.1.10909125"
166 echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/26.1.10909125" >> $GITHUB_ENV
213 key: mobile-e2e-android-${{ runner.os }}-${{ hashFiles('packages/zig/build.zig') }}
167214
168215 - name: First-party Zig dependencies
169216 uses: ./.github/actions/first-party-zig-deps
170217
171 - name: Build Android library
172 working-directory: packages/zig
218 # The NDK is what supplies bionic. Without it the JNI library builds and
219 # then fails at dlopen with `cannot locate symbol "__tls_get_addr"`, which
220 # CraftNative catches and turns into a silent fallback to the Kotlin shim
221 # — the failure this whole leg exists to catch.
222 - name: Install the Android NDK
173223 run: |
174 # Use the dedicated android step, which resolves its own target.
175 # Passing -Dtarget steers the *default* install step instead, which
176 # builds the desktop binaries — and since an android triple reports
177 # os_tag == .linux, that links GTK3/WebKit2GTK against a sysroot that
178 # does not exist for Android.
179 zig build build-android -Doptimize=ReleaseSafe
180
181 - name: First-party Zig dependencies
182 uses: ./.github/actions/first-party-zig-deps
224 sdkmanager --install "ndk;26.1.10909125"
225 echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/26.1.10909125" >> "$GITHUB_ENV"
183226
184 - name: Run Android unit tests
227 # Both ABIs. x86_64 is the one the emulator actually loads; arm64-v8a is
228 # built so the runtime leg installs the same APK a phone would get.
229 - name: Build the Zig Android runtime
185230 working-directory: packages/zig
186 run: |
187 # `zig build test` reports through its exit code. The old form piped
188 # it to a file and grepped for "mobile"/"android", which fails under
189 # `set -e` whenever the run is clean and the file is therefore empty.
190 zig build test
231 run: zig build build-android-all -Doptimize=ReleaseSafe -Dandroid-ndk="$ANDROID_NDK_HOME"
191232
192233 - name: Enable KVM
193234 run: |
@@ -202,9 +243,17 @@ jobs:
202243 path: |
203244 ~/.android/avd/*
204245 ~/.android/adb*
205 key: avd-api-34-${{ runner.os }}
206
207 - name: Create AVD and run tests
246 # The device, not just the API level. With force-avd-creation off the
247 # action reuses whatever the cache restored, so a key that ignores
248 # arch and profile would keep testing the old device after someone
249 # changed them.
250 key: avd-api-34-x86_64-pixel_6-${{ runner.os }}
251
252 # `script:` is the only place a command can run with the emulator
253 # attached, so the harness is invoked from inside it rather than as its
254 # own step.
255 - name: Run mobile E2E on the emulator
256 id: harness
208257 uses: reactivecircus/android-emulator-runner@v2
209258 with:
210259 api-level: 34
@@ -214,56 +263,110 @@ jobs:
214263 emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
215264 disable-animations: true
216265 script: |
217 echo "Emulator started"
218266 adb wait-for-device
219 mkdir -p screenshots/android
220 adb exec-out screencap -p > screenshots/android/emulator.png || true
221 if [ -x "packages/android/gradlew" ]; then
222 cd packages/android
223 ./gradlew test
224 else
225 echo "Android Gradle wrapper not present; skipping project-level tests"
226 fi
227
228 - name: Upload Android screenshots
267 bun scripts/mobile-e2e.ts --platform android --android-runtime packages/zig/zig-out/android
268
269 - name: Upload Android evidence
270 if: always() && steps.harness.conclusion != 'skipped'
229271 uses: actions/upload-artifact@v4
230272 with:
231 name: android-screenshots
232 path: screenshots/android/
233 retention-days: 7
273 name: mobile-e2e-android
274 path: |
275 artifacts/mobile-e2e/android-emulator/**
276 !artifacts/mobile-e2e/android-emulator/work/**
277 if-no-files-found: error
278 retention-days: 30
279
280 # The Zig runtime's own simulator fixture: a hand-linked four-file app that
281 # drives libcraft-ios-simulator directly, with assertions the generated-app
282 # suite cannot make — the host hand-off, the error route's escaping, a
283 # Keychain round trip, in-process SQLite, and a sensor the simulator does not
284 # have refusing rather than fabricating a stream. It has been in the tree,
285 # passing, and wired into no workflow.
286 ios-zig-slice:
287 name: iOS Zig runtime slice
288 runs-on: macos-15
289 timeout-minutes: 45
290
291 steps:
292 - uses: actions/checkout@v6
293
294 - name: Setup Pantry (provides Bun, Zig, etc.)
295 uses: pantry-pm/pantry/packages/action@235036fa0f48bae99b2293df5a3dc35c809b1777 # pinned: last SHA whose bundled typescript resolves on linux-x64
234296
235 - name: Generate Android test report
297 - name: Cache Zig artifacts
298 uses: actions/cache@v5
299 with:
300 path: |
301 ~/.cache/zig
302 packages/zig/.zig-cache
303 key: mobile-e2e-slice-${{ runner.os }}-${{ hashFiles('packages/zig/build.zig') }}
304
305 - name: First-party Zig dependencies
306 uses: ./.github/actions/first-party-zig-deps
307
308 # The script defaults CRAFT_ZIG to a pinned toolchain under
309 # ~/.cache/craft-ci-local, which exists on the author's machine and not on
310 # a runner. Point it at the one pantry provides.
311 - name: Exercise the Zig runtime on a simulator
312 id: harness
313 # The fixture polls for its late markers against a wall-clock deadline.
314 # A runner is several times slower than a laptop here — the first CI run
315 # of this job reached marker 36 of 42 before the old iteration-count
316 # budget ran out — so give it room. It exits as soon as the markers
317 # arrive; the budget only matters when something is genuinely stuck.
318 env:
319 CRAFT_SLICE_TIMEOUT: '900'
236320 run: |
237 echo "## Android Emulator Tests" >> $GITHUB_STEP_SUMMARY
238 echo "" >> $GITHUB_STEP_SUMMARY
239 echo "- Emulator: Pixel 6" >> $GITHUB_STEP_SUMMARY
240 echo "- API Level: 34" >> $GITHUB_STEP_SUMMARY
241 echo "- Status: Completed" >> $GITHUB_STEP_SUMMARY
321 CRAFT_ZIG="$(command -v zig)" \
322 packages/ios/fixtures/zig-slice/build-and-run.sh \
323 "$PWD/artifacts/mobile-e2e/ios-zig-slice"
324
325 - name: Upload slice evidence
326 if: always() && steps.harness.conclusion != 'skipped'
327 uses: actions/upload-artifact@v4
328 with:
329 name: mobile-e2e-ios-zig-slice
330 path: |
331 artifacts/mobile-e2e/ios-zig-slice/console.log
332 artifacts/mobile-e2e/ios-zig-slice/console.plain
333 if-no-files-found: error
334 retention-days: 30
242335
243336 mobile-summary:
244 # name: Mobile Test Summary
245 needs: [ios-simulator, android-emulator]
337 name: Mobile E2E summary
338 needs: [harness, ios-simulator, android-emulator, ios-zig-slice]
246339 runs-on: ubuntu-latest
247340 if: always()
248341
249342 steps:
250 - name: Check test results
343 - name: Report
251344 run: |
252 echo "## Mobile E2E Test Summary" >> $GITHUB_STEP_SUMMARY
253 echo "" >> $GITHUB_STEP_SUMMARY
254 echo "| Platform | Status |" >> $GITHUB_STEP_SUMMARY
255 echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
256 echo "| iOS Simulator | $ |" >> $GITHUB_STEP_SUMMARY
257 echo "| Android Emulator | $ |" >> $GITHUB_STEP_SUMMARY
258
259 - name: Download all screenshots
260 uses: actions/download-artifact@v4
261 with:
262 path: all-screenshots
263
264 - name: Upload combined screenshots
265 uses: actions/upload-artifact@v4
266 with:
267 name: mobile-screenshots
268 path: all-screenshots/
269 retention-days: 30
345 {
346 echo "## Mobile E2E"
347 echo
348 echo "| Leg | Result |"
349 echo "|-----|--------|"
350 echo "| Harness unit tests | $ |"
351 echo "| iOS simulator (generated app) | $ |"
352 echo "| Android emulator (generated app) | $ |"
353 echo "| iOS Zig runtime slice | $ |"
354 } >> "$GITHUB_STEP_SUMMARY"
355
356 # The summary job used to be the whole story, and it reported whatever it
357 # was given without ever failing. `needs` does not fail a job on a failed
358 # dependency when `if: always()` is set, so say it explicitly.
359 #
360 # `failure` and `skipped`, not `!= success`: with cancel-in-progress on,
361 # pushing twice in quick succession cancels the first run, and treating
362 # `cancelled` as a failure would put a red X on it — a signal about
363 # nothing, which is how the workflow this replaces earned its reputation.
364 - name: Fail if any leg failed
365 if: >-
366 contains(fromJSON('["failure", "skipped"]'), needs.harness.result)
367 || contains(fromJSON('["failure", "skipped"]'), needs.ios-simulator.result)
368 || contains(fromJSON('["failure", "skipped"]'), needs.android-emulator.result)
369 || contains(fromJSON('["failure", "skipped"]'), needs.ios-zig-slice.result)
370 run: |
371 echo "::error::at least one mobile E2E leg did not succeed"
372 exit 1
.gitignoremodified+1-0
Changes to .gitignore
@@ -31,6 +31,7 @@ release/
3131# Test outputs
3232test-app/
3333artifacts/native-lifecycle/
34artifacts/mobile-e2e/
3435
3536# Logs
3637*.log
package.jsonmodified+2-0
Changes to package.json
@@ -35,6 +35,8 @@
3535 "test": "command -v zig >/dev/null 2>&1 || eval \"$(pantry env | sed -n '/^export /,$p')\"; cd packages/zig && zig build test",
3636 "test:sdk": "cd packages/typescript && bun test",
3737 "test:native-lifecycle": "bun scripts/native-lifecycle.ts",
38 "test:mobile-e2e:ios": "bun scripts/mobile-e2e.ts --platform ios --ios-runtime packages/zig/zig-out/lib",
39 "test:mobile-e2e:android": "bun scripts/mobile-e2e.ts --platform android --android-runtime packages/zig/zig-out/android",
3840 "typecheck": "cd packages/typescript && bun run typecheck && cd ../create-craft && bun run typecheck && cd ../ts-maps && bun run typecheck",
3941 "run": "command -v zig >/dev/null 2>&1 || eval \"$(pantry env | sed -n '/^export /,$p')\"; cd packages/zig && zig build run",
4042 "run:demo": "command -v zig >/dev/null 2>&1 || eval \"$(pantry env | sed -n '/^export /,$p')\"; cd packages/zig && zig build run-demo",
packages/android/src/index.test.tsmodified+138-1
Changes to packages/android/src/index.test.ts
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFil
22import { tmpdir } from 'node:os'
33import { join } from 'node:path'
44import { describe, expect, it } from 'bun:test'
5import { build, init, renderAndroidDeepLinks, renderAndroidPermissions, syncAndroidWebAssets } from './index'
5import { build, init, installRuntime, renderAndroidDeepLinks, renderAndroidPermissions, resolveRuntimeDir, syncAndroidWebAssets } from './index'
66
77function generatedFiles(path: string): string[] {
88 return readdirSync(path, { withFileTypes: true }).flatMap((entry) => {
@@ -1188,3 +1188,140 @@ describe('Craft Android builder', () => {
11881188 }
11891189 })
11901190})
1191
1192/**
1193 * A runtime directory shaped like `zig build build-android-all`'s output.
1194 *
1195 * The contents are not a real ELF — nothing here loads it — but the layout is
1196 * exactly `zig-out/android/<abi>/libcraft.so`, because the layout is the part
1197 * the installer depends on.
1198 */
1199function fakeRuntime(abis: string[]): string {
1200 const dir = mkdtempSync(join(tmpdir(), 'craft-android-runtime-'))
1201 for (const abi of abis) {
1202 mkdirSync(join(dir, abi), { recursive: true })
1203 writeFileSync(join(dir, abi, 'libcraft.so'), `not-really-${abi}`)
1204 }
1205 return dir
1206}
1207
1208describe('Zig runtime installation', () => {
1209 it('lands the library where AGP already looks for it', () => {
1210 // app/src/main/jniLibs is the default jniLibs.srcDirs, which is why no
1211 // Gradle template has to know this happened.
1212 const output = mkdtempSync(join(tmpdir(), 'craft-android-jnilibs-'))
1213 installRuntime(output, fakeRuntime(['arm64-v8a', 'x86_64']))
1214
1215 expect(readFileSync(join(output, 'app/src/main/jniLibs/arm64-v8a/libcraft.so'), 'utf8')).toBe('not-really-arm64-v8a')
1216 expect(readFileSync(join(output, 'app/src/main/jniLibs/x86_64/libcraft.so'), 'utf8')).toBe('not-really-x86_64')
1217 })
1218
1219 it('installs what it has when an architecture is missing', () => {
1220 // A one-ABI dev loop is legitimate; it just cannot run on the other kind
1221 // of device, which the installer warns about rather than refusing.
1222 const output = mkdtempSync(join(tmpdir(), 'craft-android-one-abi-'))
1223 expect(installRuntime(output, fakeRuntime(['x86_64']))).toBe(true)
1224
1225 expect(existsSync(join(output, 'app/src/main/jniLibs/x86_64/libcraft.so'))).toBe(true)
1226 expect(existsSync(join(output, 'app/src/main/jniLibs/arm64-v8a'))).toBe(false)
1227 })
1228
1229 it('refuses a directory with no library at all, and names the build step', () => {
1230 const output = mkdtempSync(join(tmpdir(), 'craft-android-no-abi-'))
1231 const empty = mkdtempSync(join(tmpdir(), 'craft-android-empty-'))
1232
1233 expect(() => installRuntime(output, empty)).toThrow(/zig build build-android-all/)
1234 // Nothing written on the way to throwing.
1235 expect(existsSync(join(output, 'app/src/main/jniLibs'))).toBe(false)
1236 })
1237
1238 it('does not leave an architecture behind that a later build dropped', () => {
1239 // A stale .so still ships in the APK and still loads, so the app would run
1240 // an architecture nobody built for this version.
1241 const output = mkdtempSync(join(tmpdir(), 'craft-android-stale-'))
1242 installRuntime(output, fakeRuntime(['arm64-v8a', 'x86_64']))
1243 installRuntime(output, fakeRuntime(['x86_64']))
1244
1245 expect(existsSync(join(output, 'app/src/main/jniLibs/arm64-v8a'))).toBe(false)
1246 expect(existsSync(join(output, 'app/src/main/jniLibs/x86_64/libcraft.so'))).toBe(true)
1247 })
1248
1249 it('generates a shim-only app when no runtime is configured', async () => {
1250 // The default, and the state every generated app was in before this
1251 // existed: System.loadLibrary throws, isAvailable is false, Kotlin serves.
1252 const output = mkdtempSync(join(tmpdir(), 'craft-android-shim-only-'))
1253 await init({ name: 'ShimOnly', packageName: 'dev.craft.shimonly', output, runtimeDir: null })
1254
1255 expect(existsSync(join(output, 'app/src/main/jniLibs'))).toBe(false)
1256 })
1257
1258 it('installs the runtime from init when one is given', async () => {
1259 const output = mkdtempSync(join(tmpdir(), 'craft-android-with-runtime-'))
1260 await init({
1261 name: 'WithRuntime',
1262 packageName: 'dev.craft.withruntime',
1263 output,
1264 runtimeDir: fakeRuntime(['arm64-v8a', 'x86_64']),
1265 })
1266
1267 expect(existsSync(join(output, 'app/src/main/jniLibs/arm64-v8a/libcraft.so'))).toBe(true)
1268 expect(existsSync(join(output, 'app/src/main/jniLibs/x86_64/libcraft.so'))).toBe(true)
1269 })
1270
1271 it('reads CRAFT_ANDROID_RUNTIME only when the caller says nothing', () => {
1272 const dir = fakeRuntime(['x86_64'])
1273 const previous = process.env.CRAFT_ANDROID_RUNTIME
1274 process.env.CRAFT_ANDROID_RUNTIME = dir
1275
1276 try {
1277 expect(resolveRuntimeDir()).toBe(dir)
1278 // null is a declaration, not a gap: no runtime whatever the environment
1279 // says, which is what the E2E suite's shim leg depends on.
1280 expect(resolveRuntimeDir(null)).toBe(null)
1281 }
1282 finally {
1283 if (previous === undefined) delete process.env.CRAFT_ANDROID_RUNTIME
1284 else process.env.CRAFT_ANDROID_RUNTIME = previous
1285 }
1286 })
1287
1288 it('names which knob pointed at a directory that is not there', () => {
1289 const previous = process.env.CRAFT_ANDROID_RUNTIME
1290 process.env.CRAFT_ANDROID_RUNTIME = '/craft/definitely/not/here'
1291
1292 try {
1293 expect(() => resolveRuntimeDir()).toThrow(/CRAFT_ANDROID_RUNTIME points at/)
1294 expect(() => resolveRuntimeDir('/craft/also/not/here')).toThrow(/runtimeDir is/)
1295 }
1296 finally {
1297 if (previous === undefined) delete process.env.CRAFT_ANDROID_RUNTIME
1298 else process.env.CRAFT_ANDROID_RUNTIME = previous
1299 }
1300 })
1301})
1302
1303describe('the Zig library and the generated app agree on how old a device may be', () => {
1304 it('builds the JNI library for no newer an API than minSdk', () => {
1305 // One prebuilt libcraft.so serves every generated app, so it has to be
1306 // linked against the *oldest* API craft's default configuration claims to
1307 // support. Build it against a newer one and the linker happily binds
1308 // symbols that are simply absent on an older phone — and the failure is
1309 // dlopen refusing the library at startup, on exactly the devices the app
1310 // said it ran on.
1311 //
1312 // Nothing else connects a Zig constant to a TypeScript one, so this reads
1313 // build.zig and compares.
1314 const buildZig = readFileSync(join(import.meta.dir, '../../zig/build.zig'), 'utf8')
1315 const declared = buildZig.match(/const android_api_level: u32 = (\d+);/)
1316
1317 expect(declared).not.toBeNull()
1318
1319 // DEFAULT_CONFIG is not exported, so read it the same way: from the source
1320 // that defines it, which is the thing that would have to change.
1321 const generator = readFileSync(join(import.meta.dir, 'index.ts'), 'utf8')
1322 const minSdk = generator.match(/minSdk:\s*(\d+)/)
1323
1324 expect(minSdk).not.toBeNull()
1325 expect(Number(declared![1])).toBeLessThanOrEqual(Number(minSdk![1]))
1326 })
1327})
packages/android/src/index.tsmodified+123-0
Changes to packages/android/src/index.ts
@@ -77,6 +77,14 @@ export interface InitOptions {
7777 packageName?: string
7878 output: string
7979 config?: Partial<CraftAndroidConfig>
80 /**
81 * Where to find the Zig runtime's `<abi>/libcraft.so`.
82 *
83 * `null` means no runtime regardless of the environment; `undefined` falls
84 * through to `CRAFT_ANDROID_RUNTIME`. Same shape as the iOS builder's
85 * `runtimeDir`.
86 */
87 runtimeDir?: string | null
8088}
8189
8290export interface BuildOptions {
@@ -85,6 +93,8 @@ export interface BuildOptions {
8593 output: string
8694 release?: boolean
8795 compile?: boolean
96 /** Same meaning as `InitOptions.runtimeDir`; refreshes what init installed. */
97 runtimeDir?: string | null
8898}
8999
90100export interface OpenOptions {
@@ -341,6 +351,96 @@ export function renderAndroidDeepLinks(config: CraftAndroidConfig): string {
341351/**
342352 * Initialize a new Android project
343353 */
354/**
355 * The ABI directories a runtime directory is expected to hold.
356 *
357 * These are Android's own names for the two architectures craft builds, and
358 * they are also `zig build build-android-all`'s output layout — `zig-out/android/`
359 * already contains `arm64-v8a/libcraft.so` and `x86_64/libcraft.so`, which is
360 * exactly the shape `app/src/main/jniLibs/` wants. So the install below is a
361 * copy and not a rearrangement.
362 *
363 * x86_64 is not optional-in-practice: every Android emulator anyone tests on
364 * is x86_64, so a runtime shipped for arm64 alone is one that never runs in
365 * CI and never runs on a developer's machine either.
366 */
367const RUNTIME_ABIS = ['arm64-v8a', 'x86_64'] as const
368
369/**
370 * Where the runtime directory comes from when the caller does not say.
371 *
372 * The same shape as `CRAFT_BIN` and the iOS builder's `CRAFT_IOS_RUNTIME`: an
373 * explicit override for the monorepo dev loop, not a lookup path. Shipping the
374 * runtime to real apps means putting these libraries in the pantry package
375 * beside the `craft` binary, which is a distribution decision this function
376 * does not make.
377 */
378export function resolveRuntimeDir(override?: string | null): string | null {
379 if (override === null) return null
380 const dir = override ?? process.env.CRAFT_ANDROID_RUNTIME
381 if (!dir) return null
382 if (!existsSync(dir)) {
383 const source = override === undefined ? 'CRAFT_ANDROID_RUNTIME points at' : 'runtimeDir is'
384 throw new Error(`${source} ${dir}, which does not exist.`)
385 }
386 return dir
387}
388
389/**
390 * Copy the Zig runtime into the generated project as
391 * `app/src/main/jniLibs/<abi>/libcraft.so`.
392 *
393 * That path is AGP's default `jniLibs.srcDirs`, so nothing in the Gradle
394 * templates has to know about it — the library is packaged into the APK and
395 * `System.loadLibrary("craft")` finds it because the file is named for the
396 * `craft` it asks for.
397 *
398 * Until this existed, nothing put the library anywhere. `CraftNative` caught
399 * the `UnsatisfiedLinkError`, set `isAvailable = false`, and every action fell
400 * through to the Kotlin shim — by design, so an app with no runtime still
401 * works, which is also why nobody noticed that *every* generated app was in
402 * that state and the whole Android half of the Zig bridge had never run.
403 *
404 * Returns true when a runtime was installed.
405 */
406export function installRuntime(output: string, runtimeDir: string): boolean {
407 // Every ABI resolved before anything is written, so a half-populated
408 // runtime directory cannot leave a project carrying one architecture and
409 // claiming both. A single-ABI APK installs fine and then fails to load on
410 // the other kind of device, which is the break that only shows up on
411 // somebody else's phone.
412 const found = RUNTIME_ABIS.map(abi => ({ abi, source: join(runtimeDir, abi, 'libcraft.so') }))
413 .filter(entry => existsSync(entry.source))
414
415 if (found.length === 0) {
416 throw new Error(
417 `${runtimeDir} has no <abi>/libcraft.so for any of ${RUNTIME_ABIS.join(', ')}. `
418 + 'Run `zig build build-android-all -Doptimize=ReleaseSafe` in packages/zig and point at its zig-out/android.',
419 )
420 }
421
422 if (found.length < RUNTIME_ABIS.length) {
423 const missing = RUNTIME_ABIS.filter(abi => !found.some(entry => entry.abi === abi))
424 console.warn(
425 ` ⚠ only ${found.map(entry => entry.abi).join(', ')} was found; ${missing.join(', ')} is missing. `
426 + 'The app will fall back to the Kotlin shim on those devices.',
427 )
428 }
429
430 const dest = join(output, 'app/src/main/jniLibs')
431 // Wiped rather than merged, so an ABI dropped from a later build does not
432 // linger in the project as a stale library the APK still ships.
433 rmSync(dest, { force: true, recursive: true })
434
435 for (const { abi, source } of found) {
436 const abiDir = join(dest, abi)
437 mkdirSync(abiDir, { recursive: true })
438 cpSync(source, join(abiDir, 'libcraft.so'))
439 }
440
441 return true
442}
443
344444export async function init(options: InitOptions): Promise<void> {
345445 const { name, packageName, output } = options
346446
@@ -670,6 +770,15 @@ zipStorePath=wrapper/dists
670770
671771 writeFileSync(join(output, 'app/src/main/assets/index.html'), placeholderHtml)
672772
773 // The Zig runtime, when one was configured. Absent, the generated app is the
774 // Kotlin shim and behaves exactly as it always has — which is the fallback
775 // `CraftNative` was written for, not a failure.
776 const runtimeDir = resolveRuntimeDir(options.runtimeDir)
777 if (runtimeDir) {
778 installRuntime(output, runtimeDir)
779 console.log(' Installed the Zig runtime from', runtimeDir)
780 }
781
673782 console.log('✅ Project initialized')
674783 console.log('')
675784 console.log('Next steps:')
@@ -696,6 +805,20 @@ export async function build(options: BuildOptions): Promise<void> {
696805
697806 const config: CraftAndroidConfig = JSON.parse(readFileSync(configPath, 'utf-8'))
698807
808 // Refresh the runtime the same way the iOS builder does: only for a project
809 // that already has one, so `craft android build` in a shim-only project does
810 // not quietly acquire a native library it was never generated with.
811 if (existsSync(join(output, 'app/src/main/jniLibs'))) {
812 const runtimeDir = resolveRuntimeDir(options.runtimeDir)
813 if (runtimeDir) {
814 installRuntime(output, runtimeDir)
815 console.log(' Refreshed the Zig runtime from', runtimeDir)
816 }
817 else {
818 console.log(' Keeping the Zig runtime installed at init (no runtime directory configured)')
819 }
820 }
821
699822 // Update dev server URL if provided
700823 if (devServer) {
701824 const url = androidWebUrl(devServer, 'Android dev server URL')
packages/android/templates/CraftBridge.kt.templatemodified+40-1
Changes to packages/android/templates/CraftBridge.kt.template
@@ -2366,10 +2366,49 @@ class CraftBridge(
23662366 activity.packageManager.getPackageInfo(activity.packageName, 0).versionCode
23672367 }
23682368 } catch (e: Exception) { 0 })
2369 put("isEmulator", Build.FINGERPRINT.contains("generic") || Build.FINGERPRINT.contains("emulator"))
2369 put("isEmulator", isEmulator())
23702370 }.toString()
23712371 }
23722372
2373 /**
2374 * Whether this is an emulator rather than a phone.
2375 *
2376 * This used to be `FINGERPRINT.contains("generic") ||
2377 * FINGERPRINT.contains("emulator")`, and it answered false on the AVDs
2378 * anyone actually runs. A current Google APIs image fingerprints as
2379 *
2380 * google/sdk_gphone64_x86_64/emu64xa:14/UE1A.230829.036/...:user/release-keys
2381 *
2382 * which contains neither word. The mobile E2E suite caught it on an API 34
2383 * x86_64 emulator, reporting `isEmulator is false` from inside one.
2384 *
2385 * `HARDWARE` is the reliable signal and leads here: the AVD's virtual board
2386 * is `goldfish` on the old QEMU pipeline and `ranchu` on the current one,
2387 * and has been one of those two for over a decade. The rest are kept as
2388 * secondary evidence for images that report something else - Genymotion,
2389 * and the older `generic`/`sdk` products.
2390 *
2391 * Note this is a hint, not a security boundary. Anything that can rewrite
2392 * these properties can defeat it, and nothing here should be treated as
2393 * proof of where the code is running.
2394 */
2395 private fun isEmulator(): Boolean {
2396 val hardware = Build.HARDWARE.lowercase()
2397 if (hardware.startsWith("goldfish") || hardware.startsWith("ranchu")) return true
2398
2399 val fingerprint = Build.FINGERPRINT.lowercase()
2400 if (fingerprint.startsWith("generic") || fingerprint.startsWith("unknown")) return true
2401 if (fingerprint.contains("emulator") || fingerprint.contains("sdk_gphone")) return true
2402
2403 val product = Build.PRODUCT.lowercase()
2404 if (product.startsWith("sdk") || product.contains("_sdk") || product.contains("sdk_")) return true
2405
2406 val model = Build.MODEL.lowercase()
2407 if (model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for")) return true
2408
2409 return Build.MANUFACTURER.contains("Genymotion") || Build.BRAND.startsWith("generic")
2410 }
2411
23732412 // ==================== Network Status ====================
23742413
23752414 @JavascriptInterface
packages/android/templates/CraftNative.kt.templatemodified+14-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -110,10 +110,24 @@ object CraftNative {
110110 */
111111 val isAvailable: Boolean = try {
112112 System.loadLibrary("craft")
113 // Said out loud, because the two outcomes below are otherwise
114 // indistinguishable from outside: a library that never shipped and a
115 // library that loaded and then failed to bind both end with the shim
116 // answering every call. Zig logs its own registration under the same
117 // tag, so the pair reads as a sequence.
118 android.util.Log.i("CraftNative", "craft: libcraft.so loaded")
113119 true
114120 } catch (e: UnsatisfiedLinkError) {
121 // The reason, not just the fact. This used to be a bare `false`, and
122 // the first time the runtime was genuinely shipped and genuinely
123 // failed to load, the only evidence anywhere was that nothing
124 // happened — no linker line, no exception, nothing in logcat at all.
125 // The message carries the dynamic linker's own complaint, which is the
126 // one thing that says *why*.
127 android.util.Log.w("CraftNative", "craft: libcraft.so did not load: ${e.message}")
115128 false
116129 } catch (e: SecurityException) {
130 android.util.Log.w("CraftNative", "craft: libcraft.so was refused: ${e.message}")
117131 false
118132 }
119133
packages/ios/fixtures/zig-slice/build-and-run.shmodified+39-8
Changes to packages/ios/fixtures/zig-slice/build-and-run.sh
@@ -123,14 +123,45 @@ LAUNCH_PID=$!
123123
124124# The round trip is fast, but a cold simulator is not. Poll rather than sleep a
125125# fixed amount, so a slow boot does not read as a failure.
126for _ in $(seq 1 60); do
127 if grep -q 'i=26' "$LOG" 2>/dev/null && grep -q 'i=28' "$LOG" 2>/dev/null \
128 && grep -q 'i=32' "$LOG" 2>/dev/null && grep -q 'i=22' "$LOG" 2>/dev/null \
129 && grep -q 'i=34' "$LOG" 2>/dev/null && grep -q 'i=38' "$LOG" 2>/dev/null \
130 && grep -q 'i=46' "$LOG" 2>/dev/null && grep -q 'i=44' "$LOG" 2>/dev/null \
131 && grep -q 'i=24' "$LOG" 2>/dev/null && grep -q 'i=20' "$LOG" 2>/dev/null \
132 && grep -q 'i=72' "$LOG" 2>/dev/null && grep -q 'i=76' "$LOG" 2>/dev/null \
133 && grep -q 'i=78' "$LOG" 2>/dev/null && grep -q 'i=82' "$LOG" 2>/dev/null; then sleep 1; break; fi
126#
127# The budget is wall-clock, not iterations. It used to be `seq 1 60` around a
128# body that runs fourteen greps, which on this laptop is about a minute and on
129# a loaded CI runner is nearer three — and when it ran out the loop simply fell
130# through to the assertions below, which then failed on whichever late marker
131# had not arrived yet. That is a timeout wearing the costume of a wrong result:
132# the first CI run of this fixture reported "the deletion completion never
133# fired, or its reply was lost" when the truth was that the app was still
134# working through the ids before it.
135#
136# So: a deadline, and an explicit failure that names what is missing. The two
137# situations look nothing alike now.
138SLICE_TIMEOUT="${CRAFT_SLICE_TIMEOUT:-600}"
139AWAITED_IDS="20 22 24 26 28 32 34 38 44 46 72 76 78 82"
140DEADLINE=$(( $(date +%s) + SLICE_TIMEOUT ))
141
142missing_ids() {
143 local missing=""
144 for id in $AWAITED_IDS; do
145 # ERE with an explicit boundary rather than \b, which is a GNU
146 # extension this script cannot count on, and rather than a bare
147 # "i=$id", which matches i=260 when it is looking for i=26.
148 grep -qE "i=$id(\$|[^0-9])" "$LOG" 2>/dev/null || missing="$missing i=$id"
149 done
150 echo "$missing"
151}
152
153while :; do
154 MISSING="$(missing_ids)"
155 [ -z "$MISSING" ] && { sleep 1; break; }
156 if [ "$(date +%s)" -ge "$DEADLINE" ]; then
157 echo "==> console"
158 cat "$LOG" || true
159 echo "FAIL: timed out after ${SLICE_TIMEOUT}s waiting for:$MISSING"
160 echo " The app never got that far — this is a timeout, not a wrong answer."
161 echo " Raise CRAFT_SLICE_TIMEOUT if the machine is simply slow."
162 kill "$LAUNCH_PID" 2>/dev/null || true
163 exit 1
164 fi
134165 sleep 1
135166done
136167kill "$LAUNCH_PID" 2>/dev/null || true
packages/ios/src/index.test.tsmodified+17-0
Changes to packages/ios/src/index.test.ts
@@ -350,6 +350,23 @@ describe('choosing a simulator', () => {
350350 expect(chosen?.name).toBe('iPhone 17 Pro')
351351 })
352352
353 it('ignores simulators that cannot run an iOS app', () => {
354 // Booted sorts ahead of everything, so an open Apple Watch or Apple TV
355 // simulator would otherwise be handed back as the place to install an
356 // iphonesimulator build.
357 const chosen = orderSimulators([
358 device('Apple Watch Series 10', 'watchOS-11-0', 'Booted'),
359 device('Apple TV 4K', 'tvOS-18-0', 'Booted'),
360 device('iPhone 17', 'iOS-27-0'),
361 ])[0]
362
363 expect(chosen?.name).toBe('iPhone 17')
364 })
365
366 it('returns nothing when only non-iOS simulators exist', () => {
367 expect(orderSimulators([device('Apple Watch Series 10', 'watchOS-11-0', 'Booted')])[0]).toBeUndefined()
368 })
369
353370 it('and never invents one that is not installed', () => {
354371 // The regression in one line: no devices means no device, rather than a
355372 // hard-coded name xcodebuild will refuse.
packages/ios/src/index.tsmodified+19-12
Changes to packages/ios/src/index.ts
@@ -819,23 +819,30 @@ export interface SimulatorDevice {
819819}
820820
821821/**
822 * Every available simulator, newest runtime first, iPhones before iPads.
822 * Every available *iOS* simulator, newest runtime first, iPhones before iPads.
823823 *
824824 * Exported because the choice is worth testing without a simulator present -
825825 * `pickSimulator` is the policy and this is the data it reads.
826826 */
827827export function orderSimulators(devices: SimulatorDevice[]): SimulatorDevice[] {
828 return [...devices].sort((left, right) => {
829 const booted = Number(right.state === 'Booted') - Number(left.state === 'Booted')
830 if (booted !== 0)
831 return booted
832
833 const phone = Number(right.name.startsWith('iPhone')) - Number(left.name.startsWith('iPhone'))
834 if (phone !== 0)
835 return phone
836
837 return right.runtime.localeCompare(left.runtime)
838 })
828 // iOS runtimes only, before anything else is considered. A booted device
829 // sorts ahead of every shut-down one, and a developer with an Apple Watch or
830 // Apple TV simulator open would otherwise have it handed back as the place
831 // to install an iphonesimulator build - which fails at `simctl install`,
832 // naming a device nobody asked for.
833 return devices
834 .filter(device => device.runtime.startsWith('iOS'))
835 .sort((left, right) => {
836 const booted = Number(right.state === 'Booted') - Number(left.state === 'Booted')
837 if (booted !== 0)
838 return booted
839
840 const phone = Number(right.name.startsWith('iPhone')) - Number(left.name.startsWith('iPhone'))
841 if (phone !== 0)
842 return phone
843
844 return right.runtime.localeCompare(left.runtime)
845 })
839846}
840847
841848/**
packages/ios/templates/CraftApp.swiftmodified+19-1
Changes to packages/ios/templates/CraftApp.swift
@@ -1445,11 +1445,29 @@ struct CraftWebView: UIViewRepresentable {
14451445
14461446 private func isTrustedURL(_ url: URL?) -> Bool {
14471447 guard let url = url else { return false }
1448 if url.scheme == "craft" && url.host == "app" { return true }
14491448 return isTrustedOrigin(scheme: url.scheme ?? "", host: url.host ?? "", port: url.port ?? 0)
14501449 }
14511450
1451 /// The single answer to "may this origin reach native?".
1452 ///
1453 /// The bundled app is served from craft://app by
1454 /// `BundledAssetSchemeHandler`, and it is the only content a generated
1455 /// app loads when no dev server is configured. That origin used to be
1456 /// trusted for *navigation* only: `isTrustedURL` carried the clause,
1457 /// while the `userContentController` guard called this function
1458 /// directly and fell through to the https/localhost check. So every
1459 /// bridge call from the app's own page was answered with
1460 ///
1461 /// Blocked Craft bridge message from untrusted origin: craft://app
1462 ///
1463 /// and its promise never settled — the whole native surface was dead
1464 /// in the default configuration, which is the one every generated app
1465 /// ships with. Two guards that had to agree, and did not. There is one
1466 /// now, and `scripts/mobile-e2e.ts` runs a real round trip on a booted
1467 /// simulator so a future hardening pass cannot quietly take the bridge
1468 /// away again.
14521469 private func isTrustedOrigin(scheme: String, host: String, port: Int) -> Bool {
1470 if scheme == "craft" && host == "app" { return true }
14531471 if scheme == "file" { return true }
14541472 guard scheme == "https" || (scheme == "http" && ["localhost", "127.0.0.1", "::1"].contains(host)) else { return false }
14551473 let defaultPort = scheme == "https" ? 443 : 80
packages/zig/build.zigmodified+182-30
Changes to packages/zig/build.zig
@@ -15,6 +15,19 @@ pub fn build(b: *std.Build) void {
1515 // to avoid Zig bug where --sysroot breaks @cImport (ziglang/zig#22704, #25010)
1616 const macos_sdk = b.option([]const u8, "macos-sdk", "macOS SDK path for cross-compilation");
1717
18 // The Android NDK, needed by the JNI library and nothing else. An explicit
19 // path rather than a read of ANDROID_NDK_HOME, matching -Dmacos-sdk: the
20 // caller passes "$ANDROID_NDK_HOME" when that is what they mean, and a
21 // build that depends on an environment variable is a build that behaves
22 // differently in two shells for reasons neither of them shows.
23 //
24 // It stopped being optional because without bionic the library builds,
25 // ships, and then fails to load — std references getauxval and
26 // __tls_get_addr, both of which live there, and Zig answers "unable to
27 // provide libc" for an android target. The failure was a silent fallback
28 // to the Kotlin shim, which is the quietest of the three outcomes.
29 const android_ndk = b.option([]const u8, "android-ndk", "Android NDK root, required by build-android");
30
1831 // Set when the opt-in JavaScript tests are wired up, so `test` can include
1932 // them without the declaration being scoped inside the `if`.
2033 var js_test_run: ?*std.Build.Step.Run = null;
@@ -2391,6 +2404,11 @@ pub fn build(b: *std.Build) void {
23912404 .cpu_arch = .aarch64,
23922405 .os_tag = .linux,
23932406 .abi = .android,
2407 // Named explicitly, because Zig's default is 29 and the stubs this
2408 // links against come from the API-26 directory. A target and a sysroot
2409 // that disagree is how a library binds a symbol the device does not
2410 // have.
2411 .android_api_level = android_api_level,
23942412 });
23952413
23962414 const android_arm64_lib = b.addLibrary(.{
@@ -2425,46 +2443,74 @@ pub fn build(b: *std.Build) void {
24252443 // the arch-suffixed name beside it: the loader picks the ABI directory,
24262444 // and a per-arch name would make the Kotlin need to know its own
24272445 // architecture.
2428 const android_arm64_jni = b.addLibrary(.{
2429 .linkage = .dynamic,
2430 .name = "craft",
2431 .root_module = b.createModule(.{
2432 .root_source_file = b.path("src/android_dispatch.zig"),
2433 .target = android_arm64_target,
2434 .optimize = optimize,
2435 }),
2436 });
2446 if (android_ndk) |ndk| {
2447 const android_arm64_jni = b.addLibrary(.{
2448 .linkage = .dynamic,
2449 .name = "craft",
2450 .root_module = b.createModule(.{
2451 .root_source_file = b.path("src/android_dispatch.zig"),
2452 .target = android_arm64_target,
2453 .optimize = optimize,
2454 }),
2455 });
2456 linkAndroidLibc(b, android_arm64_jni, ndk, "aarch64-linux-android");
24372457
2438 const android_arm64_jni_install = b.addInstallArtifact(android_arm64_jni, .{
2439 .dest_dir = .{ .override = .{ .custom = "android/arm64-v8a" } },
2440 });
2441 build_android.dependOn(&android_arm64_jni_install.step);
2442 build_android_all.dependOn(&android_arm64_jni_install.step);
2458 const android_arm64_jni_install = b.addInstallArtifact(android_arm64_jni, .{
2459 .dest_dir = .{ .override = .{ .custom = "android/arm64-v8a" } },
2460 });
2461 build_android.dependOn(&android_arm64_jni_install.step);
2462 build_android_all.dependOn(&android_arm64_jni_install.step);
2463 } else {
2464 // Not built at all rather than built without bionic. The library would
2465 // compile, install, ship in the APK and then fail at `dlopen` with two
2466 // unresolved symbols, at which point `CraftNative` catches the
2467 // `UnsatisfiedLinkError` and every action quietly uses the Kotlin shim.
2468 // A step that refuses, naming the flag, is the same information
2469 // delivered where someone can act on it.
2470 //
2471 // A `Fail` step rather than a panic, so this is only reached by
2472 // *asking* for an Android build: `zig build test` on a machine with no
2473 // NDK must still work, and it did not while this was a configure-time
2474 // panic.
2475 const missing = b.addFail(
2476 "build-android needs the NDK: pass -Dandroid-ndk=\"$ANDROID_NDK_HOME\" " ++
2477 "(sdkmanager --install \"ndk;26.1.10909125\"). Without bionic the JNI library " ++
2478 "builds and then loads nowhere — std references getauxval and __tls_get_addr, " ++
2479 "both of which live there, and Zig cannot supply libc for an android target.",
2480 );
2481 build_android.dependOn(&missing.step);
2482 build_android_x86.dependOn(&missing.step);
2483 build_android_all.dependOn(&missing.step);
2484 }
24432485
24442486 // Android Emulator (x86_64)
24452487 const android_x86_target = b.resolveTargetQuery(.{
24462488 .cpu_arch = .x86_64,
24472489 .os_tag = .linux,
24482490 .abi = .android,
2491 .android_api_level = android_api_level,
24492492 });
24502493
2451 const android_x86_jni = b.addLibrary(.{
2452 .linkage = .dynamic,
2453 .name = "craft",
2454 .root_module = b.createModule(.{
2455 .root_source_file = b.path("src/android_dispatch.zig"),
2456 .target = android_x86_target,
2457 .optimize = optimize,
2458 }),
2459 });
2494 if (android_ndk) |ndk| {
2495 const android_x86_jni = b.addLibrary(.{
2496 .linkage = .dynamic,
2497 .name = "craft",
2498 .root_module = b.createModule(.{
2499 .root_source_file = b.path("src/android_dispatch.zig"),
2500 .target = android_x86_target,
2501 .optimize = optimize,
2502 }),
2503 });
2504 linkAndroidLibc(b, android_x86_jni, ndk, "x86_64-linux-android");
24602505
2461 // `x86_64`, matching the jniLibs directory the loader looks in — not
2462 // `x86`, which is the 32-bit ABI and would be silently ignored.
2463 const android_x86_jni_install = b.addInstallArtifact(android_x86_jni, .{
2464 .dest_dir = .{ .override = .{ .custom = "android/x86_64" } },
2465 });
2466 build_android_x86.dependOn(&android_x86_jni_install.step);
2467 build_android_all.dependOn(&android_x86_jni_install.step);
2506 // `x86_64`, matching the jniLibs directory the loader looks in — not
2507 // `x86`, which is the 32-bit ABI and would be silently ignored.
2508 const android_x86_jni_install = b.addInstallArtifact(android_x86_jni, .{
2509 .dest_dir = .{ .override = .{ .custom = "android/x86_64" } },
2510 });
2511 build_android_x86.dependOn(&android_x86_jni_install.step);
2512 build_android_all.dependOn(&android_x86_jni_install.step);
2513 }
24682514
24692515 const android_x86_lib = b.addLibrary(.{
24702516 .linkage = .static,
@@ -2524,6 +2570,112 @@ pub fn build(b: *std.Build) void {
25242570/// Link platform-specific system libraries for a build module.
25252571/// Centralizes the per-OS library linking that is shared across exe, craft_exe,
25262572/// lib_unit_tests, and cross-compilation targets.
2573/// The Android API level the JNI library targets.
2574///
2575/// Must not exceed the generator's default `minSdk`, because one prebuilt
2576/// library serves every generated app: building against a newer API would let
2577/// the linker bind symbols that are simply absent on an older device, and the
2578/// failure is `dlopen` refusing the library at startup on exactly the phones
2579/// the app claims to support. `packages/android/src/index.test.ts` asserts the
2580/// two numbers agree, since nothing else connects a Zig constant to a
2581/// TypeScript one.
2582const android_api_level: u32 = 26;
2583
2584/// The NDK's sysroot, or null when `ndk` does not contain one.
2585///
2586/// The prebuilt directory is named for the *host* and the NDK ships exactly
2587/// one, so the candidates are tried in turn rather than guessed from
2588/// `builtin.os`. An Apple-silicon Mac still gets `darwin-x86_64` from most NDK
2589/// releases, which is the guess that would have been wrong.
2590///
2591/// Null rather than a panic: this runs at configure time, so a panic here
2592/// would break `zig build test` for anyone who passed a wrong -Dandroid-ndk,
2593/// not just the Android steps.
2594fn androidSysroot(b: *std.Build, ndk: []const u8) ?[]const u8 {
2595 const io = b.graph.io;
2596 for ([_][]const u8{ "darwin-x86_64", "darwin-arm64", "linux-x86_64", "windows-x86_64" }) |host| {
2597 const candidate = b.pathJoin(&.{ ndk, "toolchains/llvm/prebuilt", host, "sysroot" });
2598 std.Io.Dir.cwd().access(io, candidate, .{}) catch continue;
2599 return candidate;
2600 }
2601 return null;
2602}
2603
2604/// Link one Android artifact against the NDK's bionic.
2605///
2606/// A `--libc` file, not include and library paths. Paths alone are what
2607/// `applySdkPaths` does for Apple SDKs and they are not enough here: with
2608/// `link_libc` set and no libc file, Zig looks the target up in its own table
2609/// of libcs it can build, does not find android, and stops with
2610///
2611/// error: unable to provide libc for target "x86_64-linux...-android.29"
2612///
2613/// before it ever consults a search path. The libc file is how Zig is told the
2614/// libc already exists.
2615///
2616/// `gcc_dir` is not optional, and its absence is reported under the wrong
2617/// name. For any `.linux` target — Android is one — Zig takes crtbegin and
2618/// crtend from `gcc_dir`, not `crt_dir`, and returns
2619/// `LibCInstallationMissingCrtDir` when `gcc_dir` is empty
2620/// (std/zig/LibCInstallation.zig, `resolveCrtPaths`). So a file with a correct
2621/// `crt_dir` and a blank `gcc_dir` fails with "libc installation is missing crt
2622/// directory", which sends you checking a directory that is fine. Bionic keeps
2623/// crtbegin_so.o and crtend_so.o beside its stubs, so both fields point at the
2624/// same API-level directory. Zig 0.17.0-dev.1441 read them from `crt_dir`,
2625/// which is why this passed locally and failed on CI's 1963.
2626///
2627/// Still not `--sysroot`, which breaks `@cImport` (ziglang/zig#22704, #25010)
2628/// and is why the Apple side takes the path route too.
2629fn linkAndroidLibc(b: *std.Build, compile: *std.Build.Step.Compile, ndk: []const u8, triple: []const u8) void {
2630 const io = b.graph.io;
2631
2632 const sysroot = androidSysroot(b, ndk) orelse {
2633 compile.step.dependOn(&b.addFail(b.fmt(
2634 "-Dandroid-ndk={s} has no toolchains/llvm/prebuilt/<host>/sysroot. " ++
2635 "Point it at an NDK root, not at the SDK.",
2636 .{ndk},
2637 )).step);
2638 return;
2639 };
2640
2641 const api = b.fmt("{d}", .{android_api_level});
2642 const lib_dir = b.pathJoin(&.{ sysroot, "usr/lib", triple, api });
2643
2644 // Checked here so a missing API level fails with the path and the fix,
2645 // rather than as a linker error about crtbegin_so.o. Lazy, like the NDK
2646 // check: only a build that asks for this artifact is refused.
2647 std.Io.Dir.cwd().access(io, b.pathJoin(&.{ lib_dir, "crtbegin_so.o" }), .{}) catch {
2648 compile.step.dependOn(&b.addFail(b.fmt(
2649 "no crtbegin_so.o in {s}: this NDK does not carry API {s} for {s}. " ++
2650 "Install one that does, or lower android_api_level.",
2651 .{ lib_dir, api, triple },
2652 )).step);
2653 return;
2654 };
2655
2656 const libc_file = b.addWriteFile("android-libc.conf", b.fmt(
2657 \\include_dir={s}
2658 \\sys_include_dir={s}
2659 \\crt_dir={s}
2660 \\msvc_lib_dir=
2661 \\kernel32_lib_dir=
2662 \\gcc_dir={s}
2663 \\
2664 , .{
2665 b.pathJoin(&.{ sysroot, "usr/include" }),
2666 // The triple-specific headers: bionic keeps its architecture-dependent
2667 // definitions there and the generic ones include them by relative path.
2668 b.pathJoin(&.{ sysroot, "usr/include", triple }),
2669 lib_dir,
2670 lib_dir,
2671 }));
2672
2673 compile.setLibCFile(libc_file.getDirectory().path(b, "android-libc.conf"));
2674 compile.step.dependOn(&libc_file.step);
2675 compile.root_module.link_libc = true;
2676 compile.root_module.addLibraryPath(.{ .cwd_relative = lib_dir });
2677}
2678
25272679/// The iOS SDK sysroot, resolved through xcrun at configure time.
25282680///
25292681/// Needed because the iOS archives compile the vendored sqlite3.c, and C
packages/zig/src/android_dispatch.zigmodified+45-7
Changes to packages/zig/src/android_dispatch.zig
@@ -36,6 +36,7 @@
3636const std = ;
3737const builtin = ;
3838const jni = ;
39const android_log = ;
3940const permissions = ;
4041const device = ;
4142const system = ;
@@ -76,12 +77,24 @@ const Jni = jni.Jni;
7677
7778/// The allocator every reply is built with.
7879///
79/// `page_allocator`, and the choice is a build constraint rather than a
80/// preference. `c_allocator` would pull in bionic, and Zig cannot provide
81/// bionic — a static Android library links nothing and builds anyway, but this
82/// one is shared and has to resolve its symbols, so libc here means the whole
83/// library needs the NDK to build at all. Standing free of libc keeps it
84/// buildable with nothing but Zig.
80/// This used to carry a note explaining that `page_allocator` was chosen over
81/// `c_allocator` to keep the library buildable with nothing but Zig, since
82/// bionic would mean needing the NDK. That reasoning was sound and the
83/// conclusion was wrong: standing free of libc does not make the references go
84/// away, it only defers them. `page_allocator` needs `getauxval` for the page
85/// size, any `threadlocal` needs `__tls_get_addr`, and so does `std.fmt` — so
86/// the library linked clean, shipped, and then failed at `dlopen` with
87///
88/// cannot locate symbol "__tls_get_addr" referenced by ".../libcraft.so"
89///
90/// which `CraftNative` caught as an `UnsatisfiedLinkError` and turned into a
91/// silent fallback to the Kotlin shim. Every generated Android app was in that
92/// state; the mobile E2E suite is what finally said so out loud.
93///
94/// So the library links bionic now and `build-android` requires the NDK
95/// (`build.zig`'s -Dandroid-ndk). The allocator is free to be whichever one
96/// suits; it stays `page_allocator` because the arena below wants pages
97/// anyway.
8598///
8699/// Every native call wraps this in an arena, so the page granularity costs one
87100/// page per call rather than one per allocation, and nothing has to be freed
@@ -97,6 +110,17 @@ const backing = std.heap.page_allocator;
97110const request_calendar: i32 = 1005;
98111
99112/// The shim's default event length: `System.currentTimeMillis() + 3600000`.
113/// The log handler, installed here because this file is the root module of the
114/// JNI library and `std_options` only takes effect from a root.
115///
116/// Without it every `std.log` line below goes to stderr, which ART discards —
117/// including `JNI_OnLoad`'s report of a refused registration, which is the one
118/// message worth having. `android_log.zig` says more about why it routes
119/// through `android.util.Log` rather than liblog.
120pub const std_options: std.Options = .{
121 .logFn = android_log.logFn,
122};
123
100124const one_hour_ms: i64 = 60 * 60 * 1000;
101125
102126/// `CraftBridge.REQUEST_CONTACTS`.
@@ -137,13 +161,27 @@ pub const holder_class = "com/craft/runtime/CraftNative";
137161/// linked.
138162pub export fn JNI_OnLoad(vm_handle: jni.JavaVM, _: ?*anyopaque) callconv(.c) jni.jint {
139163 java_vm = vm_handle;
164 // The log channel first, so the registration report below has somewhere to
165 // go. It is the only diagnosis available for a library that loaded and
166 // failed to bind.
167 android_log.setVm(vm_handle);
140168 // The reply channel needs the same handle, and needs it before any action
141169 // runs: a callback that fires between load and the first native call has
142170 // nowhere to deliver otherwise.
143171 events.setVm(vm_handle);
144172
145173 switch (bindNatives(vm_handle)) {
146 .registered => {},
174 // Said out loud on the happy path, not only on the failures below.
175 // "The library is loaded and bound" and "the library was never
176 // shipped" are otherwise both silent, and they are the two states
177 // anyone debugging this needs to tell apart first. It is also the only
178 // line that proves the log channel itself works: a diagnostic that
179 // only speaks when something is wrong cannot be trusted when it says
180 // nothing.
181 .registered => std.log.info(
182 "craft: registered {d} natives on {s}",
183 .{ natives.len, holder_class },
184 ),
147185 .no_env => std.log.err(
148186 "craft: no JNIEnv at load; every action stays on the Kotlin shim",
149187 .{},
packages/zig/src/android_events.zigmodified+9-33
Changes to packages/zig/src/android_events.zig
@@ -26,9 +26,13 @@
2626//! which is the normal case for a callback arriving on a framework thread,
2727//! exactly like a `CLLocationManager` delegate on iOS.
2828//!
29//! A thread this file attaches, this file detaches. Leaving one attached keeps
30//! the JVM's thread record alive after the OS thread is gone, and the crash
31//! surfaces somewhere unrelated much later.
29//! A thread this file attaches, this file detaches: `jni.attachCurrentThread`
30//! reports whether the attachment was ours, and `Attachment.release` gives
31//! back only those. Leaving one attached keeps the JVM's thread record alive
32//! after the OS thread is gone, and the crash surfaces somewhere unrelated
33//! much later. That dance lives in `jni_runtime.zig` rather than here because
34//! the log channel needs it too, and a second copy of it is how the two would
35//! drift apart.
3236
3337const std = ;
3438const jni = ;
@@ -66,34 +70,6 @@ pub fn resetDroppedForTest() void {
6670 dropped = 0;
6771}
6872
69/// This thread's env, attaching if it has none.
70///
71/// Returns whether the caller must detach. A thread the JVM created — the UI
72/// thread, a binder thread — is already attached and must **not** be detached
73/// by us; a thread Zig or a native library created must be.
74const Attachment = struct {
75 env: jni.JNIEnv,
76 owned: bool,
77};
78
79fn attach(vm: jni.JavaVM) ?Attachment {
80 if (jni.envForThisThread(vm)) |env| return .{ .env = env, .owned = false };
81
82 const attach_fn: *const fn (jni.JavaVM, *?*anyopaque, ?*anyopaque) callconv(.c) jni.jint =
83 @ptrCast(vm.*.AttachCurrentThread orelse return null);
84
85 var env_ptr: ?*anyopaque = null;
86 if (attach_fn(vm, &env_ptr, null) != jni.JNI_OK) return null;
87 const env: jni.JNIEnv = @ptrCast(@alignCast(env_ptr orelse return null));
88 return .{ .env = env, .owned = true };
89}
90
91fn detach(vm: jni.JavaVM) void {
92 const detach_fn: *const fn (jni.JavaVM) callconv(.c) jni.jint =
93 @ptrCast(vm.*.DetachCurrentThread orelse return);
94 _ = detach_fn(vm);
95}
96
9773/// Hand `script` to `CraftNative.deliver`, from any thread.
9874///
9975/// Silent on every failure, and deliberately: the caller is a device callback
@@ -105,11 +81,11 @@ pub fn evaluate(allocator: std.mem.Allocator, script: []const u8) void {
10581 return;
10682 };
10783
108 const attachment = attach(vm) orelse {
84 const attachment = jni.attachCurrentThread(vm) orelse {
10985 dropped += 1;
11086 return;
11187 };
112 defer if (attachment.owned) detach(vm);
88 defer attachment.release(vm);
11389
11490 deliverThrough(allocator, attachment.env, script) catch {
11591 dropped += 1;
packages/zig/src/android_log.zigadded+181-0
Changes to packages/zig/src/android_log.zig
@@ -0,0 +1,181 @@
1//! Zig's `std.log` output, routed to logcat through `android.util.Log`.
2//!
3//! Before this, every `std.log` line in the Android build went to stderr, and
4//! ART discards stderr. `JNI_OnLoad` reports a refused `RegisterNatives` that
5//! way, so three quite different states were indistinguishable from outside:
6//! the library was never shipped, the library loaded but a descriptor was
7//! wrong, or the library loaded and bound and the action simply declined. All
8//! three end with the Kotlin shim answering and nothing anywhere saying which
9//! happened.
10//!
11//! Through Java rather than `__android_log_write`, because this library
12//! deliberately links no libc — `android_dispatch.zig` records why, and the
13//! same reasoning rules out `dlopen`ing liblog, since libdl is not linked
14//! either. JNI calls are the one thing it can already do, and `android.util.Log`
15//! is reachable that way with no new link dependency at all.
16//!
17//! iOS has never needed this: `ios_dispatch.zig`'s lines reach the device
18//! console as they are, which is what the mobile E2E suite reads to prove the
19//! Zig runtime served a call. This is the Android counterpart.
20
21const std = ;
22const jni = ;
23
24/// The logcat tag. Distinct from the Kotlin shim's `CraftBridge`, so a reader
25/// can tell which side of the hand-off produced a line.
26pub const tag = "CraftNative";
27
28/// Longest line that survives intact. Longer ones are replaced by their own
29/// format string, which is comptime-known and still names the log site.
30const max_line = 1024;
31
32var java_vm: ?jni.JavaVM = null;
33var dropped: std.atomic.Value(usize) = .init(0);
34
35/// Guards against a log inside a log. Everything below can fail, and the
36/// failure paths must stay silent — one bad JNI call would otherwise become an
37/// unbounded stack of them. Thread-local because two threads logging at once
38/// is normal and neither should mute the other.
39threadlocal var writing: bool = false;
40
41/// Give the log channel the VM, from `JNI_OnLoad`.
42///
43/// Until this is called there is nowhere to write, which covers the window
44/// before the library is loaded. Lines in that window are counted, not queued:
45/// a queue would have to be bounded, and a bound is another thing to get wrong
46/// for output nobody is reading yet.
47pub fn setVm(vm: jni.JavaVM) void {
48 java_vm = vm;
49}
50
51/// Lines that could not be written. A diagnosis reads this rather than
52/// wondering whether the code was silent or the channel was.
53pub fn droppedCount() usize {
54 return dropped.load(.monotonic);
55}
56
57pub fn resetForTest() void {
58 java_vm = null;
59 dropped.store(0, .monotonic);
60}
61
62/// `std.Options.logFn`, installed by `android_dispatch.zig`.
63pub fn logFn(
64 comptime message_level: std.log.Level,
65 comptime scope: @EnumLiteral(),
66 comptime format: []const u8,
67 args: anytype,
68) void {
69 if (writing) return;
70 writing = true;
71 defer writing = false;
72
73 // The scope is prefixed rather than dropped, the same choice
74 // `src/minimal.zig` makes: `std.log.scoped(...)` is used throughout craft
75 // and the scope is most of what makes a line findable.
76 const prefix = if (scope == .default) "" else "[" ++ @tagName(scope) ++ "] ";
77
78 var buffer: [max_line]u8 = undefined;
79 // On overflow, the format string itself — comptime-known, no arguments to
80 // render, and it still says which log site fired. A truncated line would
81 // be better still, but `bufPrint` does not report how far it got.
82 const text = std.fmt.bufPrint(&buffer, prefix ++ format, args) catch prefix ++ format;
83
84 write(message_level, text);
85}
86
87fn write(level: std.log.Level, text: []const u8) void {
88 const vm = java_vm orelse {
89 _ = dropped.fetchAdd(1, .monotonic);
90 return;
91 };
92
93 const attachment = jni.attachCurrentThread(vm) orelse {
94 _ = dropped.fetchAdd(1, .monotonic);
95 return;
96 };
97 defer attachment.release(vm);
98
99 writeThrough(attachment.env, level, text) catch {
100 _ = dropped.fetchAdd(1, .monotonic);
101 };
102}
103
104fn writeThrough(env: jni.JNIEnv, level: std.log.Level, text: []const u8) !void {
105 const j = jni.Jni.init(env);
106
107 // A frame around the class and the two strings. Logging is the one thing
108 // that can happen on any thread at any rate, so leaking a local ref per
109 // line would overflow the JVM's sixteen slots and abort the process —
110 // turning a diagnostic into the fault it was meant to diagnose.
111 try j.pushLocalFrame(8);
112 defer _ = j.popLocalFrame(null);
113
114 // Stack, not the heap: a logger that allocates is a logger that cannot
115 // report an allocation failure. Sized for the tag plus a `max_line`
116 // message re-encoded to modified UTF-8.
117 var storage: [4 * max_line]u8 = undefined;
118 var fba = std.heap.FixedBufferAllocator.init(&storage);
119 const allocator = fba.allocator();
120
121 const cls = try j.findClass("android/util/Log");
122 const tag_string = try j.newStringUtf8(allocator, tag);
123 const message = try j.newStringUtf8(allocator, text);
124
125 const method = try j.staticMethodId(cls, methodFor(level), "(Ljava/lang/String;Ljava/lang/String;)I");
126 _ = try j.callStaticIntMethodA(cls, method, &.{ .{ .l = tag_string }, .{ .l = message } });
127}
128
129/// `android.util.Log`'s one-letter method for each Zig level.
130///
131/// Named methods rather than `Log.println(priority, …)` so the priority
132/// constants do not have to be mirrored here: getting `WARN` wrong by one
133/// would misfile every warning under a level that reads as routine.
134pub fn methodFor(level: std.log.Level) [*:0]const u8 {
135 return switch (level) {
136 .err => "e",
137 .warn => "w",
138 .info => "i",
139 .debug => "d",
140 };
141}
142
143const testing = std.testing;
144
145test "every level maps to a real android.util.Log method" {
146 // The four one-letter statics on android.util.Log, and nothing else. A
147 // typo here fails at `GetStaticMethodID` on a device and nowhere before.
148 for ([_]std.log.Level{ .err, .warn, .info, .debug }) |level| {
149 const name = std.mem.span(methodFor(level));
150 try testing.expectEqual(@as(usize, 1), name.len);
151 try testing.expect(std.mem.indexOfScalar(u8, "ewid", name[0]) != null);
152 }
153}
154
155test "levels do not share a method" {
156 const levels = [_]std.log.Level{ .err, .warn, .info, .debug };
157 for (levels, 0..) |left, i| {
158 for (levels[i + 1 ..]) |right| {
159 try testing.expect(!std.mem.eql(
160 u8,
161 std.mem.span(methodFor(left)),
162 std.mem.span(methodFor(right)),
163 ));
164 }
165 }
166}
167
168test "a line with no VM is counted rather than written" {
169 resetForTest();
170 defer resetForTest();
171
172 try testing.expectEqual(@as(usize, 0), droppedCount());
173 write(.warn, "nowhere to put this");
174 try testing.expectEqual(@as(usize, 1), droppedCount());
175}
176
177test "the tag is not the shim's" {
178 // Both reach logcat, and telling Zig's output from Kotlin's is the whole
179 // point of the Android half of the E2E suite's attribution.
180 try testing.expect(!std.mem.eql(u8, tag, "CraftBridge"));
181}
packages/zig/src/bridge_android_device.zigmodified+125-18
Changes to packages/zig/src/bridge_android_device.zig
@@ -146,15 +146,53 @@ fn appendString(
146146// The JNI half
147147// =============================================================================
148148
149/// `Build.FINGERPRINT.contains("generic") || contains("emulator")`.
149/// The `Build` fields the emulator test reads.
150pub const BuildIdentity = struct {
151 hardware: []const u8,
152 fingerprint: []const u8,
153 product: []const u8,
154 model: []const u8,
155 manufacturer: []const u8,
156 brand: []const u8,
157};
158
159/// Whether this is an emulator, asking exactly what `CraftBridge.isEmulator()`
160/// asks.
161///
162/// Kept as a function so it can be asserted without a device, and kept in step
163/// with the Kotlin deliberately: the whole point of the hand-off is that a page
164/// cannot tell which side answered, and two emulator tests that disagree would
165/// make `isEmulator` depend on whether libcraft.so happened to load.
150166///
151/// Kotlin's own test, kept as a function so it can be asserted without a
152/// device. Deliberately not widened: the Kotlin does not check `"sdk"`,
153/// `"vbox"` or the other markers a more thorough emulator test would, and this
154/// answers the same question the shim answers.
155pub fn fingerprintLooksEmulated(fingerprint: []const u8) bool {
156 return std.mem.indexOf(u8, fingerprint, "generic") != null or
157 std.mem.indexOf(u8, fingerprint, "emulator") != null;
167/// Both sides used to be `FINGERPRINT.contains("generic") ||
168/// contains("emulator")`, which answers false on the AVDs anyone actually runs
169/// - a current Google APIs image fingerprints as
170/// `google/sdk_gphone64_x86_64/emu64xa:14/...` and contains neither word. The
171/// mobile E2E suite caught it on an API 34 emulator.
172///
173/// Note the last line is case-sensitive on both sides. That is the Kotlin's
174/// behaviour, not an oversight here.
175pub fn looksEmulated(build: BuildIdentity) bool {
176 const ascii = std.ascii;
177
178 if (ascii.startsWithIgnoreCase(build.hardware, "goldfish") or
179 ascii.startsWithIgnoreCase(build.hardware, "ranchu")) return true;
180
181 if (ascii.startsWithIgnoreCase(build.fingerprint, "generic") or
182 ascii.startsWithIgnoreCase(build.fingerprint, "unknown")) return true;
183 if (ascii.findIgnoreCase(build.fingerprint, "emulator") != null or
184 ascii.findIgnoreCase(build.fingerprint, "sdk_gphone") != null) return true;
185
186 if (ascii.startsWithIgnoreCase(build.product, "sdk") or
187 ascii.findIgnoreCase(build.product, "_sdk") != null or
188 ascii.findIgnoreCase(build.product, "sdk_") != null) return true;
189
190 if (ascii.findIgnoreCase(build.model, "google_sdk") != null or
191 ascii.findIgnoreCase(build.model, "emulator") != null or
192 ascii.findIgnoreCase(build.model, "android sdk built for") != null) return true;
193
194 return std.mem.indexOf(u8, build.manufacturer, "Genymotion") != null or
195 std.mem.startsWith(u8, build.brand, "generic");
158196}
159197
160198/// Read a `static final String` off a class, as UTF-8.
@@ -200,6 +238,10 @@ pub fn read(allocator: std.mem.Allocator, j: Jni, activity: jobject) !DeviceInfo
200238
201239 const fingerprint = try staticString(allocator, j, build, "FINGERPRINT");
202240 defer allocator.free(fingerprint);
241 const hardware = try staticString(allocator, j, build, "HARDWARE");
242 defer allocator.free(hardware);
243 const product = try staticString(allocator, j, build, "PRODUCT");
244 defer allocator.free(product);
203245
204246 // `Build$VERSION` — a nested class, and the `$` is not decoration: the JVM
205247 // knows this type by that binary name and `android/os/Build/VERSION` finds
@@ -227,7 +269,14 @@ pub fn read(allocator: std.mem.Allocator, j: Jni, activity: jobject) !DeviceInfo
227269 .screen_height = metrics.height,
228270 .density = metrics.density,
229271 .app_build = package.build,
230 .is_emulator = fingerprintLooksEmulated(fingerprint),
272 .is_emulator = looksEmulated(.{
273 .hardware = hardware,
274 .fingerprint = fingerprint,
275 .product = product,
276 .model = model,
277 .manufacturer = manufacturer,
278 .brand = brand,
279 }),
231280 };
232281}
233282
@@ -450,15 +499,73 @@ test "density renders the way JSONObject renders a float" {
450499}
451500
452501test "the emulator test asks exactly what the Kotlin asks" {
453 // Real fingerprints, and the two markers the shim looks for.
454 try testing.expect(fingerprintLooksEmulated("generic/sdk_gphone64_arm64/emu64a:14/UE1A.230829.036/11228894:user/release-keys"));
455 try testing.expect(fingerprintLooksEmulated("Android/aosp_cf_x86_64_phone/emulator:13/x/y:userdebug/test-keys"));
456 try testing.expect(!fingerprintLooksEmulated("google/husky/husky:14/AP1A.240405.002/11480754:user/release-keys"));
457 try testing.expect(!fingerprintLooksEmulated("samsung/dm3qxxx/dm3q:14/UP1A.231005.007/S918BXXU4BWL5:user/release-keys"));
458
459 // Not widened past the shim: these are emulator markers this deliberately
460 // does not know about, because the Kotlin does not either.
461 try testing.expect(!fingerprintLooksEmulated("unknown/vbox86p/vbox86p:9/x/y:userdebug/test-keys"));
502 const phone: BuildIdentity = .{
503 .hardware = "husky",
504 .fingerprint = "google/husky/husky:14/AP1A.240405.002/11480754:user/release-keys",
505 .product = "husky",
506 .model = "Pixel 8 Pro",
507 .manufacturer = "Google",
508 .brand = "google",
509 };
510 try testing.expect(!looksEmulated(phone));
511
512 var samsung = phone;
513 samsung.hardware = "qcom";
514 samsung.fingerprint = "samsung/dm3qxxx/dm3q:14/UP1A.231005.007/S918BXXU4BWL5:user/release-keys";
515 samsung.product = "dm3qxxx";
516 samsung.model = "SM-S918B";
517 samsung.manufacturer = "samsung";
518 samsung.brand = "samsung";
519 try testing.expect(!looksEmulated(samsung));
520
521 // The image the E2E suite runs on, and the one the old fingerprint test
522 // answered `false` for: neither "generic" nor "emulator" appears anywhere
523 // in it. HARDWARE is what gives it away.
524 var avd = phone;
525 avd.hardware = "ranchu";
526 avd.fingerprint = "google/sdk_gphone64_x86_64/emu64xa:14/UE1A.230829.036/11228894:user/release-keys";
527 avd.product = "sdk_gphone64_x86_64";
528 avd.model = "sdk_gphone64_x86_64";
529 try testing.expect(looksEmulated(avd));
530
531 // Each marker on its own, so a mutation to any one line fails.
532 var hardware_only = phone;
533 hardware_only.hardware = "goldfish";
534 try testing.expect(looksEmulated(hardware_only));
535
536 var fingerprint_only = phone;
537 fingerprint_only.fingerprint = "generic/sdk/generic:9/x/y:eng/test-keys";
538 try testing.expect(looksEmulated(fingerprint_only));
539
540 var cuttlefish = phone;
541 cuttlefish.fingerprint = "Android/aosp_cf_x86_64_phone/emulator:13/x/y:userdebug/test-keys";
542 try testing.expect(looksEmulated(cuttlefish));
543
544 var product_only = phone;
545 product_only.product = "sdk_google_phone_x86";
546 try testing.expect(looksEmulated(product_only));
547
548 var model_only = phone;
549 model_only.model = "Android SDK built for x86";
550 try testing.expect(looksEmulated(model_only));
551
552 var genymotion = phone;
553 genymotion.manufacturer = "Genymotion";
554 try testing.expect(looksEmulated(genymotion));
555
556 var brand_only = phone;
557 brand_only.brand = "generic_x86";
558 try testing.expect(looksEmulated(brand_only));
559
560 // Genymotion, which the old fingerprint test missed and the `unknown`
561 // prefix now catches on its own - before MANUFACTURER is even consulted.
562 var vbox = phone;
563 vbox.fingerprint = "unknown/vbox86p/vbox86p:9/x/y:userdebug/test-keys";
564 try testing.expect(looksEmulated(vbox));
565
566 // Still a hint and not a boundary, on both sides: anything that can rewrite
567 // these properties defeats it, and nothing here is proof of where the code
568 // is running.
462569}
463570
464571test "the action name matches the Kotlin method exactly" {
packages/zig/src/jni_runtime.zigmodified+36-0
Changes to packages/zig/src/jni_runtime.zig
@@ -437,6 +437,42 @@ pub fn envForThisThread(vm: JavaVM) ?JNIEnv {
437437 return @ptrCast(@alignCast(env orelse return null));
438438}
439439
440/// A `JNIEnv` for this thread, and whether this code owes the JVM a detach.
441///
442/// A thread the JVM created — the UI thread, a binder thread — is already
443/// attached and must **not** be detached by us; a thread Zig or a native
444/// library created must be. Getting that backwards either leaks the attachment
445/// or detaches a thread still using it, and neither fails at the call site.
446pub const Attachment = struct {
447 env: JNIEnv,
448 owned: bool,
449
450 /// Give the attachment back, if it was ours to give.
451 pub fn release(self: Attachment, vm: JavaVM) void {
452 if (!self.owned) return;
453 const detach: *const fn (JavaVM) callconv(.c) jint =
454 @ptrCast(vm.*.DetachCurrentThread orelse return);
455 _ = detach(vm);
456 }
457};
458
459/// This thread's `JNIEnv`, attaching the thread if it has none.
460///
461/// Lives here rather than beside either caller because there are two of them —
462/// the reply channel and the log channel — and a second copy of a JNI dance
463/// this subtle is how the two drift apart.
464pub fn attachCurrentThread(vm: JavaVM) ?Attachment {
465 if (envForThisThread(vm)) |env| return .{ .env = env, .owned = false };
466
467 const attach: *const fn (JavaVM, *?*anyopaque, ?*anyopaque) callconv(.c) jint =
468 @ptrCast(vm.*.AttachCurrentThread orelse return null);
469
470 var env_ptr: ?*anyopaque = null;
471 if (attach(vm, &env_ptr, null) != JNI_OK) return null;
472 const env: JNIEnv = @ptrCast(@alignCast(env_ptr orelse return null));
473 return .{ .env = env, .owned = true };
474}
475
440476/// Bind native implementations to a Java class's `external`/`native` methods.
441477///
442478/// The alternative is exporting a symbol named
scripts/mobile-e2e.tsadded+128-0
Changes to scripts/mobile-e2e.ts
@@ -0,0 +1,128 @@
1#!/usr/bin/env bun
2
3/**
4 * Mobile end-to-end: generate an app, run it on a simulator or emulator, and
5 * assert that a call made from JavaScript reached native code and came back.
6 *
7 * What this replaces ran nothing. Every step in the old mobile-e2e workflow
8 * that would have tested something was guarded by
9 * `if: hashFiles('packages/ios/TestApp/TestApp.xcodeproj') != ''`, and that
10 * project has never existed in the tree, so each one skipped and the job went
11 * green. The Android half looked for `packages/android/gradlew`, which the
12 * generator does not write either, and echoed "skipping project-level tests".
13 * Months of green with no coverage behind it.
14 *
15 * So the rules here are the opposite ones. Nothing skips: a missing toolchain,
16 * an absent device, an app that never printed anything are all failures with a
17 * named cause. The page announces which cases it is going to run before it
18 * runs them, so a suite that dies halfway cannot be read as a shorter suite
19 * that passed. And every leg writes its console log, its build log and a
20 * screenshot under artifacts/mobile-e2e/, uploaded with
21 * `if-no-files-found: error` so a run that produced no evidence is red rather
22 * than quietly empty.
23 *
24 * bun scripts/mobile-e2e.ts --platform ios --ios-runtime packages/zig/zig-out/lib
25 * bun scripts/mobile-e2e.ts --platform android
26 */
27
28import type { LegOutcome, RunnerOptions } from './mobile-e2e/types'
29import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
30import { join, resolve } from 'node:path'
31import { runAndroid } from './mobile-e2e/android'
32import { runIos } from './mobile-e2e/ios'
33
34const root = resolve(import.meta.dir, '..')
35
36function flag(name: string): string | undefined {
37 const prefixed = `--${name}`
38 const index = process.argv.indexOf(prefixed)
39 if (index !== -1 && index + 1 < process.argv.length) return process.argv[index + 1]
40
41 const inline = process.argv.find(argument => argument.startsWith(`${prefixed}=`))
42 return inline?.slice(prefixed.length + 1)
43}
44
45const platform = flag('platform')
46if (platform !== 'ios' && platform !== 'android')
47 throw new Error('pass --platform ios or --platform android')
48
49const evidenceLabel = process.env.CRAFT_EVIDENCE_LABEL || `${platform}-${process.arch}`
50const evidenceDir = join(root, 'artifacts', 'mobile-e2e', evidenceLabel)
51const workDir = join(evidenceDir, 'work')
52
53// A single run id, reused as the clipboard nonce suffix. Deliberately not a
54// timestamp alone: two legs run minutes apart and each needs its own value, so
55// the leg name is mixed in downstream.
56const runId = process.env.GITHUB_RUN_ID || String(process.pid)
57
58const options: RunnerOptions = {
59 root,
60 evidenceDir,
61 workDir,
62 timeoutMs: Number(flag('timeout') ?? 180_000),
63 runId,
64 iosRuntimeDir: flag('ios-runtime')
65 ? resolve(root, flag('ios-runtime')!)
66 : (process.env.CRAFT_IOS_RUNTIME ? resolve(root, process.env.CRAFT_IOS_RUNTIME) : null),
67 androidRuntimeDir: flag('android-runtime')
68 ? resolve(root, flag('android-runtime')!)
69 : (process.env.CRAFT_ANDROID_RUNTIME ? resolve(root, process.env.CRAFT_ANDROID_RUNTIME) : null),
70}
71
72async function main(): Promise<void> {
73 rmSync(evidenceDir, { force: true, recursive: true })
74 mkdirSync(workDir, { recursive: true })
75
76 let outcomes: LegOutcome[] = []
77 let error: string | undefined
78
79 try {
80 outcomes = platform === 'ios' ? await runIos(options) : await runAndroid(options)
81 }
82 catch (caught) {
83 error = caught instanceof Error ? caught.stack || caught.message : String(caught)
84 }
85
86 // A run with no legs is the shape the old workflow had, and it must not read
87 // as success. Named here rather than left to the caller, because "zero
88 // failures" is true of an empty list.
89 if (!error && outcomes.length === 0)
90 error = 'no legs ran; the harness produced no result at all'
91
92 const failedLegs = outcomes.filter(leg => leg.status === 'failed')
93 if (!error && failedLegs.length)
94 error = failedLegs.map(leg => `${leg.name}: ${leg.failures.join('; ')}`).join('\n')
95
96 const report = {
97 schemaVersion: 1,
98 generatedAt: new Date().toISOString(),
99 revision: process.env.GITHUB_SHA || 'local',
100 platform,
101 label: evidenceLabel,
102 runner: { os: process.platform, arch: process.arch, bun: Bun.version },
103 status: error ? 'failed' : 'passed',
104 legs: outcomes,
105 error,
106 }
107
108 mkdirSync(evidenceDir, { recursive: true })
109 writeFileSync(join(evidenceDir, 'report.json'), `${JSON.stringify(report, null, 2)}\n`)
110
111 for (const leg of outcomes) {
112 const cases = `${leg.passed.length}/${leg.planned.length} cases`
113 const zig = leg.zigActions.length ? `zig served ${leg.zigActions.join(', ')}` : 'no zig'
114 console.log(`${leg.status === 'passed' ? 'ok ' : 'FAIL'} ${leg.name} — ${cases}, ${zig}`)
115 for (const failure of leg.failures) console.log(` ${failure}`)
116 }
117
118 if (error) {
119 // The annotation is what makes the reason visible on the run summary
120 // rather than only inside a step log the reader has to expand.
121 console.log(`::error::mobile e2e (${evidenceLabel}) failed: ${error.split('\n')[0]}`)
122 throw new Error(error)
123 }
124
125 console.log(`\nmobile e2e passed on ${platform}; evidence in artifacts/mobile-e2e/${evidenceLabel}`)
126}
127
128await main()
scripts/mobile-e2e/android.tsadded+270-0
Changes to scripts/mobile-e2e/android.ts
@@ -0,0 +1,270 @@
1import type { LegOutcome, RunnerOptions } from './types'
2import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3import { join } from 'node:path'
4import { init } from '../../packages/android/src/index'
5import { evaluateRun, hasTerminated } from './protocol'
6import { command, driverPage, waitForFile } from './support'
7
8/**
9 * Push off, which is what makes the rejection case deterministic: with it
10 * disabled the generator writes a literal `_craftPushReject('Push
11 * notifications are disabled')` into CraftBridge.kt rather than a Firebase
12 * block, so the refusal is fixed at generation time and needs no network, no
13 * Play Services and no account on the emulator.
14 *
15 * Clipboard is not a configurable capability on Android - CraftBridge serves
16 * clipboardRead/clipboardWrite unconditionally - so the success case needs no
17 * flag, and asking for one would imply a gate that is not there.
18 */
19const CONFIG = {
20 enablePushNotifications: false,
21}
22
23const PACKAGE = 'dev.craft.e2e.probe'
24const APP_NAME = 'CraftE2EProbe'
25
26/**
27 * Tags worth reading. CraftBridge is the Kotlin shim's, CraftNative is Zig's
28 * own — that pair is how this leg tells which side answered — chromium carries
29 * the page's console for a run whose bridge never arrived, and AndroidRuntime
30 * carries the crash if it did not get that far.
31 */
32const LOGCAT_FILTER = ['CraftBridge:D', 'CraftNative:D', 'chromium:I', 'AndroidRuntime:E', '*:S']
33
34/**
35 * One leg is one answer to "who served the call".
36 *
37 * The same split the iOS suite runs, and it exists here for a sharper reason:
38 * until this change nothing copied libcraft.so into a generated app at all, so
39 * every Android app anyone has ever generated was the shim leg and the whole
40 * Zig bridge had never run. The runtime leg is what stops that being true
41 * again without anybody noticing.
42 */
43interface Leg {
44 name: 'shim' | 'runtime'
45 runtimeDir: string | null
46 requireZig: boolean
47}
48
49function legs(runtimeDir: string | null): Leg[] {
50 return [
51 { name: 'shim', runtimeDir: null, requireZig: false },
52 { name: 'runtime', runtimeDir, requireZig: true },
53 ]
54}
55
56/** What JNI_OnLoad logs once it has bound the natives. */
57const REGISTERED = /craft: registered (\d+) natives on com\/craft\/runtime\/CraftNative/
58
59async function adb(argv: string[], options: { serial: string, logPath?: string, allowFailure?: boolean }) {
60 return command(['adb', '-s', options.serial, ...argv], { logPath: options.logPath, allowFailure: options.allowFailure })
61}
62
63async function resolveSerial(): Promise<string> {
64 const listed = await command(['adb', 'devices'])
65 const serials = listed.stdout
66 .split('\n')
67 .slice(1)
68 .map(line => line.trim().split(/\s+/))
69 .filter(parts => parts.length >= 2 && parts[1] === 'device')
70 .map(parts => parts[0]!)
71
72 if (!serials.length)
73 throw new Error(`no Android device is attached; \`adb devices\` reported:\n${listed.stdout.trim()}`)
74
75 return serials[0]!
76}
77
78async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
79 const label = `android-${leg.name}`
80 const evidence = join(options.evidenceDir, label)
81 const project = join(options.workDir, label)
82 const failures: string[] = []
83
84 mkdirSync(evidence, { recursive: true })
85 rmSync(project, { force: true, recursive: true })
86
87 await init({
88 name: APP_NAME,
89 packageName: PACKAGE,
90 output: project,
91 config: CONFIG,
92 runtimeDir: leg.runtimeDir,
93 })
94
95 const nonce = `craft-e2e-${label}-${options.runId}`
96 const assets = join(project, 'app', 'src', 'main', 'assets')
97 writeFileSync(join(assets, 'index.html'), driverPage(nonce, 'android'))
98 copyFileSync(join(assets, 'index.html'), join(evidence, 'index.html'))
99 copyFileSync(join(assets, 'craft.config.json'), join(evidence, 'craft.config.json'))
100
101 // The generator writes gradle-wrapper.properties but never gradlew, so there
102 // is no wrapper to invoke. Call Gradle directly, the way the templates gate
103 // already does, and honour the same override so both use one toolchain.
104 const gradle = process.env.GRADLE_EXECUTABLE || 'gradle'
105 await command([gradle, '--no-daemon', '--stacktrace', ':app:assembleDebug'], {
106 cwd: project,
107 logPath: join(evidence, 'gradle.log'),
108 })
109
110 const apk = join(project, 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk')
111 if (!existsSync(apk))
112 throw new Error(`gradle reported success but ${apk} is missing`)
113
114 // Which side is about to answer, established from the APK rather than
115 // assumed. `System.loadLibrary("craft")` finding nothing is a designed
116 // no-op — CraftNative catches it and every action falls through to Kotlin —
117 // so a runtime leg whose library never shipped would look exactly like the
118 // shim leg and pass. The APK is the only place to see the difference before
119 // the app runs.
120 const listed = await command(['unzip', '-l', apk], { logPath: join(evidence, 'apk.txt') })
121 const zigLibraries = listed.stdout
122 .split('\n')
123 .map(line => line.trim().split(/\s+/).pop() ?? '')
124 .filter(name => /^lib\/[^/]+\/libcraft\.so$/.test(name))
125
126 if (leg.requireZig && !zigLibraries.some(name => name.includes('/x86_64/'))) {
127 failures.push(
128 `the runtime leg's APK carries ${zigLibraries.length ? zigLibraries.join(', ') : 'no libcraft.so at all'}, `
129 + 'and nothing for x86_64 — which is the architecture every emulator runs, so the runtime could not load here whatever else shipped',
130 )
131 }
132 if (!leg.requireZig && zigLibraries.length) {
133 failures.push(`the shim leg's APK ships ${zigLibraries.join(', ')}; it was generated with no runtime`)
134 }
135
136 const serial = await resolveSerial()
137 writeFileSync(join(evidence, 'device.txt'), `${serial}\n`)
138
139 // Ground truth from outside the app, to check getDeviceInfo against.
140 const sdk = (await adb(['shell', 'getprop', 'ro.build.version.sdk'], { serial })).stdout.trim()
141
142 await adb(['uninstall', PACKAGE], { serial, allowFailure: true })
143 await adb(['install', '-r', apk], { serial, logPath: join(evidence, 'adb.log') })
144
145 // Not best-effort: the ring buffer outlives an uninstall, and a transcript
146 // left in it would otherwise be read as this run's. The plan event carries
147 // this run's nonce as a second guard.
148 await adb(['logcat', '-c'], { serial })
149 await adb(['shell', 'am', 'force-stop', PACKAGE], { serial, allowFailure: true })
150 await adb(['shell', 'am', 'start', '-n', `${PACKAGE}/.MainActivity`], { serial, logPath: join(evidence, 'adb.log') })
151
152 // logcat -d dumps and exits, so poll it into the evidence file rather than
153 // streaming: a stream would have to be killed at exactly the right moment,
154 // and the dump is cheap.
155 const logPath = join(evidence, 'logcat.txt')
156 const finished = await waitForFile(logPath, options.timeoutMs, hasTerminated, async () => {
157 const dumped = await command(['adb', '-s', serial, 'logcat', '-d', ...LOGCAT_FILTER], { allowFailure: true })
158 writeFileSync(logPath, dumped.stdout)
159 })
160
161 await command(['adb', '-s', serial, 'exec-out', 'screencap', '-p'], { allowFailure: true, outPath: join(evidence, 'screen.png') })
162 const full = await command(['adb', '-s', serial, 'logcat', '-d', '-b', 'all'], { allowFailure: true })
163 writeFileSync(join(evidence, 'logcat-full.txt'), full.stdout)
164 await adb(['shell', 'am', 'force-stop', PACKAGE], { serial, allowFailure: true })
165
166 const logText = existsSync(logPath) ? readFileSync(logPath, 'utf8') : ''
167 if (!finished)
168 failures.push(`the app produced no terminating event within ${options.timeoutMs}ms; see ${label}/logcat.txt`)
169
170 const verdict = evaluateRun('android', logText, nonce)
171 failures.push(...verdict.failures)
172
173 // The page reported the SDK level the bridge told it; the host asked the
174 // device directly. Agreement means the value crossed the bridge rather than
175 // being invented in JavaScript - this leg's substitute for the pasteboard
176 // read the iOS leg does, since Android has no equally cheap host-side
177 // clipboard read.
178 const observed = logText.match(/"event":"observed","name":"sdkVersion","value":(\d+)/)
179 if (!sdk) {
180 // Not skipped when the probe comes back empty. An unverifiable check that
181 // quietly passes is worse than one that fails: it reads as corroboration
182 // and is not.
183 failures.push('`adb shell getprop ro.build.version.sdk` returned nothing, so the page\'s sdkVersion could not be corroborated')
184 }
185 else if (!observed) {
186 failures.push('the page never reported the sdkVersion it got from the bridge')
187 }
188 else if (observed[1] !== sdk) {
189 failures.push(`the bridge reported sdkVersion ${observed[1]} but the device says ${sdk}`)
190 }
191
192 // Did the Zig runtime actually load and bind? JNI_OnLoad says so itself, on
193 // the happy path as well as the failure ones — which is deliberate, because
194 // "loaded and bound" and "never shipped" are otherwise both silent, and this
195 // is also the only line that proves the log channel is working rather than
196 // merely quiet.
197 //
198 // There is no per-action dispatch line to count the way the iOS legs do.
199 // Binding is the gate instead: once the natives are registered, every
200 // CraftNative wrapper returns a value and the Kotlin falls through to Zig
201 // rather than the other way round.
202 const registered = logText.match(REGISTERED)
203 const zigActions: string[] = []
204
205 if (leg.requireZig) {
206 if (!registered) {
207 failures.push(
208 'the Zig runtime never reported registering its natives. Either libcraft.so did not load, '
209 + `or JNI_OnLoad refused a descriptor — see ${label}/logcat.txt under the CraftNative tag, which now carries the reason`,
210 )
211 }
212 else {
213 zigActions.push(`registered:${registered[1]}`)
214 }
215 }
216 else if (registered) {
217 failures.push(`the shim leg registered ${registered[1]} natives; it was generated with no runtime`)
218 }
219
220 return {
221 name: label,
222 status: failures.length ? 'failed' : 'passed',
223 failures,
224 planned: verdict.planned,
225 passed: verdict.passed,
226 failed: verdict.failed,
227 zigActions,
228 evidence: evidence.replace(`${options.root}/`, ''),
229 }
230}
231
232export async function runAndroid(options: RunnerOptions): Promise<LegOutcome[]> {
233 const runtimeDir = options.androidRuntimeDir
234 if (!runtimeDir) {
235 throw new Error(
236 'no Android Zig runtime directory; run `zig build build-android-all -Doptimize=ReleaseSafe` '
237 + 'in packages/zig and pass --android-runtime packages/zig/zig-out/android',
238 )
239 }
240
241 const abi = join(runtimeDir, 'x86_64', 'libcraft.so')
242 if (!existsSync(abi)) {
243 throw new Error(
244 `${abi} is missing. Every Android emulator is x86_64, so without it the runtime leg would `
245 + 'install an APK that cannot load the library and pass as though the shim were the only option. '
246 + 'Run `zig build build-android-all -Doptimize=ReleaseSafe` in packages/zig.',
247 )
248 }
249
250 const outcomes: LegOutcome[] = []
251 for (const leg of legs(runtimeDir)) {
252 try {
253 outcomes.push(await runLeg(leg, options))
254 }
255 catch (caught) {
256 outcomes.push({
257 name: `android-${leg.name}`,
258 status: 'failed',
259 failures: [caught instanceof Error ? caught.message : String(caught)],
260 planned: [],
261 passed: [],
262 failed: [],
263 zigActions: [],
264 evidence: join(options.evidenceDir, `android-${leg.name}`).replace(`${options.root}/`, ''),
265 })
266 }
267 }
268
269 return outcomes
270}
scripts/mobile-e2e/driver.htmladded+254-0
Changes to scripts/mobile-e2e/driver.html
@@ -0,0 +1,254 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Craft mobile E2E</title>
7 <style>
8 body { font: 14px -apple-system, Roboto, sans-serif; background: #0b1712; color: #e6f2ec; padding: 16px; }
9 li { margin: 4px 0; font-family: ui-monospace, monospace; }
10 .pass { color: #7ee08a; }
11 .fail { color: #ff8a80; }
12 </style>
13</head>
14<body>
15 <h1 id="headline">Craft mobile E2E</h1>
16 <ul id="results"></ul>
17 <script>
18 // The page drives itself and reports through the bridge's own `log` action,
19 // which is the one channel both runtimes already forward off-device: iOS
20 // prints "[Craft Web] <message>" to the process stderr that
21 // `simctl launch --console-pty` captures, and Android writes it to logcat
22 // under the CraftBridge tag. Nothing else in the generated app reaches a
23 // host-side reader, so the harness would otherwise be watching a screenshot.
24 //
25 // Every line is CRAFT-E2E followed by one JSON object. The harness reads
26 // three kinds: `plan` (once, up front), `case` (once per case), and `done`.
27 // Announcing the plan before running anything is what lets the harness tell
28 // "this case failed" apart from "this case never ran" — a suite that dies
29 // halfway through is a failure, not a pass with fewer cases.
30 (function () {
31 var NONCE = '__CRAFT_E2E_NONCE__'
32 // Which platform the harness built this leg for. Substituted in from
33 // outside, because the page comparing window.craft.platform against a
34 // value it read out of window.craft.platform is a tautology - it was one,
35 // and bridge.ready passed unconditionally until this arrived.
36 var EXPECTED_PLATFORM = '__CRAFT_E2E_PLATFORM__'
37 var CASE_TIMEOUT_MS = 8000
38
39 function emit(payload) {
40 var line = 'CRAFT-E2E ' + JSON.stringify(payload)
41 // console.log first: on Android it reaches logcat under `chromium` even
42 // when the bridge never arrived, which is the case the harness most
43 // needs evidence for.
44 try { console.log(line) } catch (ignored) {}
45 try { if (window.craft && window.craft.log) window.craft.log(line) } catch (ignored) {}
46 }
47
48 function show(name, ok, detail) {
49 var li = document.createElement('li')
50 li.className = ok ? 'pass' : 'fail'
51 li.textContent = (ok ? 'PASS ' : 'FAIL ') + name + (detail ? ' — ' + detail : '')
52 document.getElementById('results').appendChild(li)
53 }
54
55 // Rejections arrive in three shapes across the two runtimes: an Error from
56 // iOS's _rejectCallback, a bare string from Android's named reject globals
57 // (promise-runtime.ts parks `window._craftPushReject` and Kotlin calls it
58 // with a quoted string), and an object with `code`/`message` from iOS's
59 // capability gate. Normalise once here so a case can assert on a code
60 // without caring which runtime produced it.
61 function describeError(error) {
62 if (error === null || error === undefined) return { code: '', message: '' }
63 if (typeof error === 'string') return { code: '', message: error }
64 return { code: String(error.code || ''), message: String(error.message || error) }
65 }
66
67 function withTimeout(run) {
68 return new Promise(function (resolve) {
69 var settled = false
70 var timer = setTimeout(function () {
71 if (settled) return
72 settled = true
73 resolve({ state: 'timeout' })
74 }, CASE_TIMEOUT_MS)
75
76 function finish(value) {
77 if (settled) return
78 settled = true
79 clearTimeout(timer)
80 resolve(value)
81 }
82
83 try {
84 Promise.resolve(run()).then(
85 function (value) { finish({ state: 'resolved', value: value }) },
86 function (error) { finish({ state: 'rejected', error: describeError(error) }) }
87 )
88 }
89 catch (thrown) {
90 finish({ state: 'rejected', error: describeError(thrown) })
91 }
92 })
93 }
94
95 // A case passes only by returning nothing. Returning a string is how it
96 // explains what it saw instead, and that string lands in the report — so a
97 // failure names the actual value rather than just the assertion that broke.
98 var CASES = [
99 {
100 name: 'bridge.ready',
101 platforms: ['ios', 'android'],
102 run: function () {
103 if (!window.craft) return 'window.craft is absent'
104 if (typeof window.craft.log !== 'function') return 'window.craft has no log function'
105 if (window.craft.platform !== EXPECTED_PLATFORM)
106 return 'platform is ' + window.craft.platform + ', expected ' + EXPECTED_PLATFORM
107 }
108 },
109 {
110 // Ground truth the page cannot invent: `isSimulator` comes from Swift's
111 // `#if targetEnvironment(simulator)`, decided by the compiler, and
112 // `isEmulator` from the device's own Build properties. A stubbed bridge
113 // that echoed plausible JSON would have to guess it right.
114 name: 'deviceInfo.isSimulator',
115 platforms: ['ios'],
116 run: function () {
117 return withTimeout(function () { return window.craft.getDeviceInfo() }).then(function (outcome) {
118 if (outcome.state !== 'resolved') return 'getDeviceInfo ' + outcome.state
119 if (!outcome.value) return 'getDeviceInfo resolved with nothing'
120 if (outcome.value.platform !== 'ios') return 'platform is ' + outcome.value.platform
121 if (outcome.value.isSimulator !== true) return 'isSimulator is ' + outcome.value.isSimulator
122 })
123 }
124 },
125 {
126 name: 'deviceInfo.isEmulator',
127 platforms: ['android'],
128 run: function () {
129 return withTimeout(function () { return window.craft.getDeviceInfo() }).then(function (outcome) {
130 if (outcome.state !== 'resolved') return 'getDeviceInfo ' + outcome.state
131 if (!outcome.value) return 'getDeviceInfo resolved with nothing'
132
133 // Reported before anything is asserted, so a failing assertion
134 // still leaves the values behind. The first run of this case said
135 // only "isEmulator is false" and the log did not say what the
136 // device had called itself, which is the one thing needed to fix
137 // it.
138 emit({ event: 'observed', name: 'sdkVersion', value: outcome.value.sdkVersion })
139 emit({ event: 'observed', name: 'model', value: outcome.value.model })
140 emit({ event: 'observed', name: 'manufacturer', value: outcome.value.manufacturer })
141 emit({ event: 'observed', name: 'device', value: outcome.value.device })
142
143 if (outcome.value.platform !== 'android') return 'platform is ' + outcome.value.platform
144 if (!outcome.value.sdkVersion) return 'sdkVersion is ' + outcome.value.sdkVersion
145 if (outcome.value.isEmulator !== true)
146 return 'isEmulator is ' + outcome.value.isEmulator + ' on model ' + outcome.value.model
147 })
148 }
149 },
150 {
151 // The success path. On iOS the harness then reads the device pasteboard
152 // from the host with `simctl pbpaste`, so the case asserts the value
153 // reached real system clipboard state rather than a variable the bridge
154 // handed back. Android has no equally cheap host-side read, so its
155 // external check is the sdkVersion cross-reference below instead.
156 name: 'clipboard.roundTrip',
157 platforms: ['ios', 'android'],
158 run: function () {
159 return withTimeout(function () { return window.craft.clipboard.write(NONCE) }).then(function (written) {
160 if (written.state !== 'resolved') return 'clipboard.write ' + written.state + ' ' + (written.error ? written.error.message : '')
161 return withTimeout(function () { return window.craft.clipboard.read() }).then(function (read) {
162 if (read.state !== 'resolved') return 'clipboard.read ' + read.state + ' ' + (read.error ? read.error.message : '')
163 if (read.value !== NONCE) return 'read back ' + JSON.stringify(read.value)
164 })
165 })
166 }
167 },
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',
175 platforms: ['ios'],
176 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)
181 })
182 }
183 },
184 {
185 // Android's equivalent gate. With push disabled the generator emits a
186 // literal reject call rather than a Firebase block, so the rejection
187 // string is fixed at generation time and is safe to match on.
188 name: 'push.disabled.rejects',
189 platforms: ['android'],
190 run: function () {
191 return withTimeout(function () { return window.craft.registerPush() }).then(function (outcome) {
192 if (outcome.state === 'timeout') return 'registerPush never settled'
193 if (outcome.state === 'resolved') return 'registerPush resolved while push is disabled'
194 if (outcome.error.message.indexOf('Push notifications are disabled') === -1)
195 return 'message is ' + JSON.stringify(outcome.error.message)
196 })
197 }
198 }
199 ]
200
201 function waitForBridge(attempt) {
202 if (window.craft && window.craft.getDeviceInfo) return Promise.resolve(true)
203 if (attempt >= 150) return Promise.resolve(false)
204 return new Promise(function (resolve) {
205 setTimeout(function () { resolve(waitForBridge(attempt + 1)) }, 100)
206 })
207 }
208
209 waitForBridge(0).then(function (ready) {
210 if (!ready) {
211 emit({ event: 'fatal', reason: 'window.craft never appeared' })
212 document.getElementById('headline').textContent = 'FATAL: no bridge'
213 return
214 }
215
216 var platform = window.craft.platform
217 var selected = CASES.filter(function (entry) { return entry.platforms.indexOf(platform) !== -1 })
218 emit({ event: 'plan', platform: platform, run: NONCE, cases: selected.map(function (entry) { return entry.name }) })
219
220 var passed = 0
221 var failed = 0
222
223 // Sequential, not Promise.all. Android's promise runtime is single-flight
224 // per channel and rejects a second in-flight request outright; running
225 // the suite in parallel would fail cases for colliding with each other.
226 function step(index) {
227 if (index >= selected.length) {
228 emit({ event: 'done', passed: passed, failed: failed })
229 document.getElementById('headline').textContent = failed ? 'FAILED' : 'PASSED'
230 return
231 }
232
233 var entry = selected[index]
234 Promise.resolve()
235 .then(function () { return entry.run() })
236 .then(
237 function (detail) { return detail ? String(detail) : '' },
238 function (thrown) { return 'threw: ' + describeError(thrown).message }
239 )
240 .then(function (detail) {
241 if (detail) failed++
242 else passed++
243 emit({ event: 'case', name: entry.name, status: detail ? 'fail' : 'pass', detail: detail })
244 show(entry.name, !detail, detail)
245 step(index + 1)
246 })
247 }
248
249 step(0)
250 })
251 })()
252 </script>
253</body>
254</html>
scripts/mobile-e2e/ios.tsadded+241-0
Changes to scripts/mobile-e2e/ios.ts
@@ -0,0 +1,241 @@
1import type { LegOutcome, RunnerOptions } from './types'
2import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3import { join } from 'node:path'
4import { bootSimulator, init, pickSimulator } from '../../packages/ios/src/index'
5import { evaluateRun, hasTerminated, ZIG_REFUSED_ACTION, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigRefusals } from './protocol'
6import { command, driverPage, waitForFile } from './support'
7
8/**
9 * The app the suite runs against.
10 *
11 * Clipboard on, geolocation off, and that pairing is the whole point: the
12 * success case needs a capability that is enabled and reachable without a
13 * permission prompt, and the rejection case needs one that is switched off so
14 * the refusal is a property of the configuration rather than of the machine.
15 * Reading a pasteboard the app itself just wrote raises no iOS paste prompt,
16 * which is what makes the round trip scriptable.
17 */
18const CONFIG = {
19 enableClipboard: true,
20 enableGeolocation: false,
21}
22
23const BUNDLE_ID = 'dev.craft.e2e.probe'
24const APP_NAME = 'CraftE2EProbe'
25
26/**
27 * One leg is one answer to "who served the call".
28 *
29 * `shim` is what every generated app is today: no Zig archive, every dlsym
30 * miss, Swift serving the whole surface. `runtime` links the archive so Zig
31 * takes the actions it owns. They assert the same cases, because the hand-off
32 * is supposed to be invisible to the page - and that is exactly why the
33 * runtime leg also counts Zig's dispatch log lines. Identical behaviour is the
34 * requirement; identical behaviour with the archive silently absent is the bug
35 * that requirement would otherwise hide.
36 */
37interface Leg {
38 name: 'shim' | 'runtime'
39 runtimeDir: string | null
40 requireZigDispatch: boolean
41}
42
43function legs(runtimeDir: string | null): Leg[] {
44 return [
45 { name: 'shim', runtimeDir: null, requireZigDispatch: false },
46 { name: 'runtime', runtimeDir, requireZigDispatch: true },
47 ]
48}
49
50async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
51 const label = `ios-${leg.name}`
52 const evidence = join(options.evidenceDir, label)
53 const project = join(options.workDir, label)
54 const failures: string[] = []
55
56 mkdirSync(evidence, { recursive: true })
57 rmSync(project, { force: true, recursive: true })
58
59 await init({
60 name: APP_NAME,
61 bundleId: BUNDLE_ID,
62 output: project,
63 config: CONFIG,
64 runtimeDir: leg.runtimeDir,
65 })
66
67 // The archive is linked through project.yml's ,
68 // which init() leaves empty when it installed no runtime. Checking the
69 // generated file rather than trusting the option means a leg cannot quietly
70 // become a second copy of the shim leg.
71 const projectYml = readFileSync(join(project, 'project.yml'), 'utf8')
72 const linksRuntime = projectYml.includes('-lcraft-ios')
73 if (leg.requireZigDispatch && !linksRuntime)
74 failures.push(`the ${leg.name} leg generated a project that does not link the Zig archive; check ${leg.runtimeDir}`)
75 if (!leg.requireZigDispatch && linksRuntime)
76 failures.push(`the ${leg.name} leg was supposed to have no runtime but its project links one`)
77
78 const nonce = `craft-e2e-${label}-${options.runId}`
79 writeFileSync(join(project, 'dist', 'index.html'), driverPage(nonce, 'ios'))
80 copyFileSync(join(project, 'dist', 'index.html'), join(evidence, 'index.html'))
81 copyFileSync(join(project, 'craft.config.json'), join(evidence, 'craft.config.json'))
82 copyFileSync(join(project, 'project.yml'), join(evidence, 'project.yml'))
83
84 await command(['xcodegen', 'generate'], { cwd: project, logPath: join(evidence, 'xcodegen.log') })
85
86 const derived = join(project, 'DerivedData')
87 await command([
88 'xcodebuild',
89 '-project', `${APP_NAME}.xcodeproj`,
90 '-scheme', APP_NAME,
91 '-configuration', 'Debug',
92 // The simulator SDK, which no existing job builds against: the templates
93 // gate compiles -sdk iphoneos only. A generated app that links for a
94 // device and not for a simulator would have gone unnoticed.
95 '-sdk', 'iphonesimulator',
96 '-derivedDataPath', derived,
97 'CODE_SIGNING_ALLOWED=NO',
98 'build',
99 ], { cwd: project, logPath: join(evidence, 'xcodebuild.log') })
100
101 const app = join(derived, 'Build', 'Products', 'Debug-iphonesimulator', `${APP_NAME}.app`)
102 if (!existsSync(app))
103 throw new Error(`xcodebuild reported success but ${app} is missing`)
104
105 const device = await pickSimulator()
106 if (!device)
107 throw new Error('no iOS simulator is available; `xcrun simctl list devices available` found none')
108 await bootSimulator(device)
109 writeFileSync(join(evidence, 'device.json'), `${JSON.stringify(device, null, 2)}\n`)
110
111 await command(['xcrun', 'simctl', 'uninstall', device.udid, BUNDLE_ID], { allowFailure: true })
112 await command(['xcrun', 'simctl', 'install', device.udid, app])
113
114 // Clear the pasteboard first. Without this a nonce left by the previous leg
115 // would still be there, and the external check below would pass on a value
116 // this run never wrote.
117 await command(['xcrun', 'simctl', 'pbcopy', device.udid], { stdin: 'craft-e2e-pasteboard-cleared', allowFailure: true })
118
119 // The app's own stdout and stderr, straight to a file. `--console-pty` is
120 // what makes Swift's `print` - and therefore the page's `craft.log` - leave
121 // the device at all; without it the launch returns a pid and says nothing.
122 // A raw descriptor rather than a pipe, so the writing outlives this process
123 // reading it and the poll below sees the file grow.
124 const consolePath = join(evidence, 'console.log')
125 const consoleFd = openSync(consolePath, 'w')
126 const launched = Bun.spawn(['xcrun', 'simctl', 'launch', '--console-pty', device.udid, BUNDLE_ID], {
127 stdout: consoleFd,
128 stderr: consoleFd,
129 })
130
131 // The suite is quick once the app is up, but a cold simulator is not, so
132 // poll for the terminator rather than sleeping a fixed amount.
133 //
134 // `hasTerminated` parses rather than matching a substring, because the very
135 // next statement kills the writer: a poll landing mid-write of the final
136 // line would otherwise leave a truncated event in the file and turn a
137 // passing suite into "driver emitted an unparseable event".
138 const finished = await waitForFile(consolePath, options.timeoutMs, hasTerminated)
139
140 launched.kill()
141 closeSync(consoleFd)
142 await command(['xcrun', 'simctl', 'terminate', device.udid, BUNDLE_ID], { allowFailure: true })
143
144 // Evidence before assertions, always: a leg that dies here is the one whose
145 // screenshot is worth the most.
146 await command(['xcrun', 'simctl', 'io', device.udid, 'screenshot', join(evidence, 'screen.png')], { allowFailure: true })
147 const pasteboard = await command(['xcrun', 'simctl', 'pbpaste', device.udid], { allowFailure: true })
148 writeFileSync(join(evidence, 'pasteboard.txt'), pasteboard.stdout)
149
150 const consoleText = existsSync(consolePath) ? readFileSync(consolePath, 'utf8') : ''
151 if (!finished)
152 failures.push(`the app produced no terminating event within ${options.timeoutMs}ms; see ${label}/console.log`)
153
154 const verdict = evaluateRun('ios', consoleText, nonce)
155 failures.push(...verdict.failures)
156
157 // The one assertion the page cannot fake. simctl reads the device's own
158 // pasteboard from the host, so a nonce found here travelled from JavaScript
159 // through the bridge into real UIPasteboard state.
160 if (!pasteboard.stdout.includes(nonce))
161 failures.push(`the simulator pasteboard does not hold the nonce this run wrote; it holds ${JSON.stringify(pasteboard.stdout.trim().slice(0, 80))}`)
162
163 const dispatched = zigDispatchedActions(consoleText)
164 const refused = zigRefusals(consoleText)
165
166 if (leg.requireZigDispatch) {
167 const missing = ZIG_TESTED_ACTIONS.filter(action => !dispatched.includes(action))
168 if (missing.length)
169 failures.push(`the runtime leg links the Zig archive but ${missing.join(', ')} never reached the Zig dispatcher; Swift served ${missing.length === 1 ? 'it' : 'them'} and the archive was not in the loop for the cases under test`)
170
171 // A dispatch line is logged on entry, before Zig decides whether it owns
172 // the action, so it alone does not prove Zig answered. The refusal line is
173 // written by Zig's own capability gate, which means the rejection the page
174 // saw came off Zig's error route.
175 if (!refused.includes(ZIG_REFUSED_ACTION))
176 failures.push(`Zig never refused ${ZIG_REFUSED_ACTION}; the rejection the page saw came from Swift, not from the Zig gate`)
177 }
178 else {
179 if (dispatched.length)
180 failures.push(`the shim leg reached the Zig dispatcher for ${dispatched.join(', ')}; it was supposed to link no runtime`)
181 if (refused.length)
182 failures.push(`the shim leg saw Zig refuse ${refused.join(', ')}; it was supposed to link no runtime`)
183 }
184
185 return {
186 name: label,
187 status: failures.length ? 'failed' : 'passed',
188 failures,
189 planned: verdict.planned,
190 passed: verdict.passed,
191 failed: verdict.failed,
192 zigActions: dispatched,
193 evidence: evidence.replace(`${options.root}/`, ''),
194 }
195}
196
197export async function runIos(options: RunnerOptions): Promise<LegOutcome[]> {
198 const runtimeDir = options.iosRuntimeDir
199 if (!runtimeDir)
200 throw new Error('no iOS Zig runtime directory; run `zig build build-ios-all` in packages/zig and pass --ios-runtime <dir>')
201
202 // The device archive as well as the simulator ones, because that is what
203 // `installRuntime` requires: it resolves every SDK in RUNTIME_ARCHIVES before
204 // writing anything and throws when one has none. Checking only the simulator
205 // slices here would accept a zig-out that project generation then rejects,
206 // several minutes later, with a message about a different build step.
207 const simulator = ['libcraft-ios-simulator-arm64.a', 'libcraft-ios-simulator-x64.a']
208 const missing: string[] = []
209 if (!simulator.some(name => existsSync(join(runtimeDir, name)))) missing.push(...simulator)
210 if (!existsSync(join(runtimeDir, 'libcraft-ios.a'))) missing.push('libcraft-ios.a')
211
212 if (missing.length) {
213 throw new Error(
214 `${runtimeDir} is missing ${missing.join(', ')}. `
215 + 'Run `zig build build-ios-all -Doptimize=ReleaseSafe` in packages/zig first — '
216 + 'build-ios-simulator alone is not enough, because the generated project links a device slice too. '
217 + 'Skipping the runtime leg instead would leave the Zig bridge untested while the suite still reported green.',
218 )
219 }
220
221 const outcomes: LegOutcome[] = []
222 for (const leg of legs(runtimeDir)) {
223 try {
224 outcomes.push(await runLeg(leg, options))
225 }
226 catch (caught) {
227 outcomes.push({
228 name: `ios-${leg.name}`,
229 status: 'failed',
230 failures: [caught instanceof Error ? caught.message : String(caught)],
231 planned: [],
232 passed: [],
233 failed: [],
234 zigActions: [],
235 evidence: join(options.evidenceDir, `ios-${leg.name}`).replace(`${options.root}/`, ''),
236 })
237 }
238 }
239
240 return outcomes
241}
scripts/mobile-e2e/protocol.test.tsadded+278-0
Changes to scripts/mobile-e2e/protocol.test.ts
@@ -0,0 +1,278 @@
1import { describe, expect, it } from 'bun:test'
2import { evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, ZIG_REFUSED_ACTION, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigRefusals } from './protocol'
3
4const ESC = String.fromCharCode(27)
5
6/** A transcript shaped like the one a real iOS run produces. */
7const RUN = 'craft-e2e-ios-shim-1234'
8
9function iosTranscript(overrides: { cases?: string[], omit?: string[], fail?: Record<string, string>, done?: boolean, run?: string } = {}): string {
10 const planned = overrides.cases ?? REQUIRED_CASES.ios
11 const omit = overrides.omit ?? []
12 const fail = overrides.fail ?? {}
13 const reported = planned.filter(name => !omit.includes(name))
14
15 const lines = [
16 'Some unrelated system chatter from the device',
17 `[Craft Web] CRAFT-E2E ${JSON.stringify({ event: 'plan', platform: 'ios', run: overrides.run ?? RUN, cases: planned })}`,
18 ...reported.map(name => `[Craft Web] CRAFT-E2E ${JSON.stringify({
19 event: 'case',
20 name,
21 status: fail[name] ? 'fail' : 'pass',
22 detail: fail[name] ?? '',
23 })}`),
24 ]
25
26 if (overrides.done !== false) {
27 const failed = reported.filter(name => fail[name]).length
28 lines.push(`[Craft Web] CRAFT-E2E ${JSON.stringify({ event: 'done', passed: reported.length - failed, failed })}`)
29 }
30
31 return `${lines.join('\n')}\n`
32}
33
34describe('required cases', () => {
35 it('keeps a success path and a rejection path on every platform', () => {
36 expect(requiredCaseProblems()).toEqual([])
37 })
38
39 it('names the same bridge.ready case on both platforms', () => {
40 expect(REQUIRED_CASES.ios).toContain('bridge.ready')
41 expect(REQUIRED_CASES.android).toContain('bridge.ready')
42 })
43})
44
45describe('parseDriverOutput', () => {
46 it('finds events inside noisy, colour-coded device logs', () => {
47 const text = [
48 'unrelated',
49 `${ESC}[33m[Craft Web] CRAFT-E2E {"event":"plan","platform":"ios","cases":["a"]}${ESC}[0m`,
50 'more unrelated',
51 ].join('\n')
52
53 const { events, malformed } = parseDriverOutput(text)
54 expect(malformed).toEqual([])
55 expect(events).toEqual([{ event: 'plan', platform: 'ios', cases: ['a'] }])
56 })
57
58 it('reports a garbled event rather than skipping it', () => {
59 const { events, malformed } = parseDriverOutput('CRAFT-E2E {"event":"plan"\n')
60 expect(events).toEqual([])
61 expect(malformed).toEqual(['{"event":"plan"'])
62 })
63
64 it('tolerates carriage returns from the console pty', () => {
65 const { events } = parseDriverOutput('CRAFT-E2E {"event":"done","passed":1,"failed":0}\r\n')
66 expect(events).toEqual([{ event: 'done', passed: 1, failed: 0 }])
67 })
68
69 // The exact shape the first Android run produced. Chromium wraps a page's
70 // console.log before it reaches logcat, and taking the rest of the line
71 // turned every event into an unparseable one - six of them, which failed a
72 // run whose bridge had in fact worked.
73 it('reads an event out of a Chromium console wrapper', () => {
74 const line = '09-15 13:49:12.345 1234 1234 I chromium: [INFO:CONSOLE(39)] "CRAFT-E2E '
75 + '{"event":"case","name":"clipboard.roundTrip","status":"pass","detail":""}", '
76 + 'source: https://appassets.androidplatform.net/ (39)'
77
78 const { events, malformed } = parseDriverOutput(line)
79 expect(malformed).toEqual([])
80 expect(events).toEqual([{ event: 'case', name: 'clipboard.roundTrip', status: 'pass', detail: '' }])
81 })
82
83 it('keeps one copy when the page reports through both channels', () => {
84 const payload = '{"event":"case","name":"bridge.ready","status":"pass","detail":""}'
85 const { events } = parseDriverOutput([
86 `I chromium: [INFO:CONSOLE(39)] "CRAFT-E2E ${payload}", source: https://appassets.androidplatform.net/ (39)`,
87 `D CraftBridge: CRAFT-E2E ${payload}`,
88 ].join('\n'))
89
90 expect(events).toEqual([{ event: 'case', name: 'bridge.ready', status: 'pass', detail: '' }])
91 })
92
93 it('does not stop at a brace inside a string', () => {
94 const { events, malformed } = parseDriverOutput(
95 'CRAFT-E2E {"event":"case","name":"a","status":"fail","detail":"read back \\"{oops}\\""}',
96 )
97 expect(malformed).toEqual([])
98 expect(events).toEqual([{ event: 'case', name: 'a', status: 'fail', detail: 'read back "{oops}"' }])
99 })
100
101 it('still reports a genuinely truncated event', () => {
102 const { events, malformed } = parseDriverOutput('CRAFT-E2E {"event":"plan","cases":["a"')
103 expect(events).toEqual([])
104 expect(malformed).toEqual(['{"event":"plan","cases":["a"'])
105 })
106})
107
108describe('evaluateRun', () => {
109 it('passes a complete run', () => {
110 const verdict = evaluateRun('ios', iosTranscript())
111 expect(verdict.failures).toEqual([])
112 expect(verdict.ok).toBe(true)
113 expect(verdict.passed).toEqual(REQUIRED_CASES.ios)
114 })
115
116 // The failure the workflow this replaces actually shipped: nothing ran, and
117 // nothing said so.
118 it('fails an empty log instead of finding nothing to complain about', () => {
119 const verdict = evaluateRun('ios', '')
120 expect(verdict.ok).toBe(false)
121 expect(verdict.failures).toEqual(['the test page never announced a plan; it did not reach the native bridge'])
122 })
123
124 it('fails a log with device chatter but no driver output', () => {
125 const verdict = evaluateRun('ios', 'Booting\nAssertion failed somewhere unrelated\n')
126 expect(verdict.ok).toBe(false)
127 })
128
129 it('fails when a planned case never reports', () => {
130 const verdict = evaluateRun('ios', iosTranscript({ omit: ['clipboard.roundTrip'], done: false }))
131 expect(verdict.ok).toBe(false)
132 expect(verdict.failures).toContain('case clipboard.roundTrip was planned but never reported; the suite stopped early')
133 expect(verdict.failures).toContain('the suite never reported done')
134 })
135
136 it('fails when the suite is hollowed out to the easy cases', () => {
137 const verdict = evaluateRun('ios', iosTranscript({ cases: ['bridge.ready'] }))
138 expect(verdict.ok).toBe(false)
139 expect(verdict.failures).toContain('required case geolocation.disabled.rejects is not in the suite the page ran')
140 expect(verdict.failures).toContain('required case clipboard.roundTrip is not in the suite the page ran')
141 })
142
143 it('carries the page detail into the failure so the reason is the value seen', () => {
144 const verdict = evaluateRun('ios', iosTranscript({ fail: { 'clipboard.roundTrip': 'read back ""' } }))
145 expect(verdict.ok).toBe(false)
146 expect(verdict.failures).toContain('case clipboard.roundTrip failed: read back ""')
147 expect(verdict.failed).toEqual(['clipboard.roundTrip'])
148 })
149
150 it('fails when the tally disagrees with the transcript', () => {
151 const text = [
152 'CRAFT-E2E {"event":"plan","platform":"ios","cases":["bridge.ready","deviceInfo.isSimulator","clipboard.roundTrip","geolocation.disabled.rejects"]}',
153 ...REQUIRED_CASES.ios.map(name => `CRAFT-E2E {"event":"case","name":"${name}","status":"pass","detail":""}`),
154 'CRAFT-E2E {"event":"done","passed":99,"failed":0}',
155 ].join('\n')
156
157 const verdict = evaluateRun('ios', text)
158 expect(verdict.ok).toBe(false)
159 expect(verdict.failures).toContain('done says 99 passed / 0 failed, transcript shows 4 / 0')
160 })
161
162 it('fails when the app is not the platform the leg was built for', () => {
163 const text = iosTranscript().replace('"platform":"ios"', '"platform":"android"')
164 const verdict = evaluateRun('ios', text)
165 expect(verdict.ok).toBe(false)
166 expect(verdict.failures).toContain('the app reports platform android, expected ios')
167 })
168
169 // Android reads its transcript out of a ring buffer that survives an
170 // uninstall, so a leg whose app printed nothing could otherwise be evaluated
171 // against the previous run's output.
172 it('refuses a transcript left over from an earlier run', () => {
173 const verdict = evaluateRun('ios', iosTranscript({ run: 'craft-e2e-ios-shim-OLD' }), RUN)
174 expect(verdict.ok).toBe(false)
175 expect(verdict.failures).toContain(
176 `the transcript is from run "craft-e2e-ios-shim-OLD", not "${RUN}"; this is stale output, not this run's`,
177 )
178 })
179
180 it('accepts the transcript when the run matches', () => {
181 expect(evaluateRun('ios', iosTranscript(), RUN).ok).toBe(true)
182 })
183
184 it('does not check the run when none was given', () => {
185 expect(evaluateRun('ios', iosTranscript({ run: 'anything' })).ok).toBe(true)
186 })
187
188 // "Planned, and did report" is not the same as "passed". A third status
189 // satisfies both of the other checks while leaving the case unasserted.
190 it('refuses a status that is neither pass nor fail', () => {
191 const text = [
192 `CRAFT-E2E ${JSON.stringify({ event: 'plan', platform: 'ios', run: RUN, cases: REQUIRED_CASES.ios })}`,
193 ...REQUIRED_CASES.ios.map((name, index) => `CRAFT-E2E ${JSON.stringify({
194 event: 'case',
195 name,
196 status: index === 0 ? 'skipped' : 'pass',
197 detail: '',
198 })}`),
199 `CRAFT-E2E ${JSON.stringify({ event: 'done', passed: 3, failed: 0 })}`,
200 ].join('\n')
201
202 const verdict = evaluateRun('ios', text, RUN)
203 expect(verdict.ok).toBe(false)
204 expect(verdict.failures).toContain('case bridge.ready reported status "skipped", which is neither pass nor fail')
205 })
206
207 it('reports the driver fatal when the bridge never appeared', () => {
208 const verdict = evaluateRun('ios', 'CRAFT-E2E {"event":"fatal","reason":"window.craft never appeared"}\n')
209 expect(verdict.ok).toBe(false)
210 expect(verdict.failures).toContain('driver reported a fatal condition: window.craft never appeared')
211 })
212
213 it('evaluates an android transcript against the android list', () => {
214 const lines = [
215 `CRAFT-E2E ${JSON.stringify({ event: 'plan', platform: 'android', run: RUN, cases: REQUIRED_CASES.android })}`,
216 ...REQUIRED_CASES.android.map(name => `CRAFT-E2E ${JSON.stringify({ event: 'case', name, status: 'pass', detail: '' })}`),
217 `CRAFT-E2E ${JSON.stringify({ event: 'done', passed: REQUIRED_CASES.android.length, failed: 0 })}`,
218 ].join('\n')
219
220 expect(evaluateRun('android', lines).ok).toBe(true)
221 // The same transcript must not satisfy the iOS leg.
222 expect(evaluateRun('ios', lines).ok).toBe(false)
223 })
224})
225
226describe('hasTerminated', () => {
227 it('waits for the whole event, not the first bytes of it', () => {
228 // The harness kills the app the moment this is true, so a substring match
229 // would cut the line it was waiting for and report it as malformed.
230 expect(hasTerminated('[Craft Web] CRAFT-E2E {"event":"done","pa')).toBe(false)
231 expect(hasTerminated('[Craft Web] CRAFT-E2E {"event":"done","passed":4,"failed":0}')).toBe(true)
232 })
233
234 it('treats a fatal as terminal too', () => {
235 expect(hasTerminated('CRAFT-E2E {"event":"fatal","reason":"window.craft never appeared"}')).toBe(true)
236 })
237
238 it('is false while cases are still arriving', () => {
239 expect(hasTerminated('CRAFT-E2E {"event":"case","name":"a","status":"pass"}')).toBe(false)
240 })
241})
242
243describe('zig attribution', () => {
244 it('names the actions the Zig dispatcher saw', () => {
245 const text = [
246 'info: craft-bridge dispatch t=mobile a=getDeviceInfo i=3',
247 `${ESC}[32minfo: craft-bridge dispatch t=mobile a=clipboardWrite i=4${ESC}[0m`,
248 'info: something else entirely',
249 ].join('\n')
250
251 expect(zigDispatchedActions(text)).toEqual(['clipboardWrite', 'getDeviceInfo'])
252 })
253
254 // The reason this counts actions and not lines: every report the page makes
255 // goes through craft.log, and log is itself dispatched. A bare count is
256 // satisfied by the reporting traffic while Swift serves everything tested.
257 it('is not satisfied by the page reporting through craft.log', () => {
258 const logOnly = Array.from({ length: 12 }, () => 'info: craft-bridge dispatch t=mobile a=log i=null').join('\n')
259 const dispatched = zigDispatchedActions(logOnly)
260
261 expect(dispatched).toEqual(['log'])
262 expect(ZIG_TESTED_ACTIONS.filter(action => !dispatched.includes(action))).toEqual(ZIG_TESTED_ACTIONS)
263 })
264
265 it('is empty for a run the platform shim served alone', () => {
266 expect(zigDispatchedActions(iosTranscript())).toEqual([])
267 expect(zigRefusals(iosTranscript())).toEqual([])
268 })
269
270 it('reads the refusal Zig writes through its own capability gate', () => {
271 const text = 'info: ios: refusing getCurrentPosition; enableGeolocation is not enabled in craft.config.json'
272 expect(zigRefusals(text)).toEqual([ZIG_REFUSED_ACTION])
273 })
274
275 it('expects a refusal for an action the suite actually exercises', () => {
276 expect(ZIG_TESTED_ACTIONS).toContain(ZIG_REFUSED_ACTION)
277 })
278})
scripts/mobile-e2e/protocol.tsadded+359-0
Changes to scripts/mobile-e2e/protocol.ts
@@ -0,0 +1,359 @@
1/**
2 * The wire between the test page and the harness, and the rules for reading it.
3 *
4 * Kept separate from the two runners because this is the part that can be
5 * tested without a simulator: `scripts/mobile-e2e/protocol.test.ts` feeds it
6 * transcripts a device would have produced, including the ones that matter
7 * most - a suite that died halfway, a case that never ran, a run with no
8 * output at all. The runners stay untested; the reading of their output does
9 * not. Same split as scripts/native-lifecycle-plan.ts and its test.
10 */
11
12/** Emitted once, before any case runs, so a truncated run is detectable. */
13export interface PlanEvent {
14 event: 'plan'
15 platform: string
16 cases: string[]
17 /** This run's nonce, so a stale transcript cannot be mistaken for this one. */
18 run?: string
19}
20
21export interface CaseEvent {
22 event: 'case'
23 name: string
24 status: 'pass' | 'fail'
25 /** Empty on pass; on failure, what the page actually saw. */
26 detail?: string
27}
28
29export interface DoneEvent {
30 event: 'done'
31 passed: number
32 failed: number
33}
34
35export interface FatalEvent {
36 event: 'fatal'
37 reason: string
38}
39
40export interface ObservedEvent {
41 event: 'observed'
42 name: string
43 value: unknown
44}
45
46export type DriverEvent = PlanEvent | CaseEvent | DoneEvent | FatalEvent | ObservedEvent
47
48export type MobilePlatform = 'ios' | 'android'
49
50/**
51 * The cases each platform must run, named here and nowhere else.
52 *
53 * The page decides what a case *asserts*; this decides which ones have to
54 * exist. Both halves are needed: without the page there is nothing to assert,
55 * and without this list a suite can be hollowed out to one trivially passing
56 * case and still report green - which is the failure mode the workflow this
57 * replaces actually had, for months, with `if: hashFiles(...)` guards that
58 * skipped every real step.
59 *
60 * Each platform's list must contain at least one success path and one
61 * rejection path; `requiredCaseProblems` enforces that rather than trusting
62 * whoever edits it next.
63 */
64export const REQUIRED_CASES: Record<MobilePlatform, string[]> = {
65 ios: [
66 'bridge.ready',
67 'deviceInfo.isSimulator',
68 'clipboard.roundTrip',
69 'geolocation.disabled.rejects',
70 ],
71 android: [
72 'bridge.ready',
73 'deviceInfo.isEmulator',
74 'clipboard.roundTrip',
75 'push.disabled.rejects',
76 ],
77}
78
79/** Cases whose whole point is that the native side *refuses*. */
80const REJECTION_SUFFIX = '.rejects'
81
82/**
83 * Guard the list above against being watered down.
84 *
85 * A platform that lost its rejection case would still look like a passing
86 * suite - every remaining case is a success path, and success paths are the
87 * easy half. Run from the protocol test.
88 */
89export function requiredCaseProblems(): string[] {
90 const problems: string[] = []
91
92 for (const [platform, names] of Object.entries(REQUIRED_CASES)) {
93 if (!names.some(name => name.endsWith(REJECTION_SUFFIX)))
94 problems.push(`${platform} has no ${REJECTION_SUFFIX} case; a suite of success paths only proves half the bridge`)
95
96 if (!names.some(name => !name.endsWith(REJECTION_SUFFIX) && name !== 'bridge.ready'))
97 problems.push(`${platform} has no success case beyond bridge.ready`)
98
99 const duplicated = names.filter((name, index) => names.indexOf(name) !== index)
100 if (duplicated.length)
101 problems.push(`${platform} lists ${duplicated.join(', ')} more than once`)
102 }
103
104 return problems
105}
106
107const MARKER = 'CRAFT-E2E '
108
109/**
110 * Colour codes the iOS console adds, stripped before matching.
111 *
112 * Built from a char code rather than written as an escape so this file stays
113 * free of literal control characters.
114 */
115const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g')
116
117/**
118 * The first complete JSON object in `text`, or null.
119 *
120 * Taking the rest of the line would be enough on iOS, where the console shows
121 * exactly what the app printed. It is not enough on Android: the page's
122 * `console.log` reaches logcat through Chromium, which wraps it -
123 *
124 * [INFO:CONSOLE(1)] "CRAFT-E2E {...}", source: https://appassets... (1)
125 *
126 * - so the rest of the line carries a trailing quote and a source reference.
127 * Scanning to the matching brace reads the event out of either shape, while
128 * still rejecting a line that was genuinely truncated.
129 */
130function firstJsonObject(text: string): string | null {
131 const start = text.indexOf('{')
132 if (start === -1) return null
133
134 let depth = 0
135 let inString = false
136 let escaped = false
137
138 for (let index = start; index < text.length; index++) {
139 const character = text[index]!
140
141 if (escaped) { escaped = false; continue }
142 if (character === '\\' && inString) { escaped = true; continue }
143 if (character === '"') { inString = !inString; continue }
144 if (inString) continue
145
146 if (character === '{') depth++
147 else if (character === '}') {
148 depth--
149 if (depth === 0) return text.slice(start, index + 1)
150 }
151 }
152
153 return null
154}
155
156/**
157 * Pull the driver's events out of a device log.
158 *
159 * The log is not ours: on iOS it is the app's whole stderr as
160 * `simctl launch --console-pty` saw it, on Android a logcat slice, both
161 * carrying unrelated system chatter and, on iOS, ANSI colouring. So lines are
162 * found by marker rather than by position, colour codes are stripped first,
163 * and a marker line with no readable event is reported rather than skipped -
164 * a garbled event is evidence of a problem, not something to shrug at.
165 *
166 * The page emits each event twice on purpose, through `console.log` and
167 * through the bridge's own `log` action, so that a run whose bridge never
168 * arrived can still say so. Only one of those reaches the log on iOS; both do
169 * on Android. Duplicates are dropped here rather than being left for
170 * `evaluateRun` to trip over.
171 */
172export function parseDriverOutput(text: string): { events: DriverEvent[], malformed: string[] } {
173 const events: DriverEvent[] = []
174 const malformed: string[] = []
175 const seen = new Set<string>()
176
177 for (const rawLine of text.split('\n')) {
178 const line = rawLine.replace(ANSI, '').replace(/\r$/, '')
179 const at = line.indexOf(MARKER)
180 if (at === -1) continue
181
182 const rest = line.slice(at + MARKER.length).trim()
183 const payload = firstJsonObject(rest)
184 if (payload === null) {
185 malformed.push(rest)
186 continue
187 }
188
189 let parsed: DriverEvent
190 try {
191 parsed = JSON.parse(payload) as DriverEvent
192 }
193 catch {
194 malformed.push(payload)
195 continue
196 }
197
198 if (!parsed || typeof parsed !== 'object' || typeof parsed.event !== 'string') {
199 malformed.push(payload)
200 continue
201 }
202
203 if (seen.has(payload)) continue
204 seen.add(payload)
205 events.push(parsed)
206 }
207
208 return { events, malformed }
209}
210
211/**
212 * Has the suite finished, as a complete event rather than a substring?
213 *
214 * The callers kill the app the moment this is true, so matching `"event":"done"`
215 * in a half-written line would truncate the very event being waited for, and
216 * the truncation would then be reported as a malformed event - a passing run
217 * failing on a race. Requiring the object to parse means the line is whole.
218 */
219export function hasTerminated(text: string): boolean {
220 return parseDriverOutput(text).events.some(event => event.event === 'done' || event.event === 'fatal')
221}
222
223export interface RunVerdict {
224 ok: boolean
225 /** One line per reason the run is not a pass. Empty when ok. */
226 failures: string[]
227 planned: string[]
228 passed: string[]
229 failed: string[]
230}
231
232/**
233 * Decide whether a captured run is a pass.
234 *
235 * Silence is the thing being guarded against. A run that produced no output
236 * at all, or announced a plan and then died, or ran every case it planned but
237 * planned only one - each of those reads as "nothing went wrong" to a naive
238 * reader, and each is a failure here.
239 */
240export function evaluateRun(platform: MobilePlatform, text: string, expectedRun?: string): RunVerdict {
241 const { events, malformed } = parseDriverOutput(text)
242 const failures: string[] = []
243
244 for (const line of malformed)
245 failures.push(`driver emitted an unparseable event: ${line}`)
246
247 const fatal = events.find((event): event is FatalEvent => event.event === 'fatal')
248 if (fatal) failures.push(`driver reported a fatal condition: ${fatal.reason}`)
249
250 const plan = events.find((event): event is PlanEvent => event.event === 'plan')
251 const cases = events.filter((event): event is CaseEvent => event.event === 'case')
252 const done = events.find((event): event is DoneEvent => event.event === 'done')
253
254 const planned = plan?.cases ?? []
255 const passed = cases.filter(entry => entry.status === 'pass').map(entry => entry.name)
256 const failed = cases.filter(entry => entry.status === 'fail').map(entry => entry.name)
257
258 if (!plan) {
259 // No plan means the page never reached the bridge - the app crashed, the
260 // page never loaded, or the bridge refused it. Everything below would be
261 // vacuously true, so say this and stop adding noise.
262 failures.push('the test page never announced a plan; it did not reach the native bridge')
263 return { ok: false, failures, planned, passed, failed }
264 }
265
266 if (plan.platform !== platform)
267 failures.push(`the app reports platform ${plan.platform}, expected ${platform}`)
268
269 // Android reads its transcript out of the device's logcat ring buffer, which
270 // survives an uninstall and is cleared only on a best-effort basis. Without
271 // this, a leg whose app printed nothing at all could be evaluated against —
272 // and pass on — the previous run's output still sitting in the buffer.
273 if (expectedRun !== undefined && plan.run !== expectedRun)
274 failures.push(`the transcript is from run ${JSON.stringify(plan.run)}, not ${JSON.stringify(expectedRun)}; this is stale output, not this run's`)
275
276 for (const required of REQUIRED_CASES[platform]) {
277 if (!planned.includes(required))
278 failures.push(`required case ${required} is not in the suite the page ran`)
279 }
280
281 for (const name of planned) {
282 if (!cases.some(entry => entry.name === name))
283 failures.push(`case ${name} was planned but never reported; the suite stopped early`)
284 }
285
286 for (const entry of cases) {
287 if (entry.status === 'fail')
288 failures.push(`case ${entry.name} failed: ${entry.detail || 'no detail'}`)
289 // Neither a pass nor a fail is not a third outcome to be tolerated. A case
290 // reporting anything else satisfies "was planned and did report" while
291 // never appearing in `passed`, which would leave it unasserted inside a
292 // green run - the precise shape of failure this harness exists to remove.
293 else if (entry.status !== 'pass')
294 failures.push(`case ${entry.name} reported status ${JSON.stringify(entry.status)}, which is neither pass nor fail`)
295 }
296
297 if (!done)
298 failures.push('the suite never reported done')
299 else if (done.failed !== failed.length || done.passed !== passed.length)
300 failures.push(`done says ${done.passed} passed / ${done.failed} failed, transcript shows ${passed.length} / ${failed.length}`)
301
302 return { ok: failures.length === 0, failures, planned, passed, failed }
303}
304
305/**
306 * The actions the Zig dispatcher saw, by name.
307 *
308 * Zig and the platform shim answer the same actions with the same shapes -
309 * that is the point of the hand-off design - so the only way to tell them
310 * apart from outside is what Zig's own logger writes
311 * (`packages/zig/src/ios_dispatch.zig`). With no archive linked, `dlsym`
312 * misses and none of this is emitted at all, which is what separates the two
313 * iOS legs.
314 *
315 * By name rather than by count, because the page routes every report through
316 * `craft.log`, and `log` is itself a dispatched action: a bare count is
317 * satisfied by the reporting traffic alone and would pass while Swift served
318 * every capability under test.
319 */
320export function zigDispatchedActions(text: string): string[] {
321 const plain = text.replace(ANSI, '')
322 const seen = new Set<string>()
323 for (const match of plain.matchAll(/craft-bridge dispatch t=\w+ a=(\w+)/g)) seen.add(match[1]!)
324 return [...seen].sort()
325}
326
327/**
328 * The actions Zig refused through its own capability gate.
329 *
330 * A dispatch line proves Zig was *offered* an action - it is logged on entry,
331 * before the decision. This proves Zig *answered* one: `ios_config`'s gate
332 * writes the line, and the rejection the page then sees came off Zig's error
333 * route rather than Swift's.
334 */
335export function zigRefusals(text: string): string[] {
336 const plain = text.replace(ANSI, '')
337 const seen = new Set<string>()
338 for (const match of plain.matchAll(/ios: refusing (\w+);/g)) seen.add(match[1]!)
339 return [...seen].sort()
340}
341
342/**
343 * The capability actions the page exercises, which the runtime leg expects to
344 * see Zig dispatch.
345 *
346 * Kept in step with the cases in driver.html by hand. Derived from the page it
347 * would have to be parsed out of HTML; named here it is at least checkable,
348 * and a case removed from the page fails the leg loudly rather than quietly
349 * reducing what the assertion covers.
350 */
351export const ZIG_TESTED_ACTIONS = [
352 'clipboardRead',
353 'clipboardWrite',
354 'getCurrentPosition',
355 'getDeviceInfo',
356]
357
358/** The one action the suite configures off, so Zig must refuse it by name. */
359export const ZIG_REFUSED_ACTION = 'getCurrentPosition'
scripts/mobile-e2e/support.tsadded+126-0
Changes to scripts/mobile-e2e/support.ts
@@ -0,0 +1,126 @@
1import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
2import { join } from 'node:path'
3
4const HERE = import.meta.dir
5
6export interface CommandOptions {
7 cwd?: string
8 /** Append the command line and its output here, for the evidence upload. */
9 logPath?: string
10 /** Record the failure and carry on, for teardown steps whose failure is not the story. */
11 allowFailure?: boolean
12 stdin?: string
13 /**
14 * Write raw stdout bytes here instead of returning them as text.
15 *
16 * `adb exec-out screencap -p` emits a PNG; decoding that as UTF-8 to hand it
17 * back as a string is how a screenshot becomes an unopenable file.
18 */
19 outPath?: string
20}
21
22export interface CommandResult {
23 exitCode: number
24 stdout: string
25 stderr: string
26}
27
28/**
29 * Run a command, keep its output, and fail loudly by default.
30 *
31 * Everything a leg does to a device goes through here so that the evidence
32 * upload contains the actual command lines. A failing `xcodebuild` whose log
33 * is three screens of Swift diagnostics is useless as an exception message and
34 * invaluable as a file.
35 */
36export async function command(argv: string[], options: CommandOptions = {}): Promise<CommandResult> {
37 const spawned = Bun.spawn(argv, {
38 cwd: options.cwd,
39 stdin: options.stdin === undefined ? 'ignore' : new TextEncoder().encode(options.stdin),
40 stdout: 'pipe',
41 stderr: 'pipe',
42 })
43
44 const [raw, stderr] = await Promise.all([
45 new Response(spawned.stdout).arrayBuffer(),
46 new Response(spawned.stderr).text(),
47 ])
48 const exitCode = await spawned.exited
49
50 if (options.outPath) writeFileSync(options.outPath, new Uint8Array(raw))
51 const stdout = options.outPath ? '' : new TextDecoder().decode(raw)
52
53 if (options.logPath) {
54 appendFileSync(options.logPath, [
55 `$ ${argv.join(' ')}`,
56 options.cwd ? `# cwd ${options.cwd}` : '',
57 stdout,
58 stderr,
59 `# exit ${exitCode}`,
60 '',
61 ].filter(Boolean).join('\n'))
62 }
63
64 if (exitCode !== 0 && !options.allowFailure) {
65 const tail = `${stdout}\n${stderr}`.trim().split('\n').slice(-25).join('\n')
66 throw new Error(`${argv.join(' ')} exited with ${exitCode}\n${tail}`)
67 }
68
69 return { exitCode, stdout, stderr }
70}
71
72/**
73 * Wait for a file to say what we are waiting for.
74 *
75 * `refresh` exists because the two platforms deliver output differently: iOS
76 * streams the app's stderr straight into the file, so there is nothing to do
77 * between polls, while Android has to be asked - `adb logcat -d` dumps and
78 * exits. Streaming logcat instead would mean killing it at exactly the right
79 * moment, and the dump costs nothing.
80 *
81 * Returns false on the deadline rather than throwing, because a run that
82 * produced no terminating event still has a log worth keeping and a screenshot
83 * worth taking - and the caller does both before it decides anything. Throwing
84 * here would skip the evidence for the failure that most needs it.
85 */
86export async function waitForFile(
87 path: string,
88 timeoutMs: number,
89 done: (text: string) => boolean,
90 refresh?: () => Promise<void>,
91): Promise<boolean> {
92 const deadline = Date.now() + timeoutMs
93
94 while (Date.now() < deadline) {
95 if (refresh) await refresh()
96 if (existsSync(path)) {
97 const text = readFileSync(path, 'utf8')
98 if (done(text)) return true
99 }
100 await Bun.sleep(refresh ? 1000 : 250)
101 }
102
103 return false
104}
105
106/**
107 * The test page, with this run's nonce baked in.
108 *
109 * One page serves both platforms: `window.craft` is injected by each runtime
110 * with the same shape, so the suite that runs on a simulator is the same file
111 * that runs on an emulator, and a case can only diverge where it says it does.
112 */
113export function driverPage(nonce: string, platform: string): string {
114 const template = readFileSync(join(HERE, 'driver.html'), 'utf8')
115
116 // Both placeholders checked, because a silently unsubstituted one turns its
117 // case into an assertion about a constant the page chose for itself.
118 for (const placeholder of ['__CRAFT_E2E_NONCE__', '__CRAFT_E2E_PLATFORM__']) {
119 if (!template.includes(placeholder))
120 throw new Error(`driver.html no longer carries the ${placeholder} placeholder`)
121 }
122
123 return template
124 .replaceAll('__CRAFT_E2E_NONCE__', nonce)
125 .replaceAll('__CRAFT_E2E_PLATFORM__', platform)
126}
scripts/mobile-e2e/types.tsadded+30-0
Changes to scripts/mobile-e2e/types.ts
@@ -0,0 +1,30 @@
1export interface RunnerOptions {
2 /** Repository root, used to shorten evidence paths in the report. */
3 root: string
4 /** artifacts/mobile-e2e/<label>, created before any leg runs. */
5 evidenceDir: string
6 /** Scratch space for generated projects; kept under the evidence dir in CI. */
7 workDir: string
8 /** How long one app may run before its leg is called a failure. */
9 timeoutMs: number
10 /** Distinguishes this run's clipboard nonce from a stale one. */
11 runId: string
12 /** Directory holding the Zig iOS simulator archives. */
13 iosRuntimeDir: string | null
14 /** Directory holding the Zig Android `<abi>/libcraft.so` tree. */
15 androidRuntimeDir: string | null
16}
17
18export interface LegOutcome {
19 name: string
20 status: 'passed' | 'failed'
21 /** Empty on a pass. Each entry is one reason, in the words of what was seen. */
22 failures: string[]
23 planned: string[]
24 passed: string[]
25 failed: string[]
26 /** Which actions reached the Zig dispatcher, i.e. which side answered. */
27 zigActions: string[]
28 /** Repo-relative path to this leg's evidence. */
29 evidence: string
30}