From 2ba36f65620b81cfc6edd76cf12ce90a5f73b4cd Mon Sep 17 00:00:00 2001 From: agra Date: Fri, 19 Jun 2026 22:28:56 +0300 Subject: [PATCH] P5.8: add an Android .apk bundle smoke test to the corpus (first Android bundler coverage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the macOS .app smoke test (1665) for Android. New `.build` `apk` directive (ApkCheck = { out, bundle_id, expect }) cross-compiles via `sx build --target android --apk ... --bundle-id ... -o lib*.so`, then asserts the produced APK's zip entries (AndroidManifest.xml, classes.dex, lib/arm64-v8a/) via `unzip -l`. Build+inspect only — aarch64-linux-android can't execute on the host, so no exit/stdout/stderr snapshot; the apk branch is self-contained and never falls through to stream comparison. Gated on the Android SDK ($ANDROID_HOME / $ANDROID_SDK_ROOT / ~/Library/Android/sdk) AND a real JDK (`javac -version` exit 0 — the macOS /usr/bin/javac stub fails the gate). Missing either → skip cleanly, so a bare-host `zig build test` stays green. Cleanup rm -rf's the apk, staged .so, .stage dir, .unaligned/.aligned intermediates, and the apksigner .idsig sidecar. Verified: default `zig build test` skips 1666 (709 examples ran, 0 failed; 476/476 unit). With JAVA_HOME set to Android Studio's jbr, 1666 RUNS and PASSES (apk built + all three entries found). --- examples/1666-platform-android-apk-smoke.sx | 33 ++++ .../1666-platform-android-apk-smoke.build | 1 + .../1666-platform-android-apk-smoke.exit | 0 src/corpus_run.test.zig | 142 ++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 examples/1666-platform-android-apk-smoke.sx create mode 100644 examples/expected/1666-platform-android-apk-smoke.build create mode 100644 examples/expected/1666-platform-android-apk-smoke.exit diff --git a/examples/1666-platform-android-apk-smoke.sx b/examples/1666-platform-android-apk-smoke.sx new file mode 100644 index 00000000..90215026 --- /dev/null +++ b/examples/1666-platform-android-apk-smoke.sx @@ -0,0 +1,33 @@ +// Android `.apk` bundle smoke test — the corpus's first Android bundler +// coverage. +// +// `sx build --target android --apk --bundle-id -o ` +// cross-compiles for aarch64-linux-android and runs the sx default_pipeline +// → bundle_main, which drives javac/d8/aapt2/zipalign/apksigner to produce a +// signed APK containing `AndroidManifest.xml`, `classes.dex`, +// `lib/arm64-v8a/`, and `META-INF/` signatures. The `.build` `apk` +// directive builds + inspects the zip entries, then cleans up. +// +// GATED on the Android SDK (auto-discovered at $ANDROID_HOME / +// $ANDROID_SDK_ROOT / ~/Library/Android/sdk) + a real JDK on PATH — the macOS +// `/usr/bin/javac` stub is not enough. When either is missing the example +// SKIPS cleanly so a plain `zig build test` stays green. +// +// Build-only: `--target android` is a cross-compile, so the APK can't run on +// the build host. Runtime launch is validated manually on an emulator/device. +// +// Shape mirrors 1424 (a `#jni_main` Activity whose `onCreate` calls +// `super.onCreate(b)`, so the app is well-formed). + +#import "modules/std.sx"; +#import "modules/build.sx"; + +Bundle :: #jni_class("android/os/Bundle") extern { } + +SxApp :: #jni_main #jni_class("co/swipelab/sxapksmoke/SxApp") { + onCreate :: (self: *Self, b: *Bundle) { + super.onCreate(b); + } +} + +main :: () -> i32 { 0 } diff --git a/examples/expected/1666-platform-android-apk-smoke.build b/examples/expected/1666-platform-android-apk-smoke.build new file mode 100644 index 00000000..3024e5e5 --- /dev/null +++ b/examples/expected/1666-platform-android-apk-smoke.build @@ -0,0 +1 @@ +{ "apk": { "out": ".sx-tmp/1666-platform-android-apk-smoke.apk", "bundle_id": "co.swipelab.sxapksmoke", "expect": ["AndroidManifest.xml", "classes.dex", "lib/arm64-v8a/"] } } diff --git a/examples/expected/1666-platform-android-apk-smoke.exit b/examples/expected/1666-platform-android-apk-smoke.exit new file mode 100644 index 00000000..e69de29b diff --git a/src/corpus_run.test.zig b/src/corpus_run.test.zig index 053bf730..2a83b58c 100644 --- a/src/corpus_run.test.zig +++ b/src/corpus_run.test.zig @@ -191,6 +191,12 @@ const BuildConfig = struct { /// on any other host the example is SKIPPED (the bundler would take a different /// per-OS branch / fail codesign). bundle: ?BundleCheck = null, + /// Android `.apk` bundle smoke-test directive. Cross-compiles for + /// aarch64-linux-android (so the APK can't be executed here — build+inspect + /// only, no exit/stdout/stderr snapshot). GATED on the Android SDK + + /// a real JDK being available; when either is missing the example SKIPS + /// cleanly so a plain `zig build test` on a bare host stays green. + apk: ?ApkCheck = null, }; const BundleCheck = struct { @@ -198,6 +204,14 @@ const BundleCheck = struct { expect: []const []const u8, }; +const ApkCheck = struct { + /// Repo-relative output `.apk` path (conventionally under `.sx-tmp/`). + out: []const u8, + bundle_id: []const u8, + /// Zip entries asserted present (substring match against `unzip -l` output). + expect: []const []const u8, +}; + fn parseBuildConfig(a: std.mem.Allocator, text: []const u8) !BuildConfig { return std.json.parseFromSliceLeaky(BuildConfig, a, text, .{}); } @@ -263,6 +277,59 @@ fn withTarget(a: std.mem.Allocator, base: []const []const u8, target: ?[]const u return v; } +/// True when an Android SDK directory is discoverable: `$ANDROID_HOME`, then +/// `$ANDROID_SDK_ROOT`, then `~/Library/Android/sdk`. The first whose `dir` +/// exists wins. +fn envVar(key: []const u8) ?[]const u8 { + return std.process.Environ.getPosix(currentEnviron(), key); +} + +fn androidSdkAvailable(a: std.mem.Allocator, io: std.Io) bool { + const env_keys = [_][]const u8{ "ANDROID_HOME", "ANDROID_SDK_ROOT" }; + for (env_keys) |key| { + const val = envVar(key) orelse continue; + if (val.len == 0) continue; + if (std.Io.Dir.access(.cwd(), io, val, .{})) |_| return true else |_| {} + } + // Fall back to the default macOS install location under $HOME. + const home = envVar("HOME") orelse return false; + if (home.len == 0) return false; + const path = std.fs.path.join(a, &.{ home, "Library/Android/sdk" }) catch return false; + if (std.Io.Dir.access(.cwd(), io, path, .{})) |_| return true else |_| {} + return false; +} + +/// True when a real JDK `javac` is on PATH. The macOS `/usr/bin/javac` stub +/// returns non-zero ("Unable to locate a Java Runtime") when no JDK is +/// installed, so we require `javac -version` to exit 0. +fn jdkAvailable(a: std.mem.Allocator, io: std.Io) bool { + const res = std.process.run(a, io, .{ + .argv = &.{ "javac", "-version" }, + .timeout = deadline(io), + }) catch return false; + return termCode(res.term) == 0; +} + +/// Remove the APK, the staged `.so`, and the bundler's intermediates +/// (`.stage` dir, `.unaligned`/`.aligned`) so a smoke test leaves no +/// litter. Best-effort `/bin/rm -rf` — failures are ignored. +fn cleanupApk(a: std.mem.Allocator, io: std.Io, out_abs: []const u8, so_abs: []const u8) void { + const targets = [_][]const u8{ + out_abs, + so_abs, + std.fmt.allocPrint(a, "{s}.stage", .{out_abs}) catch return, + std.fmt.allocPrint(a, "{s}.unaligned", .{out_abs}) catch return, + std.fmt.allocPrint(a, "{s}.aligned", .{out_abs}) catch return, + std.fmt.allocPrint(a, "{s}.idsig", .{out_abs}) catch return, // apksigner sidecar + }; + for (targets) |t| { + _ = std.process.run(a, io, .{ + .argv = &.{ "/bin/rm", "-rf", t }, + .timeout = deadline(io), + }) catch {}; + } +} + /// Run every `/expected/*.exit` test. Appends a formatted diagnostic to /// `failures` (owned by `fail_gpa`) for each mismatch. Returns the number of /// tests actually run (markers whose `.sx` is missing are skipped). @@ -353,12 +420,80 @@ fn sweepRoot( std.debug.print("[corpus-run] skip {s} (bundle smoke test — macOS host only)\n", .{name}); continue; } + // An Android `.apk` smoke test needs the Android SDK AND a real JDK. + // Missing either → skip (so a bare-host `zig build test` stays green). + if (cfg.apk != null and !(androidSdkAvailable(a, io) and jdkAvailable(a, io))) { + ran -= 1; + skipped += 1; + std.debug.print("[corpus-run] skip {s} (no Android SDK/JDK)\n", .{name}); + continue; + } const is_aot = cfg.aot; // An example pinned to a non-host target cannot execute here; it routes // to ir-only mode (verify via `sx ir` only — see the first arm below). const ir_only = if (cfg.target) |t| !hostMatchesTarget(t) else false; + // Android `.apk` smoke test: self-contained build+inspect branch. We + // cross-compile for aarch64-linux-android (can't run on the host), so + // there is NO exit/stdout/stderr/ir snapshot — this branch asserts the + // produced APK's zip entries and then `continue`s, never falling through + // to the stream snapshot comparison below. + if (cfg.apk) |ac| { + const out_abs = try std.fs.path.join(a, &.{ repo_root, ac.out }); + // Android requires the shared-lib basename to start with `lib`. + const so_abs = try std.fmt.allocPrint(a, "{s}/.sx-tmp/libsxapk_{s}.so", .{ repo_root, name }); + const build_res = std.process.run(a, io, .{ + .argv = &.{ + corpus_paths.sx_exe, "build", + "--target", "android", + "--apk", out_abs, + "--bundle-id", ac.bundle_id, + "-o", so_abs, + rel_path, + }, + .cwd = .{ .path = repo_root }, + .timeout = deadline(io), + }) catch |err| { + try failures.append(fail_gpa, try std.fmt.allocPrint(fail_gpa, "{s}: `sx build --target android` {s}{s}", .{ + name, @errorName(err), if (err == error.Timeout) " (>10s)" else "", + })); + continue; + }; + if (termCode(build_res.term) != 0) { + try failures.append(fail_gpa, try std.fmt.allocPrint(fail_gpa, "{s}: apk build failed (exit {d})\n{s}", .{ + name, termCode(build_res.term), trimNl(build_res.stderr), + })); + cleanupApk(a, io, out_abs, so_abs); + continue; + } + // Inspect the APK's zip entries via `unzip -l`; each `expect` entry + // must appear as a substring of the listing. + const list_res = std.process.run(a, io, .{ + .argv = &.{ "unzip", "-l", out_abs }, + .cwd = .{ .path = repo_root }, + .timeout = deadline(io), + }) catch |err| { + try failures.append(fail_gpa, try std.fmt.allocPrint(fail_gpa, "{s}: `unzip -l` {s}", .{ name, @errorName(err) })); + cleanupApk(a, io, out_abs, so_abs); + continue; + }; + if (termCode(list_res.term) != 0) { + try failures.append(fail_gpa, try std.fmt.allocPrint(fail_gpa, "{s}: `unzip -l` failed (exit {d}) — apk not produced?\n{s}", .{ + name, termCode(list_res.term), trimNl(list_res.stderr), + })); + cleanupApk(a, io, out_abs, so_abs); + continue; + } + for (ac.expect) |entry| { + if (std.mem.indexOf(u8, list_res.stdout, entry) == null) { + try failures.append(fail_gpa, try std.fmt.allocPrint(fail_gpa, "{s}: apk missing entry '{s}' in {s}", .{ name, entry, ac.out })); + } + } + cleanupApk(a, io, out_abs, so_abs); + continue; + } + var act_exit: u32 = undefined; var act_out: []const u8 = undefined; var act_err: []const u8 = undefined; @@ -615,6 +750,13 @@ test "parseBuildConfig: defaults, fields, unknown key" { try std.testing.expectEqual(@as(usize, 2), bnd.bundle.?.expect.len); try std.testing.expectEqualStrings("Contents/Info.plist", bnd.bundle.?.expect[1]); + const apk = try parseBuildConfig(a, "{ \"apk\": { \"out\": \".sx-tmp/x.apk\", \"bundle_id\": \"co.example.x\", \"expect\": [\"AndroidManifest.xml\", \"classes.dex\", \"lib/arm64-v8a/\"] } }"); + try std.testing.expect(apk.apk != null); + try std.testing.expectEqualStrings(".sx-tmp/x.apk", apk.apk.?.out); + try std.testing.expectEqualStrings("co.example.x", apk.apk.?.bundle_id); + try std.testing.expectEqual(@as(usize, 3), apk.apk.?.expect.len); + try std.testing.expectEqualStrings("lib/arm64-v8a/", apk.apk.?.expect[2]); + // Unknown key is a loud error, not a silent ignore. try std.testing.expectError(error.UnknownField, parseBuildConfig(a, "{ \"bogus\": 1 }")); }