P5.8: add an Android .apk bundle smoke test to the corpus (first Android bundler coverage)
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).
This commit is contained in:
33
examples/1666-platform-android-apk-smoke.sx
Normal file
33
examples/1666-platform-android-apk-smoke.sx
Normal file
@@ -0,0 +1,33 @@
|
||||
// Android `.apk` bundle smoke test — the corpus's first Android bundler
|
||||
// coverage.
|
||||
//
|
||||
// `sx build --target android --apk <out.apk> --bundle-id <id> -o <lib.so>`
|
||||
// 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/<lib.so>`, 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 }
|
||||
1
examples/expected/1666-platform-android-apk-smoke.build
Normal file
1
examples/expected/1666-platform-android-apk-smoke.build
Normal file
@@ -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/"] } }
|
||||
@@ -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
|
||||
/// (`<apk>.stage` dir, `<apk>.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 `<root>/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 }"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user