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
9 days ago

The mobile E2E workflow has been reporting green while running nothing. Every iOS step that would have tested something was guarded on hashFiles('packages/ios/TestApp/TestApp.xcodeproj') != '', against a project that has never existed in this tree, and the Android job's only test call was gated on packages/android/gradlew, which the generator does not write. Both skipped on every run.

Running a generated app for the first time found why that mattered.

The iOS bridge was dead in the shipping configuration

A generated iOS app with no dev server loads its page from craft://app, served out of the bundle. The message-handler guard added in 68c6d3a called isTrustedOrigin, which trusted file://, https:// and http-on-localhost — and not the scheme the app actually uses. Every call from the app's own page was refused:

Blocked Craft bridge message from untrusted origin: craft://app

Not one window.craft promise ever settled. isTrustedURL knew about craft://app; the bridge guard did not, because the clause lived in the caller rather than in the decision. Both go through one function now.

The code compiles perfectly and simply says no, so no compile gate could have seen it.

And Android could not tell it was an emulator

isEmulator was FINGERPRINT.contains("generic") || contains("emulator"). A current Google APIs image fingerprints as google/sdk_gphone64_x86_64/emu64xa:14/..., which contains neither word, so the first real Android run reported isEmulator is false from inside an API 34 emulator. Build.HARDWARE leads now — an AVD's virtual board is goldfish on the old QEMU pipeline and ranchu on the current one.

bridge_android_device.zig carried a copy of the same broken check, under a test named "the emulator test asks exactly what the Kotlin asks". Both sides ask the same six questions again. That divergence matters more than either implementation: the point of the hand-off is that a page cannot tell which side answered, and two emulator tests that disagree would make isEmulator depend on whether libcraft.so happened to load.

What runs now

Generate an app, drop a self-driving page into its web assets, build against the simulator SDK — which no job did before; the templates gate compiles -sdk iphoneos only — install on a booted device, and read the page's own report back off the console.

Per platform, one success path and one rejection path:

successrejection
iOSclipboard round trip, getDeviceInfo().isSimulatorgetCurrentPosition() with geolocation off → CAPABILITY_DISABLED
Androidclipboard round trip, getDeviceInfo().isEmulator + sdkVersion vs getpropregisterPush() with push off → "Push notifications are disabled"

Two things make those hard to fake. The clipboard nonce is generated by the harness and read back out of the device's own pasteboard afterwards with simctl pbpaste, so a value invented in the page would not be there. And isSimulator comes from Swift's #if targetEnvironment(simulator) — decided by the compiler, not guessable by JavaScript.

Nothing skips

The page announces which cases it will run before running any, so a suite that dies halfway cannot be read as a shorter suite that passed. protocol.ts holds a required-case list per platform that must all appear and all pass, plus a guard that each platform keeps at least one rejection case — a suite of success paths only proves half the bridge. The plan carries the run's nonce, because Android reads its transcript out of a logcat ring buffer that outlives an uninstall. A missing toolchain, an absent device, or an app that printed nothing is a failure with a named cause, and evidence uploads with if-no-files-found: error.

iOS runs twice

Once with the Zig archive linked, once without. Both legs assert the same cases, because the hand-off is meant to be invisible to the page — and that is exactly why the linked leg also checks Zig's own logs. Not a count: log is itself a dispatched action, so six of the ten lines in a passing run are the page's own reporting traffic, and a count is satisfied while Swift serves everything under test. It names the four actions the suite exercises, and requires the refusal line Zig's capability gate writes for getCurrentPosition — the part that proves Zig answered rather than merely being offered the call.

ok   ios-shim — 4/4 cases, no zig
ok   ios-runtime — 4/4 cases, zig served clipboardRead, clipboardWrite, getCurrentPosition, getDeviceInfo, log

Mutation-tested: reverting the origin fix turns the suite red with four independent reasons, including "the test page never announced a plan; it did not reach the native bridge".

Also

  • The header's claim that the iOS Zig sources have not compiled since 1b6f285 is stale — zig build build-ios-all succeeds, and it is now the iOS job's first step. build-ios-all rather than build-ios-simulator because installRuntime resolves every SDK before writing anything and refuses a runtime directory with no device slice.
  • -Dmacos-sdk is dropped from the iOS build: the iOS steps resolve their SDK through xcrun at configure time, and only desktop and host-test artifacts read that option.
  • Two path filters pointed at packages/zig/src/ios/** and .../android/**, neither of which exists; the mobile sources are flat files under src/.
  • packages/ios/fixtures/zig-slice — 435 lines of assertions against the Zig runtime on a simulator, passing, wired into no workflow — now runs too.
  • Every job gets a timeout-minutes; none had one.
  • orderSimulators sorted booted devices ahead of everything without looking at the runtime, so an open Apple Watch or Apple TV simulator would have been handed back as the place to install an iOS build. craft ios run picks its device the same way.
  • The summary job treated cancelled as a failure, so pushing twice in quick succession would have put a red X on the cancelled run.
  • scripts/mobile-e2e/protocol.test.ts is run by a job now. It was not: every bun test in this repo is scoped to a package directory or one explicit file, so the ratchet guarding the case list was itself unenforced.

Then the suite proved its own worth: Android was running no Zig at all

Closes #200 and #201.

Nothing copied libcraft.so into a generated app. CraftNative calls System.loadLibrary("craft") inside a try/catch and sets isAvailable = false on the UnsatisfiedLinkError, so every @JavascriptInterface method fell through to Kotlin — by design, so an app with no runtime still works, which is exactly why nobody noticed that every generated app was in that state. All 38 bridge_android_*.zig modules were built by CI and loaded by nothing.

init({ runtimeDir }) installs it now, into app/src/main/jniLibs/<abi>/libcraft.so — AGP's default jniLibs.srcDirs, so no Gradle template has to learn anything, and zig build build-android-all already emits that exact shape. CRAFT_ANDROID_RUNTIME mirrors the iOS builder's knob, null included.

The other half is being able to see it. Zig's std.log went to stderr, which ART discards, so three states were indistinguishable: never shipped, loaded with a bad descriptor, or loaded and bound and the action declined. All three end with the shim answering. android_log.zig routes std.log to logcat under the CraftNative tag — through android.util.Log, not liblog, because this library deliberately links no libc and so can neither call liblog nor dlopen it, while JNI calls are the one thing it can already do.

JNI_OnLoad now reports success too, not just failure — "registered 103 natives on com/craft/runtime/CraftNative" — because otherwise "bound" and "never shipped" are both silent, and it is the only line that proves the log channel works rather than merely being quiet.

So the Android suite runs twice as well, and the runtime leg asserts two independent things, since either alone would pass with the runtime absent: the APK carries lib/x86_64/libcraft.so (every emulator is x86_64), and JNI_OnLoad reported binding. The shim leg asserts the negatives of both.

The thread-attach dance moved into jni_runtime.zig on the way, because the reply channel and the log channel both need it — a second copy of something that subtle is how the two drift apart, which is the same failure that had a broken emulator test living in two places.

What this found and did not fix

Filed rather than fixed: #202 (11 iOS dispatch cases drop the callback on a bad argument), #203 (share() is fire-and-forget, so four of its native outcomes cannot reach the page), and #204 (the shipped libcraft.so carries debug info — ~5.5 MB per ABI, unstripped because the generated project configures no NDK for AGP's strip task to use).

Deliberately not included: a true cancellation path. The realistic one — grant CAMERA, openCamera(), then KEYCODE_BACK for RESULT_CANCELED — depends on a camera app being present and on keyevent timing, and a flaky gate is worth less than a narrower dependable one. Both platforms exercise a rejection instead.

Verification

bun run test (Zig suite), bun run typecheck, bun run verify:packages, bun run verify:docs and Pickier all pass, as do 31 unit tests over the report protocol, 28 over the iOS builder and 61 over the Android builder.

On devices, from this PR's CI:

ok   ios-shim        — 4/4 cases, no zig
ok   ios-runtime     — 4/4 cases, zig served clipboardRead, clipboardWrite, getCurrentPosition, getDeviceInfo, log
ok   android-shim    — 4/4 cases, no zig
ok   android-runtime — 4/4 cases, zig served registered:103

and in the runtime leg's logcat, the first time the Zig library has loaded on Android at all:

I CraftNative: craft: libcraft.so loaded
I CraftNative: craft: registered 103 natives on com/craft/runtime/CraftNative
W CraftNative: craft: getDeviceInfo fell through to the shim (JavaException)

That last line is worth being plain about. The Android runtime leg proves the library loads and binds — that is what it asserts — not that Zig serves each action the way the iOS leg proves. getDeviceInfo throws a Java exception on its first-ever run on a device and falls back to Kotlin, which the page cannot see and the suite correctly still passes. Fixing it, and tightening the Android leg to assert per-action like iOS, is the follow-up.

26 changed files on the files tab, with 0 review threads.

Sign in to comment on this pull request.